实现M002 V4入站写入新模型

This commit is contained in:
andy
2026-07-19 00:54:58 +07:00
parent e6fbd7a111
commit e59ac2f3bf
17 changed files with 1256 additions and 115 deletions

View File

@@ -1030,6 +1030,172 @@ class SuperAgentTaskResultControllerTest {
AND ai_payload_json LIKE '%"v4_message_event"%'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(2L);
Long v4OrderTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
AND order_ref = 'order-1'
AND order_context_index = 1
AND target_booking_type = 'GROUP'
AND target_locator_type = 'GROUP_CODE'
AND target_locator_value = 'GRP-V4-001'
AND target_resolution_status = 'RESOLVED'
AND order_task_status = 'OPEN'
""", Long.class, source.inboxId());
assertThat(v4OrderTaskCount).isEqualTo(1L);
List<String> v4CardTypes = jdbcTemplate.queryForList("""
SELECT card_type
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
ORDER BY card_sort_order, source_event_index
""", String.class, source.inboxId());
assertThat(v4CardTypes).containsExactly(
"SOURCE_MESSAGE_DISPLAY",
"BASIC_INFORMATION",
"ROOM_INFORMATION",
"PAYMENT");
Long v4CardStatusCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND (
(card_type = 'SOURCE_MESSAGE_DISPLAY' AND card_status = 'READONLY' AND source_event_index = 0)
OR (card_type = 'BASIC_INFORMATION' AND card_status = 'PENDING_CONFIRM' AND source_event_index = 0)
OR (card_type = 'ROOM_INFORMATION' AND card_status = 'PENDING_CONFIRM' AND source_event_index = 1)
OR (card_type = 'PAYMENT' AND card_status = 'PENDING_CONFIRM' AND source_event_index = 2)
)
""", Long.class, source.inboxId());
assertThat(v4CardStatusCount).isEqualTo(4L);
String sourceDisplayPayload = jdbcTemplate.queryForObject("""
SELECT display_payload_json
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'SOURCE_MESSAGE_DISPLAY'
LIMIT 1
""", String.class, source.inboxId());
assertThat(sourceDisplayPayload)
.contains("att-pay-1")
.contains("payment-slip.jpg")
.doesNotContain("https://oss.example.test");
mockMvc.perform(signedPost(body, "nonce-v4-business-root-replay-001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.idempotent_replay").value(true));
Long replayOrderTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
Long replayCardCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(replayOrderTaskCount).isEqualTo(1L);
assertThat(replayCardCount).isEqualTo(4L);
}
@Test
void shouldCreateV4OrderTasksForMultipleOrderRefs() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-two-order-refs-001");
String body = v4TwoOrderRefsBody("mail-v4-two-order-refs-001");
mockMvc.perform(signedPost(body, "nonce-v4-two-order-refs-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2))
.andExpect(jsonPath("$.items[0].execution_order").value(1))
.andExpect(jsonPath("$.items[1].execution_order").value(1));
List<String> orderRefs = jdbcTemplate.queryForList("""
SELECT order_ref
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
ORDER BY order_context_index
""", String.class, source.inboxId());
Long resolvedOrderTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
AND target_resolution_status = 'RESOLVED'
""", Long.class, source.inboxId());
Long cardCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(orderRefs).containsExactly("order-1", "order-2");
assertThat(resolvedOrderTaskCount).isEqualTo(2L);
assertThat(cardCount).isEqualTo(6L);
}
@Test
void shouldKeepV4BookingCodeOrderTaskUnresolvedUntilBindingCheckpoint() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-fit-booking-code-001");
String body = v4FitBookingCodeBody("mail-v4-fit-booking-code-001");
mockMvc.perform(signedPost(body, "nonce-v4-fit-booking-code-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY"));
Long unresolvedCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
AND target_locator_type = 'BOOKING_CODE'
AND target_locator_value = 'BK-V4-FIT-001'
AND target_resolution_status = 'UNRESOLVED'
AND order_id IS NULL
""", Long.class, source.inboxId());
assertThat(unresolvedCount).isEqualTo(1L);
}
@Test
void shouldChooseResolvedV4TargetWhenEarlierEventIsUnresolved() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unresolved-then-resolved-001");
String body = v4UnresolvedThenResolvedBody("mail-v4-unresolved-then-resolved-001");
mockMvc.perform(signedPost(body, "nonce-v4-unresolved-then-resolved-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2))
.andExpect(jsonPath("$.items[0].route_code").value("R04_UPDATE_BOOKING_AMENDMENT_REVIEW"))
.andExpect(jsonPath("$.items[1].route_code").value("R10_PAYMENT_EVIDENCE_NORMAL"));
Long resolvedCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
AND order_ref = 'order-1'
AND target_locator_type = 'GROUP_CODE'
AND target_locator_value = 'GRP-V4-RESOLVED-LATER-001'
AND target_resolution_status = 'RESOLVED'
AND order_id IS NOT NULL
""", Long.class, source.inboxId());
assertThat(resolvedCount).isEqualTo(1L);
}
@Test
void shouldMarkV4BasicInformationCardReviewRequired() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-basic-review-001");
String body = v4BasicInformationReviewBody("mail-v4-basic-review-001");
mockMvc.perform(signedPost(body, "nonce-v4-basic-review-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(1));
Long basicReviewCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'BASIC_INFORMATION'
AND card_status = 'REVIEW_REQUIRED'
AND review_status = 'PENDING'
""", Long.class, source.inboxId());
assertThat(basicReviewCount).isEqualTo(1L);
}
@Test
@@ -1080,10 +1246,23 @@ class SuperAgentTaskResultControllerTest {
)
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(3L);
List<String> v4CardTypes = jdbcTemplate.queryForList("""
SELECT card_type
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
ORDER BY card_sort_order, source_event_index
""", String.class, source.inboxId());
assertThat(v4CardTypes).containsExactly(
"SOURCE_MESSAGE_DISPLAY",
"BASIC_INFORMATION",
"ROOM_INFORMATION",
"TRACE_RESERVATION_NOTES",
"ROOMING_LIST");
}
@Test
void shouldCreateReadOnlyTaskForV4S10WithoutLegacyResultType() throws Exception {
void shouldCreateSourceNotificationForV4S10WithoutLegacyTask() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-s10-entry-result-001");
String body = v4S10Body("mail-v4-s10-entry-result-001");
@@ -1094,17 +1273,63 @@ class SuperAgentTaskResultControllerTest {
.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"));
.andExpect(jsonPath("$.items[0].task_id").doesNotExist())
.andExpect(jsonPath("$.items[0].task_status").doesNotExist());
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND result_type = 'source_message_review_notification'
AND ai_task_type = 'S10'
AND ai_task_type = 'Message Notification'
AND task_subtype = 'S10'
AND route_code = 'S10'
""", Long.class, source.inboxId());
Long legacyTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
Long sourceNotificationCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_source_notification
WHERE source_message_id = ?
AND route_code = 'S10'
AND notification_status = 'ACK_REQUIRED'
AND raw_payload_json LIKE '%"route_code":"S10"%'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
assertThat(legacyTaskCount).isZero();
assertThat(sourceNotificationCount).isEqualTo(1L);
}
@Test
void shouldCreateSourceNotificationForV4S99WithoutLegacyTask() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-s99-entry-result-001");
String body = v4S99Body("mail-v4-s99-entry-result-001");
mockMvc.perform(signedPost(body, "nonce-v4-s99-entry-result-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.source_message_id").value("mail-v4-s99-entry-result-001"))
.andExpect(jsonPath("$.accepted_count").value(1))
.andExpect(jsonPath("$.items[0].route_code").value("S99"))
.andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION"))
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
Long legacyTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
Long sourceNotificationCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_source_notification
WHERE source_message_id = ?
AND route_code = 'S99'
AND notification_status = 'ACK_REQUIRED'
""", Long.class, source.inboxId());
assertThat(legacyTaskCount).isZero();
assertThat(sourceNotificationCount).isEqualTo(1L);
}
@Test
@@ -1131,8 +1356,20 @@ class SuperAgentTaskResultControllerTest {
AND system_process_category = 'ADAPTER_CONTRACT_ERROR'
AND adapter_error_code = 'V4_ROOT_CONTRACT_INVALID'
""", Long.class, source.inboxId());
Long v4OrderTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
Long v4NotificationCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_source_notification
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
assertThat(adapterErrorCount).isEqualTo(1L);
assertThat(v4OrderTaskCount).isZero();
assertThat(v4NotificationCount).isZero();
}
@Test
@@ -1186,8 +1423,14 @@ class SuperAgentTaskResultControllerTest {
AND system_process_category = 'ADAPTER_CONTRACT_ERROR'
AND adapter_error_code = 'PAYMENT_ATTACHMENT_ID_NOT_FOUND'
""", Long.class, source.inboxId());
Long v4OrderTaskCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_v4_order_task
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(taskCount).isZero();
assertThat(adapterErrorCount).isEqualTo(1L);
assertThat(v4OrderTaskCount).isZero();
}
@Test
@@ -1218,6 +1461,17 @@ class SuperAgentTaskResultControllerTest {
""", Long.class, source.inboxId());
assertThat(taskCount).isEqualTo(1L);
assertThat(adapterErrorCount).isEqualTo(1L);
List<String> v4CardTypes = jdbcTemplate.queryForList("""
SELECT card_type
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
ORDER BY card_sort_order, source_event_index
""", String.class, source.inboxId());
assertThat(v4CardTypes).containsExactly(
"SOURCE_MESSAGE_DISPLAY",
"BASIC_INFORMATION",
"ROOM_INFORMATION");
}
@Test
@@ -3509,6 +3763,243 @@ class SuperAgentTaskResultControllerTest {
""".formatted(externalSourceMessageId);
}
private String v4TwoOrderRefsBody(String externalSourceMessageId) {
return """
{
"route_code": null,
"source_message": {
"source_message_id": "%s",
"conversation_id": "thread-v4-two-order-refs-001",
"subject": "Two order refs",
"sender": "agent@example.test",
"sent_at": "2026-07-18T02:10:00Z",
"body": "Please create one group and one FIT booking.",
"body_content_type": "text/plain",
"attachments": []
},
"order_contexts": [
{
"order_ref": "order-1",
"basic_information": {
"account_code": "QBD_TRAVEL",
"manual_review": null
}
},
{
"order_ref": "order-2",
"basic_information": {
"account_code": "LIAN_TAI",
"manual_review": null
}
}
],
"message_events": [
{
"order_ref": "order-1",
"event_type": "NEW_BOOKING",
"target_order": {
"booking_type": "GROUP",
"locator_type": "GROUP_CODE",
"locator_value": "GRP-V4-MULTI-001"
},
"arrival_date": "2026-08-01",
"departure_date": "2026-08-03",
"rate_code": "BAR",
"booking_scenario": "STANDARD",
"room_items": [
{
"room_type_code": "TWN",
"room_count": 2
}
],
"manual_review": null
},
{
"order_ref": "order-2",
"event_type": "NEW_BOOKING",
"target_order": {
"booking_type": "FIT",
"locator_type": "CONFIRMATION_NUMBER",
"locator_value": "CNF-V4-MULTI-002"
},
"arrival_date": "2026-08-05",
"departure_date": "2026-08-06",
"rate_code": "BAR",
"booking_scenario": "STANDARD",
"guest_name": "TEST GUEST",
"room_items": [
{
"room_type_code": "KING",
"room_count": 1
}
],
"manual_review": null
}
]
}
""".formatted(externalSourceMessageId);
}
private String v4FitBookingCodeBody(String externalSourceMessageId) {
return """
{
"route_code": null,
"source_message": {
"source_message_id": "%s",
"conversation_id": "thread-v4-fit-booking-code-001",
"subject": "FIT booking code",
"sender": "agent@example.test",
"sent_at": "2026-07-18T02:10:00Z",
"body": "Please create or locate FIT booking by booking code.",
"body_content_type": "text/plain",
"attachments": []
},
"order_contexts": [
{
"order_ref": "order-1",
"basic_information": {
"account_code": "QBD_TRAVEL",
"manual_review": null
}
}
],
"message_events": [
{
"order_ref": "order-1",
"event_type": "NEW_BOOKING",
"target_order": {
"booking_type": "FIT",
"locator_type": "BOOKING_CODE",
"locator_value": "BK-V4-FIT-001"
},
"arrival_date": "2026-08-05",
"departure_date": "2026-08-06",
"rate_code": "BAR",
"booking_scenario": "STANDARD",
"guest_name": "BOOKING CODE GUEST",
"room_items": [
{
"room_type_code": "KING",
"room_count": 1
}
],
"manual_review": null
}
]
}
""".formatted(externalSourceMessageId);
}
private String v4UnresolvedThenResolvedBody(String externalSourceMessageId) {
return """
{
"route_code": null,
"source_message": {
"source_message_id": "%s",
"conversation_id": "thread-v4-unresolved-then-resolved-001",
"subject": "Unresolved event followed by payment",
"sender": "agent@example.test",
"sent_at": "2026-07-18T02:10:00Z",
"body": "Please update something; payment evidence attached.",
"body_content_type": "text/plain",
"attachments": [
{
"id": "att-pay-1",
"name": "payment-slip.jpg",
"content_type": "image/jpeg",
"url": "https://oss.example.test/payment-slip.jpg",
"size": 251524
}
]
},
"order_contexts": [
{
"order_ref": "order-1",
"basic_information": {
"account_code": "QBD_TRAVEL",
"manual_review": null
}
}
],
"message_events": [
{
"order_ref": "order-1",
"event_type": "UPDATE_BOOKING",
"target_order": {
"booking_type": null,
"locator_type": null,
"locator_value": null
},
"after": {
"arrival_date": "2026-08-07"
},
"manual_review": true
},
{
"order_ref": "order-1",
"event_type": "PAYMENT",
"target_order": {
"booking_type": "GROUP",
"locator_type": "GROUP_CODE",
"locator_value": "GRP-V4-RESOLVED-LATER-001"
},
"attachment_ids": ["att-pay-1"],
"manual_review": null
}
]
}
""".formatted(externalSourceMessageId);
}
private String v4BasicInformationReviewBody(String externalSourceMessageId) {
return """
{
"route_code": null,
"source_message": {
"source_message_id": "%s",
"conversation_id": "thread-v4-basic-review-001",
"subject": "Basic information needs review",
"sender": "agent@example.test",
"sent_at": "2026-07-18T02:10:00Z",
"body": "Account code is not clear.",
"body_content_type": "text/plain",
"attachments": []
},
"order_contexts": [
{
"order_ref": "order-1",
"basic_information": {
"account_code": null,
"manual_review": true
}
}
],
"message_events": [
{
"order_ref": "order-1",
"event_type": "NEW_BOOKING",
"target_order": {
"booking_type": "GROUP",
"locator_type": "GROUP_CODE",
"locator_value": "GRP-V4-BASIC-REVIEW-001"
},
"arrival_date": "2026-08-01",
"departure_date": "2026-08-03",
"rate_code": "BAR",
"booking_scenario": "STANDARD",
"room_items": [
{
"room_type_code": "TWN",
"room_count": 2
}
],
"manual_review": null
}
]
}
""".formatted(externalSourceMessageId);
}
private String v4CancelTraceRoomingListBody(String externalSourceMessageId) {
return """
{
@@ -3595,6 +4086,26 @@ class SuperAgentTaskResultControllerTest {
""".formatted(externalSourceMessageId);
}
private String v4S99Body(String externalSourceMessageId) {
return """
{
"route_code": "S99",
"source_message": {
"source_message_id": "%s",
"conversation_id": "thread-v4-s99-001",
"subject": "Cannot form material package",
"sender": "guest@example.test",
"sent_at": "2026-07-18T02:10:00Z",
"body": "The input does not contain enough business material.",
"body_content_type": "text/plain",
"attachments": []
},
"order_contexts": [],
"message_events": []
}
""".formatted(externalSourceMessageId);
}
private String v4MalformedMissingOrderContextsBody(String externalSourceMessageId) {
return """
{