完成 M002 V3 同卡复核解阻

This commit is contained in:
andy
2026-07-11 20:11:03 +08:00
parent 6416c1b78a
commit 705f779b32
21 changed files with 1150 additions and 23 deletions

View File

@@ -1141,6 +1141,8 @@ class SuperAgentTaskResultControllerTest {
.value("EVENT_CONTRACT_ERROR"))
.andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.contract_errors[0].code")
.value("unsupported_contract"))
.andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.case_keys").doesNotExist())
.andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.extracted_fields").doesNotExist())
.andExpect(jsonPath("$.unhandled_intents.length()").value(1))
.andExpect(jsonPath("$.unhandled_intents[0].route_code")
.value("R42_UNHANDLED_CURRENT_INTENT"))
@@ -1730,6 +1732,258 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.error_code").value("TASK_READ_ONLY"));
}
@Test
void shouldResolveTypeKnownManualReviewOnSameTaskAndMoveToReady() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-resolve-001");
String body = typeKnownManualReviewBody(
"mail-v3-manual-review-resolve-001",
"CNF-V3-MR-RESOLVE-001",
"2026-09-01",
"2026-09-03");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-resolve-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_REVIEW"))
.andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"))
.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");
Long sourceTaskCountBefore = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result_type").value("manual_review"))
.andExpect(jsonPath("$.review_status").value("PENDING"))
.andExpect(jsonPath("$.manual_review.reason_code").value("missing_room_type_code"))
.andExpect(jsonPath("$.manual_review.missing_fields[0]").value("/extracted_fields/pms_room_type_code"));
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"confirmed_order_id": "%s",
"reason": "确认 PMS 房型代码后解阻。",
"field_overrides": [
{
"field_pointer": "/extracted_fields/pms_room_type_code",
"value": "RM3"
}
]
}
""".formatted(orderId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(taskId))
.andExpect(jsonPath("$.order_id").value(orderId))
.andExpect(jsonPath("$.task_status").value("READY"))
.andExpect(jsonPath("$.review_status").value("RESOLVED"))
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
.value("/extracted_fields/pms_room_type_code"))
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
.value("extracted_fields.pms_room_type_code"))
.andExpect(jsonPath("$.review_resolution.field_overrides[0].value").value("RM3"))
.andExpect(jsonPath("$.review_resolution.resolved_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']")
.value("RM3"))
.andExpect(jsonPath("$.opera_operations.length()").value(2));
Long sourceTaskCountAfter = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
String taskStatus = jdbcTemplate.queryForObject("""
SELECT task_status
FROM workflow_reservation_task
WHERE id = ?
""", String.class, Long.valueOf(taskId));
String reviewStatus = jdbcTemplate.queryForObject("""
SELECT review_status
FROM workflow_reservation_task_card
WHERE task_id = ?
""", String.class, Long.valueOf(taskId));
String reviewResolutionJson = jdbcTemplate.queryForObject("""
SELECT review_resolution_json
FROM workflow_reservation_task_card
WHERE task_id = ?
""", String.class, Long.valueOf(taskId));
String aiPayloadJson = jdbcTemplate.queryForObject("""
SELECT ai_payload_json
FROM workflow_reservation_task_card
WHERE task_id = ?
""", String.class, Long.valueOf(taskId));
Long auditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_audit_log
WHERE task_id = ?
AND action = 'MANUAL_REVIEW_RESOLVE'
""", Long.class, Long.valueOf(taskId));
assertThat(sourceTaskCountAfter).isEqualTo(sourceTaskCountBefore);
assertThat(taskStatus).isEqualTo("READY");
assertThat(reviewStatus).isEqualTo("RESOLVED");
assertThat(reviewResolutionJson).contains("\"field_pointer\":\"/extracted_fields/pms_room_type_code\"");
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"RM3\"");
assertThat(auditCount).isEqualTo(1L);
}
@Test
void shouldRejectGenericConfirmForTypeKnownManualReview() throws Exception {
captureSourceMessage("mail-v3-manual-review-confirm-001");
String body = typeKnownManualReviewBody(
"mail-v3-manual-review-confirm-001",
"CNF-V3-MR-CONFIRM-001",
"2026-09-04",
"2026-09-06");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-confirm-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": {
"extracted_fields.pms_room_type_code": "RM3"
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_RESOLUTION_REQUIRED"));
String taskStatus = jdbcTemplate.queryForObject("""
SELECT task_status
FROM workflow_reservation_task
WHERE id = ?
""", String.class, Long.valueOf(taskId));
assertThat(taskStatus).isEqualTo("PENDING_CONFIRM");
}
@Test
void shouldRejectManualReviewResolutionWhenPointerIsNotEditable() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-pointer-001");
String body = typeKnownManualReviewBody(
"mail-v3-manual-review-pointer-001",
"CNF-V3-MR-POINTER-001",
"2026-09-05",
"2026-09-07");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-pointer-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_pointer": "/manual_review/visible_reason",
"value": "不允许覆盖复核说明"
}
]
}
""".formatted(orderId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"))
.andExpect(jsonPath("$.details[0]").value(containsString("/manual_review/visible_reason")));
Long sourceTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(sourceTaskCount).isEqualTo(1L);
}
@Test
void shouldRejectManualReviewResolutionWhenMissingFieldsAreNotResolved() throws Exception {
captureSourceMessage("mail-v3-manual-review-missing-001");
String body = typeKnownManualReviewBody(
"mail-v3-manual-review-missing-001",
"CNF-V3-MR-MISSING-001",
"2026-09-06",
"2026-09-08");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-missing-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": []
}
""".formatted(orderId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
.andExpect(content().string(containsString("/extracted_fields/pms_room_type_code")));
}
@Test
void shouldRejectManualReviewResolutionWhenConfirmedOrderDoesNotMatchCurrentTask() throws Exception {
SourceMessageCaptureResult firstSource = captureSourceMessage("mail-v3-manual-review-order-001");
String body = typeKnownManualReviewBody(
"mail-v3-manual-review-order-001",
"CNF-V3-MR-ORDER-001",
"2026-09-09",
"2026-09-11");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-order-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[0].task_id");
SourceMessageCaptureResult otherSource = captureSourceMessage("mail-v3-manual-review-other-order-001");
String otherBody = completeNewBookingBody(otherSource.inboxId().toString(), "CNF-V3-MR-OTHER-001");
MvcResult otherResult = mockMvc.perform(signedPost(otherBody, "nonce-v3-manual-review-other-order-001"))
.andExpect(status().isCreated())
.andReturn();
String otherOrderId = com.jayway.jsonpath.JsonPath.read(
otherResult.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_pointer": "/extracted_fields/pms_room_type_code",
"value": "RM3"
}
]
}
""".formatted(otherOrderId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("REVIEW_ORDER_ASSIGNMENT_MISMATCH"));
}
@Test
void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception {
SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001");
@@ -2120,6 +2374,68 @@ class SuperAgentTaskResultControllerTest {
""".formatted(caseKeys));
}
private String typeKnownManualReviewBody(
String externalSourceMessageId,
String confirmationNumber,
String arrivalDate,
String departureDate) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": "Manual review new booking",
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"message_events": [
{
"event_type": "New Booking",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "%s"
},
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "%s",
"departure_date": "%s",
"room_quantity": 2,
"room_type": "Deluxe King"
},
"manual_review": {
"review_record_type": "business_event_review",
"reason_code": "missing_room_type_code",
"visible_reason": "需要人工确认 PMS 房型代码。",
"known_fields": {
"/extracted_fields/booking_object_type": "FIT Reservation",
"/extracted_fields/room_type": "Deluxe King"
},
"missing_fields": [
"/extracted_fields/pms_room_type_code"
],
"blocking_points": [
"缺少 PMS 房型代码,不能进入 OPERA 写入。"
],
"conflicting_points": [],
"suggested_human_actions": [
"查看邮件和附件后补充 PMS 房型代码。"
],
"evidence_to_check": [
"邮件正文房型描述"
]
}
}
],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""".formatted(externalSourceMessageId, confirmationNumber, arrivalDate, departureDate);
}
private String[] createReadyTaskWithTwoOperaOperations(
String externalMessageId,
String nonce,