修复M002 V3字段矩阵迁移问题
This commit is contained in:
@@ -3,14 +3,17 @@ package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 同卡人工复核字段修正请求。field_pointer 使用 RFC 6901 JSON Pointer。
|
||||
* 同卡人工复核字段修正请求。field_pointer 使用 RFC 6901 JSON Pointer,field_path 用于兼容矩阵主路径或旧扁平路径。
|
||||
*
|
||||
* @param fieldPointer 指向任务卡可编辑字段的 JSON Pointer
|
||||
* @param fieldPath 指向任务卡可编辑字段的矩阵路径,支持 P0 主路径和旧扁平路径
|
||||
* @param value 人工修正后的字段值
|
||||
*/
|
||||
public record ManualReviewResolutionFieldOverrideRequest(
|
||||
@JsonProperty("field_pointer")
|
||||
String fieldPointer,
|
||||
@JsonProperty("field_path")
|
||||
String fieldPath,
|
||||
Object value
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -616,6 +616,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
item.put("task_type", route.taskType());
|
||||
item.put("task_subtype", route.taskSubtype());
|
||||
copyIfPresent(event, item, "current_or_history");
|
||||
copyIfPresent(event, item, "relevant_message_excerpt");
|
||||
copyIfPresent(event, item, "case_keys");
|
||||
copyIfPresent(event, item, "extracted_fields");
|
||||
copyIfPresent(event, item, "manual_review");
|
||||
|
||||
@@ -85,6 +85,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
private static final String PAYLOAD_SOURCE_DRAFT = "TASK_DRAFT";
|
||||
private static final String PAYLOAD_SOURCE_CONFIRMATION = "TASK_CONFIRMATION";
|
||||
private static final String PAYLOAD_SOURCE_MANUAL_REVIEW_RESOLUTION = "MANUAL_REVIEW_RESOLUTION";
|
||||
private static final String V3_CATALOG_CODE = "M002V3";
|
||||
private static final String OPERA_OPERATION_CODE_PRECHECK = "SIMULATE_PRECHECK";
|
||||
private static final String OPERA_OPERATION_CODE_WRITE = "SIMULATE_WRITE";
|
||||
private static final int MAX_QUEUE_ORDER_RETRY = 5;
|
||||
@@ -825,12 +826,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
Set<String> seenFieldPaths = new LinkedHashSet<>();
|
||||
for (ManualReviewResolutionFieldOverrideRequest request : requests) {
|
||||
String fieldPointer = trimToNull(request == null ? null : request.fieldPointer());
|
||||
ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
if (!seenPointers.add(fieldPointer) || !seenFieldPaths.add(definition.fieldPath())) {
|
||||
throw reviewPointerDuplicateError(fieldPointer, definition.fieldPath());
|
||||
String fieldPath = trimToNull(request == null ? null : request.fieldPath());
|
||||
ReservationTaskCardFieldDefinition definition =
|
||||
findEditableDefinitionByOverride(definitionByPath, fieldPointer, fieldPath);
|
||||
String resultFieldPointer = fieldPointer != null ? fieldPointer : fieldPointerFor(definition.fieldPath());
|
||||
if (!seenPointers.add(resultFieldPointer) || !seenFieldPaths.add(definition.fieldPath())) {
|
||||
throw reviewPointerDuplicateError(resultFieldPointer, definition.fieldPath());
|
||||
}
|
||||
results.add(new ManualReviewResolutionFieldOverrideResult(
|
||||
fieldPointer,
|
||||
resultFieldPointer,
|
||||
definition.fieldPath(),
|
||||
legacyFieldPathFor(definition.fieldPath()),
|
||||
request.value()));
|
||||
@@ -883,6 +887,48 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按复核请求定位可编辑字段,支持 JSON Pointer、P0 主 field_path 和旧扁平 field_path。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByOverride(
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPointer,
|
||||
String fieldPath) {
|
||||
ReservationTaskCardFieldDefinition pointerDefinition = null;
|
||||
if (fieldPointer != null) {
|
||||
pointerDefinition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
}
|
||||
ReservationTaskCardFieldDefinition pathDefinition = null;
|
||||
if (fieldPath != null) {
|
||||
pathDefinition = findEditableDefinitionByFieldPath(definitionByPath, fieldPath);
|
||||
}
|
||||
if (pointerDefinition != null && pathDefinition != null
|
||||
&& !pointerDefinition.fieldPath().equals(pathDefinition.fieldPath())) {
|
||||
throw reviewPointerError(fieldPointer + " / " + fieldPath);
|
||||
}
|
||||
if (pointerDefinition != null) {
|
||||
return pointerDefinition;
|
||||
}
|
||||
if (pathDefinition != null) {
|
||||
return pathDefinition;
|
||||
}
|
||||
throw reviewPointerError(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按矩阵字段路径查找可编辑字段,先将旧扁平路径归一化到 P0 主路径。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByFieldPath(
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPath) {
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath);
|
||||
ReservationTaskCardFieldDefinition definition = definitionByPath.get(canonicalFieldPath);
|
||||
if (definition == null || !isYes(definition.editable())) {
|
||||
throw reviewPointerError(fieldPath);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 与旧扁平路径。
|
||||
*/
|
||||
@@ -1223,7 +1269,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
}
|
||||
|
||||
/**
|
||||
* 按字段矩阵执行校验。复核解阻场景允许只读证据字段缺失,因为用户无法在该接口补齐。
|
||||
* 按字段矩阵执行校验。P0 V3 业务事件缺省旧 visible_reason 时做窄口兼容,其他只读必填仍要校验。
|
||||
*/
|
||||
private List<String> validateDefinitionValues(
|
||||
ReservationTaskSnapshot task,
|
||||
@@ -1237,7 +1283,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
for (ReservationTaskCardFieldDefinition definition : activeDefinitions) {
|
||||
Object value = valueForField(aiPayload, editedValues, definition.fieldPath());
|
||||
if (validateRequired && isMissingRequired(task, definition, aiPayload, editedValues, value)) {
|
||||
if (allowMissingReadonlyRequired && !isYes(definition.editable())) {
|
||||
if (allowMissingReadonlyRequired && isReadonlyRequiredMissingAllowed(definition, aiPayload)) {
|
||||
continue;
|
||||
}
|
||||
errors.add(definition.fieldPath() + ": 必填字段缺失。");
|
||||
@@ -1252,6 +1298,18 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 0711 P0 业务事件:V3 message_event 没有旧矩阵根 visible_reason 字段,但保留 relevant_message_excerpt 作为证据。
|
||||
*/
|
||||
private boolean isReadonlyRequiredMissingAllowed(
|
||||
ReservationTaskCardFieldDefinition definition,
|
||||
JsonNode aiPayload) {
|
||||
return !isYes(definition.editable())
|
||||
&& "visible_reason".equals(definition.fieldPath())
|
||||
&& V3_CATALOG_CODE.equals(aiPayload.path("catalog_code").asText())
|
||||
&& aiPayload.path("v3_message_event").isObject();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字段定义路径集合,用于最终确认时校验全部展示字段。
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
UPDATE workflow_reservation_task_card
|
||||
SET field_contract_version = '20260711-p0'
|
||||
WHERE field_contract_version = 'code-v1';
|
||||
WHERE field_contract_version = 'code-v1'
|
||||
AND draft_payload_json IS NULL
|
||||
AND confirmed_payload_json IS NULL;
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
@@ -901,6 +902,73 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmV3BusinessTaskWithoutLegacyVisibleReason() throws Exception {
|
||||
captureSourceMessage("mail-v3-confirm-without-legacy-visible-reason-001");
|
||||
String body = """
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "mail-v3-confirm-without-legacy-visible-reason-001",
|
||||
"subject": "New booking without legacy visible reason",
|
||||
"from": null,
|
||||
"cc": [],
|
||||
"received_at": null,
|
||||
"source_channel": "Email"
|
||||
},
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "CNF-V3-NO-VISIBLE-001",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create a new FIT reservation without old root visible reason.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "2026-09-01",
|
||||
"departure_date": "2026-09-03",
|
||||
"room_quantity": 2,
|
||||
"room_type": "Deluxe King",
|
||||
"pms_room_type_code": "RM2"
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
""";
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-confirm-without-visible-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"field_values": {}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['relevant_message_excerpt']")
|
||||
.value("Please create a new FIT reservation without old root visible reason."))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
|
||||
.value("RM2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectEmptyV3BusinessRootWithoutPersistingBatch() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-empty-business-root-001");
|
||||
@@ -1556,6 +1624,61 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(auditCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConfirmWhenReadonlyRequiredEvidenceFieldMissing() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-readonly-required-missing-001");
|
||||
String body = """
|
||||
{
|
||||
"source_message_id": "%s",
|
||||
"ai_task_results": [
|
||||
{
|
||||
"source_event_index": 1,
|
||||
"catalog_code": "S01",
|
||||
"skill_id": "S01_new_booking_skill",
|
||||
"result_type": "normal_task",
|
||||
"task_type": "New Booking",
|
||||
"task_subtype": "new_fit_reservation",
|
||||
"current_or_history": "current",
|
||||
"relevant_message_excerpt": "Please handle booking message.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"case_keys": {"confirmation_number": "CNF-CP5-READONLY-MISSING-001"},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "2026-08-01",
|
||||
"departure_date": "2026-08-02",
|
||||
"room_quantity": 2,
|
||||
"room_type": "Deluxe King",
|
||||
"pms_room_type_code": "RM2"
|
||||
},
|
||||
"additional_operations": [],
|
||||
"idempotency_key": null
|
||||
}
|
||||
],
|
||||
"extraction_warnings": []
|
||||
}
|
||||
""".formatted(source.inboxId());
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-readonly-required-missing-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"field_values": {}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value(containsString("visible_reason")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExecuteFirstOperaSimulationOperationAndRecordAttempt() throws Exception {
|
||||
String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
|
||||
@@ -1915,6 +2038,90 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(auditCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTypeKnownManualReviewFromP0FieldPath() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-field-path-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-field-path-001",
|
||||
"CNF-V3-MR-FIELD-PATH-001",
|
||||
"2026-09-02",
|
||||
"2026-09-04");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-field-path-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].order_id");
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"confirmed_order_id": "%s",
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_path": "extracted_fields.room_items.0.pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/extracted_fields/room_items/0/pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
|
||||
.value("extracted_fields.room_items.0.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
|
||||
.value("RM3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTypeKnownManualReviewFromLegacyFieldPath() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-legacy-field-path-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-legacy-field-path-001",
|
||||
"CNF-V3-MR-LEGACY-FIELD-PATH-001",
|
||||
"2026-09-02",
|
||||
"2026-09-04");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-legacy-field-path-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].order_id");
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"confirmed_order_id": "%s",
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_path": "extracted_fields.pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/extracted_fields/room_items/0/pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
|
||||
.value("extracted_fields.room_items.0.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].legacy_field_path")
|
||||
.value("extracted_fields.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.pms_room_type_code']")
|
||||
.value("RM3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectGenericConfirmForTypeKnownManualReview() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-confirm-001");
|
||||
@@ -2112,6 +2319,18 @@ class SuperAgentTaskResultControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("REVIEW_ORDER_ASSIGNMENT_MISMATCH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMarkHistoricalPayloadRowsAsP0ContractInV18Migration() throws Exception {
|
||||
ClassPathResource migration = new ClassPathResource(
|
||||
"db/migration/V18__update_reservation_field_contract_version.sql");
|
||||
assertThat(migration.exists()).isTrue();
|
||||
|
||||
String sql = migration.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("field_contract_version = 'code-v1'");
|
||||
assertThat(sql).contains("draft_payload_json IS NULL");
|
||||
assertThat(sql).contains("confirmed_payload_json IS NULL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception {
|
||||
SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001");
|
||||
|
||||
Reference in New Issue
Block a user