实现 M002 V3 入站解析与路由基线
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user