实现 M002 V3 入站解析与路由基线

This commit is contained in:
andy
2026-07-11 14:11:18 +08:00
parent c5e72078e5
commit 3589bd99c7
22 changed files with 2306 additions and 72 deletions

View File

@@ -0,0 +1,29 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ReservationAiRouteDefinitionTest {
@Test
void shouldKeepFortyTwoM002V3P0RouteDefinitions() {
assertThat(ReservationAiRouteDefinition.values()).hasSize(42);
}
@Test
void shouldResolveSourceMessageAndBusinessRoutesByStableKeys() {
assertThat(ReservationAiRouteDefinition.findByRouteCode("S10"))
.contains(ReservationAiRouteDefinition.SOURCE_MESSAGE_S10);
assertThat(ReservationAiRouteDefinition.findByTriplet(
"normal_task",
"New Booking",
"new_fit_reservation"))
.contains(ReservationAiRouteDefinition.NEW_FIT_RESERVATION_NORMAL);
assertThat(ReservationAiRouteDefinition.findByTriplet(
"manual_review",
"Payment Evidence",
"payment_evidence"))
.contains(ReservationAiRouteDefinition.PAYMENT_EVIDENCE_REVIEW);
}
}

View File

@@ -616,6 +616,434 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.error_code").value("SOURCE_MESSAGE_NOT_FOUND"));
}
@Test
void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS10Result() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s10-entry-result-001");
String body = """
{
"source_message": {
"source_message_id": "mail-v3-s10-entry-result-001",
"subject": null,
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"route_code": "S10",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "no_booking_action_detected",
"reason_code": "no_booking_action_detected",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true
},
"manual_review": null
}
""";
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s10-entry-result-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value("mail-v3-s10-entry-result-001"))
.andExpect(jsonPath("$.items[0].route_code").value("S10"))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND result_type = 'source_message_review_notification'
AND route_code = 'S10'
AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
""", Long.class, source.inboxId());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE id = ?
AND source_message_id = ?
AND result_type = 'source_message_review_notification'
AND task_subtype = 'S10'
AND queue_participation = 0
""", Long.class, Long.valueOf(taskId), source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
assertThat(taskCount).isEqualTo(1L);
}
@Test
void shouldCreateReadOnlySourceMessageOnlyTaskForStructuredS99Result() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-s99-entry-result-001");
String body = """
{
"source_message": {
"source_message_id": "mail-v3-s99-entry-result-001",
"subject": null,
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"route_code": "S99",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "insufficient_business_material",
"reason_code": "cannot_form_business_material_package",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true
},
"manual_review": {
"reason_code": "cannot_form_business_material_package",
"review_notes": "需要人工查看原邮件。"
}
}
""";
MvcResult result = mockMvc.perform(signedPost(body, "nonce-v3-s99-entry-result-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value("mail-v3-s99-entry-result-001"))
.andExpect(jsonPath("$.items[0].route_code").value("S99"))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_status").value("COMPLETED"))
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(result.getResponse().getContentAsString(), "$.items[0].task_id");
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND result_type = 'source_message_review_notification'
AND route_code = 'S99'
AND system_process_category = 'SOURCE_MESSAGE_NOTIFICATION'
""", Long.class, source.inboxId());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE id = ?
AND source_message_id = ?
AND result_type = 'source_message_review_notification'
AND task_subtype = 'S99'
AND queue_participation = 0
""", Long.class, Long.valueOf(taskId), source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
assertThat(taskCount).isEqualTo(1L);
}
@Test
void shouldCreateBusinessTaskFromV3BusinessRootMessageEvent() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-business-root-001");
String body = """
{
"source_message": {
"source_message_id": "mail-v3-business-root-001",
"subject": "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": "CNF-V3-NEW-001"
},
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "2026-09-01"
},
"manual_review": null
}
],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""";
mockMvc.perform(signedPost(body, "nonce-v3-business-root-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].source_event_index").value(1))
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
.andExpect(jsonPath("$.items[0].system_process_category").value("BUSINESS_TASK"))
.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"))
.andExpect(jsonPath("$.items[0].order_status").value("ACTIVE"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND source_event_index = 1
AND result_type = 'normal_task'
AND ai_task_type = 'New Booking'
AND task_subtype = 'new_fit_reservation'
AND route_code = 'R01_NEW_FIT_RESERVATION_NORMAL'
AND system_process_category = 'BUSINESS_TASK'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldPersistAdapterContractErrorWhenV3EventRouteCannotBeDerived() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-adapter-error-001");
String body = """
{
"source_message": {
"source_message_id": "mail-v3-adapter-error-001",
"subject": "Trace without subtype",
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"message_events": [
{
"event_type": "Trace",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": "GRP-V3-ERR-001",
"confirmation_number": null
},
"extracted_fields": {},
"manual_review": null
}
],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""";
mockMvc.perform(signedPost(body, "nonce-v3-adapter-error-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].source_event_index").value(2))
.andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR"))
.andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_ROUTE_UNSUPPORTED"))
.andExpect(jsonPath("$.items[0].system_task_type").value("ADAPTER_CONTRACT_ERROR"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND result_type = 'adapter_contract_error'
AND system_process_category = 'ADAPTER_CONTRACT_ERROR'
AND adapter_error_code = 'EVENT_ROUTE_UNSUPPORTED'
""", Long.class, source.inboxId());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
assertThat(taskCount).isZero();
}
@Test
void shouldPersistAdapterContractErrorForExplicitV3ContractErrorsAndContinueSiblingEvents() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-contract-error-sibling-001");
String overlongEventType = "Unsupported Event " + "X".repeat(120);
String body = """
{
"source_message": {
"source_message_id": "mail-v3-contract-error-sibling-001",
"subject": "Mixed events",
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"message_events": [
{
"event_type": "%s",
"source_event_index": "E1",
"current_or_history": "current",
"case_keys": {
"group_code": "%s",
"confirmation_number": null
},
"extracted_fields": {},
"contract_errors": [
{
"path": "/extracted_fields",
"code": "unsupported_contract"
}
],
"manual_review": null
},
{
"event_type": "New Booking",
"source_event_index": "E2",
"current_or_history": "current",
"case_keys": {
"group_code": null,
"confirmation_number": "CNF-V3-SIBLING-001"
},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
"manual_review": null
}
],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""".formatted(overlongEventType, "GRP-" + "Y".repeat(180));
mockMvc.perform(signedPost(body, "nonce-v3-contract-error-sibling-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2))
.andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR"))
.andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_ERROR"))
.andExpect(jsonPath("$.items[1].route_code").value("R01_NEW_FIT_RESERVATION_NORMAL"))
.andExpect(jsonPath("$.items[1].system_task_type").value("NEW_BOOKING"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
""", Long.class, source.inboxId());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(2L);
assertThat(taskCount).isEqualTo(1L);
}
@Test
void shouldPersistAdapterContractErrorForIncompleteV3ManualReview() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-incomplete-001");
String body = """
{
"source_message": {
"source_message_id": "mail-v3-manual-review-incomplete-001",
"subject": "Manual review incomplete",
"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": "CNF-V3-MR-001"
},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
},
"manual_review": {
"reason_code": "missing_room_type"
}
}
],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": []
}
""";
mockMvc.perform(signedPost(body, "nonce-v3-manual-review-incomplete-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].system_process_category").value("ADAPTER_CONTRACT_ERROR"))
.andExpect(jsonPath("$.items[0].adapter_error_code").value("MANUAL_REVIEW_CONTRACT_INCOMPLETE"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND adapter_error_code = 'MANUAL_REVIEW_CONTRACT_INCOMPLETE'
""", Long.class, source.inboxId());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
assertThat(taskCount).isZero();
}
@Test
void shouldPersistUnhandledCurrentIntentWithDedicatedProcessCategory() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-unhandled-current-intent-001");
String body = """
{
"source_message": {
"source_message_id": "mail-v3-unhandled-current-intent-001",
"subject": "Unhandled current intent",
"from": null,
"cc": [],
"received_at": null,
"source_channel": "Email"
},
"message_events": [],
"case_candidates": [],
"extraction_warnings": [],
"unhandled_current_intents": [
{
"intent_type": "unsupported_active_request",
"reason_code": "no_task_card_available"
}
]
}
""";
mockMvc.perform(signedPost(body, "nonce-v3-unhandled-current-intent-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].route_code").value("R42_UNHANDLED_CURRENT_INTENT"))
.andExpect(jsonPath("$.items[0].system_process_category").value("UNHANDLED_CURRENT_INTENT"))
.andExpect(jsonPath("$.items[0].adapter_error_code").doesNotExist())
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND route_code = 'R42_UNHANDLED_CURRENT_INTENT'
AND system_process_category = 'UNHANDLED_CURRENT_INTENT'
AND adapter_error_code IS NULL
""", Long.class, source.inboxId());
Long taskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
assertThat(taskCount).isZero();
}
@Test
void shouldRejectUnsupportedContentTypeForTaskResultCallback() throws Exception {
String body = "S000,source-message-unsupported-content-type-001";