实现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

@@ -0,0 +1,31 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* Reservation V4 已通过入站校验并已写入 AI transition 的 event 草稿。
* 用于把 SuperAgent V4 回调从 AI 过渡层继续写入 V4 订单任务和多卡模型。
*/
public record ReservationV4AcceptedEventDraft(
/** V4 包内订单引用。 */
String orderRef,
/** message_events[] 中的一基序号。 */
Integer sourceEventIndex,
/** 当前 event 对应的 AI transition ID。 */
Long aiTransitionId,
/** 旧过渡链路已经解析出的本地订单投影 IDV4 第一版复用该绑定。 */
Long orderId,
/** 目标订单业务类型,例如 GROUP、FIT。 */
String targetBookingType,
/** 目标订单定位类型,例如 GROUP_CODE、BOOKING_CODE、CONFIRMATION_NUMBER。 */
String targetLocatorType,
/** 目标订单定位值,未解决时为空。 */
String targetLocatorValue,
/** V4 业务事件类型。 */
String eventType,
/** 当前 event 是否需要人工复核。 */
boolean manualReviewRequired,
/** 当前业务卡 AI 原始片段 JSON。 */
String aiPayloadJson,
/** 当前业务卡展示快照 JSON。 */
String displayPayloadJson
) {
}

View File

@@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 来源通知创建草稿。第一版用于 S10 纯通知入库。
* Reservation V4 来源通知创建草稿。第一版用于 S10/S99 纯通知入库。
*/
public record ReservationV4SourceNotificationDraft(
/** 酒店 ID。 */
@@ -12,9 +12,9 @@ public record ReservationV4SourceNotificationDraft(
Long sourceMessageId,
/** AI 回调批次 ID。 */
Long aiBatchId,
/** AI transition IDS10 第一版可为空。 */
/** AI transition ID来源通知第一版可为空。 */
Long aiTransitionId,
/** 包级路由码,第一版用于 S10。 */
/** 包级路由码,当前用于 S10/S99。 */
String routeCode,
/** 通知状态。 */
String notificationStatus,

View File

@@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 来源通知快照。用于后续 S10 通知详情和确认接口。
* Reservation V4 来源通知快照。用于后续 S10/S99 通知详情和确认接口。
*/
public record ReservationV4SourceNotificationSnapshot(
/** V4 来源通知 ID。 */
@@ -14,7 +14,7 @@ public record ReservationV4SourceNotificationSnapshot(
Long sourceMessageId,
/** AI 回调批次 ID。 */
Long aiBatchId,
/** AI transition IDS10 第一版可为空。 */
/** AI transition ID来源通知第一版可为空。 */
Long aiTransitionId,
/** 包级路由码。 */
String routeCode,

View File

@@ -6,7 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
/**
* Reservation V4 来源通知实体。第一版用于保存 S10通知,不挂订单任务。
* Reservation V4 来源通知实体。第一版用于保存 S10/S99 来源通知,不挂订单任务。
*/
@TableName("workflow_reservation_v4_source_notification")
public class ReservationV4SourceNotificationEntity {

View File

@@ -80,7 +80,7 @@ public class MybatisReservationV4SourceNotificationRepository implements Reserva
}
/**
* 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。
* 幂等创建 S10/S99 来源通知;并发重复插入时返回已存在记录。
*/
@Override
public ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(
@@ -114,7 +114,7 @@ public class MybatisReservationV4SourceNotificationRepository implements Reserva
}
/**
* 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。
* 按 version 乐观锁更新来源通知状态,常用于 S10/S99 确认已读 / 已处理。
*/
@Override
public boolean updateNotificationStatusWithVersion(

View File

@@ -7,7 +7,7 @@ import java.util.List;
import java.util.Optional;
/**
* Reservation V4 来源通知持久化边界。第一版用于 S10 纯通知的幂等创建、查询和确认。
* Reservation V4 来源通知持久化边界。第一版用于 S10/S99 纯通知的幂等创建、查询和确认。
*/
public interface ReservationV4SourceNotificationRepository {
@@ -34,12 +34,12 @@ public interface ReservationV4SourceNotificationRepository {
Long sourceMessageId);
/**
* 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。
* 幂等创建 S10/S99 来源通知;并发重复插入时返回已存在记录。
*/
ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(ReservationV4SourceNotificationDraft draft);
/**
* 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。
* 按 version 乐观锁更新来源通知状态,常用于 S10/S99 确认已读 / 已处理。
*/
boolean updateNotificationStatusWithVersion(
String hotelId,

View File

@@ -0,0 +1,34 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.LocalDateTime;
import java.util.List;
/**
* Reservation V4 入站写入服务。负责把已校验的 SuperAgent V4 结果写入订单任务、多卡和来源通知模型。
*/
public interface ReservationV4TaskIntakeService {
/**
* 将普通 V4 业务包写入 V4 订单任务模型;只为至少存在一个合法业务 event 的 order_ref 建卡。
*/
void createBusinessPackageTasks(
SourceMessageInboxSnapshot sourceMessage,
Long aiBatchId,
JsonNode root,
List<ReservationV4AcceptedEventDraft> acceptedEvents,
LocalDateTime now);
/**
* 将 V4 S10/S99 纯通知写入来源通知模型,不创建旧任务、订单任务或业务卡。
*/
void createSourceNotification(
SourceMessageInboxSnapshot sourceMessage,
Long aiBatchId,
Long aiTransitionId,
String routeCode,
JsonNode root,
LocalDateTime now);
}

View File

@@ -6,6 +6,7 @@ import cn.nianxx.thhotel.platform.common.enums.SourceMessageOnlyResultCode;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft;
@@ -27,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResul
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultWarning;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4TaskIntakeService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -147,6 +149,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
private final ObjectMapper objectMapper;
private final SourceMessageInboxRepository sourceMessageInboxRepository;
private final ReservationAiWorkflowRepository workflowRepository;
private final ReservationV4TaskIntakeService v4TaskIntakeService;
private final ReservationAiV4CallbackValidator v4CallbackValidator;
private final ReservationAiV4CallbackRouter v4CallbackRouter;
private final boolean allowLegacyInternalSourceMessageId;
@@ -158,11 +161,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
ObjectMapper objectMapper,
SourceMessageInboxRepository sourceMessageInboxRepository,
ReservationAiWorkflowRepository workflowRepository,
ReservationV4TaskIntakeService v4TaskIntakeService,
@Value("${superagent.task-result.allow-legacy-internal-source-message-id:false}")
boolean allowLegacyInternalSourceMessageId) {
this.objectMapper = objectMapper;
this.sourceMessageInboxRepository = sourceMessageInboxRepository;
this.workflowRepository = workflowRepository;
this.v4TaskIntakeService = v4TaskIntakeService;
this.v4CallbackValidator = new ReservationAiV4CallbackValidator();
this.v4CallbackRouter = new ReservationAiV4CallbackRouter(objectMapper);
this.allowLegacyInternalSourceMessageId = allowLegacyInternalSourceMessageId;
@@ -446,7 +451,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
/**
* 接收 V4 S10/S99 来源邮件通知。V4 不再要求旧 result_type 和通知对象,按包级 route_code 建只读任务
* 接收 V4 S10/S99 来源邮件通知。V4 不再要求旧 result_type 和通知对象,按包级 route_code 写来源通知
*/
private SuperAgentTaskResultResponse acceptV4SourceMessageNotification(
JsonNode root,
@@ -469,20 +474,68 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode)
.filter(ReservationAiRouteDefinition::sourceMessageNotification)
.orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知 route_code 无效。"));
SourceMessageOnlyEntryResult entryResult = new SourceMessageOnlyEntryResult(
SourceMessageOnlyResultCode.fromCode(route.taskSubtype())
.orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知结果码无效。")),
resolvedSourceMessage.responseSourceMessageId(),
AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code(),
route.routeCode()
SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot();
String hotelId = sourceMessage.hotelId();
Long sourceMessageId = sourceMessage.id();
String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody);
String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessageId + "|" + requestPayloadSha256);
ReservationAiBatchSnapshot existingBatch = workflowRepository
.findBatchBySourceMessageId(hotelId, sourceMessageId)
.orElse(null);
if (existingBatch != null) {
return handleExistingBatch(
requestId,
resolvedSourceMessage.responseSourceMessageId(),
requestPayloadSha256,
existingBatch);
}
LocalDateTime now = nowUtc();
String safeRequestId = optionalText(requestId, "request_id", LENGTH_128);
ReservationAiBatchDraft batchDraft = new ReservationAiBatchDraft(
hotelId,
sourceMessageId,
requestPayloadSha256,
batchIdempotencyKey,
requireText(clientId, "clientId", LENGTH_128),
safeRequestId,
now,
1,
null
);
Long batchId = insertBatchOrReplay(batchDraft);
if (batchId == null) {
return handleExistingBatch(
requestId,
resolvedSourceMessage.responseSourceMessageId(),
requestPayloadSha256,
workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessageId)
.orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。")));
}
SuperAgentTaskResultItemResponse responseItem = createV4SourceNotificationTransition(
hotelId,
sourceMessageId,
batchId,
root,
route,
now);
v4TaskIntakeService.createSourceNotification(
sourceMessage,
batchId,
longValue(responseItem.aiTransitionId()),
route.routeCode(),
root,
now);
return new SuperAgentTaskResultResponse(
safeRequestId,
resolvedSourceMessage.responseSourceMessageId(),
batchId.toString(),
false,
1,
List.of(responseItem),
List.of()
);
return acceptSourceMessageOnlyEntryResult(
entryResult,
clientId,
requestId,
defaultHotelId,
rawBody,
resolvedSourceMessage.snapshot());
}
/**
@@ -551,6 +604,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
List<SuperAgentTaskResultItemResponse> responseItems = new ArrayList<>(messageEvents.size());
List<ReservationV4AcceptedEventDraft> acceptedEvents = new ArrayList<>(messageEvents.size());
Map<String, String> targetOrderByOrderRef = new LinkedHashMap<>();
for (int index = 0; index < messageEvents.size(); index++) {
JsonNode event = messageEvents.get(index);
@@ -585,8 +639,11 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
continue;
}
ObjectNode item = v4CallbackRouter.normalizeEventToTaskResultItem(root, event, route, arrayIndex);
responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now));
SuperAgentTaskResultItemResponse responseItem = createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now);
responseItems.add(responseItem);
acceptedEvents.add(toV4AcceptedEvent(event, route, responseItem, arrayIndex));
}
v4TaskIntakeService.createBusinessPackageTasks(sourceMessage, batchId, root, acceptedEvents, now);
return new SuperAgentTaskResultResponse(
safeRequestId,
@@ -1038,6 +1095,60 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
return "linked_parent_release_after_child_split".equals(firstText(extractedFields, event, "relationship_type"));
}
/**
* 将已创建 transition 的 V4 event 转成新模型写入草稿,避免 V4 多卡服务反向依赖旧任务响应细节。
*/
private ReservationV4AcceptedEventDraft toV4AcceptedEvent(
JsonNode event,
ReservationAiRouteDefinition route,
SuperAgentTaskResultItemResponse responseItem,
int arrayIndex) {
JsonNode targetOrder = event.path("target_order");
return new ReservationV4AcceptedEventDraft(
requireText(textAt(event, "order_ref"), "order_ref", LENGTH_128),
arrayIndex,
longValue(responseItem.aiTransitionId()),
longValue(responseItem.orderId()),
optionalText(textAt(targetOrder, "booking_type"), "target_order.booking_type", LENGTH_32),
optionalText(textAt(targetOrder, "locator_type"), "target_order.locator_type", LENGTH_64),
optionalText(textAt(targetOrder, "locator_value"), "target_order.locator_value", LENGTH_128),
requireText(textAt(event, "event_type"), "event_type", LENGTH_64),
isBooleanTrue(event.path("manual_review")),
nodeJson(v4BusinessCardAiPayload(event, route)),
nodeJson(v4BusinessCardDisplayPayload(event, route))
);
}
/**
* 生成 V4 业务卡 AI 原始片段,保留 event 原文和系统派生路由,不复制整封邮件正文。
*/
private ObjectNode v4BusinessCardAiPayload(JsonNode event, ReservationAiRouteDefinition route) {
ObjectNode payload = objectMapper.createObjectNode();
payload.set("v4_message_event", event);
payload.put("route_code", route.routeCode());
payload.put("result_type", route.resultType());
payload.put("task_type", route.taskType());
payload.put("task_subtype", route.taskSubtype());
payload.put("system_process_category", route.systemProcessCategory().name());
payload.put("field_contract_version", "20260718-v4");
return payload;
}
/**
* 生成 V4 业务卡展示快照,后续查询接口可在此基础上继续补目录和可操作性。
*/
private ObjectNode v4BusinessCardDisplayPayload(JsonNode event, ReservationAiRouteDefinition route) {
ObjectNode payload = objectMapper.createObjectNode();
payload.put("event_type", textAt(event, "event_type"));
payload.put("route_code", route.routeCode());
payload.put("result_type", route.resultType());
payload.put("task_type", route.taskType());
payload.put("task_subtype", route.taskSubtype());
payload.set("target_order", event.path("target_order"));
payload.set("business_fields", event);
return payload;
}
/**
* 归一 Parent split 的 Parent Group key只补齐 adapter 派生用副本,不修改请求原文。
*/
@@ -1969,6 +2080,16 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
}
/**
* 将本服务内部生成的字符串 ID 转回数据库 Long 外键;空值表示当前响应未创建对应对象。
*/
private Long longValue(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Long.valueOf(value);
}
/**
* 从对象节点中读取文本字段,缺失或 null 时返回 null。
*/
@@ -2267,6 +2388,34 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
V4_SKILL_ID);
}
/**
* 保存 V4 S10/S99 来源通知 transition。该记录只用于 AI 入站追踪,不创建旧任务或隐藏订单。
*/
private SuperAgentTaskResultItemResponse createV4SourceNotificationTransition(
String hotelId,
Long sourceMessageId,
Long batchId,
JsonNode root,
ReservationAiRouteDefinition route,
LocalDateTime now) {
ObjectNode notificationPayload = objectMapper.createObjectNode();
notificationPayload.set("v4_package", root == null ? objectMapper.createObjectNode() : root);
notificationPayload.set("v4_source_message", root == null ? objectMapper.createObjectNode() : root.path("source_message"));
return createAdapterContractErrorTransition(
hotelId,
sourceMessageId,
batchId,
notificationPayload,
1,
1,
route,
null,
null,
now,
V4_CATALOG_CODE,
V4_SKILL_ID);
}
/**
* 保存 V4 包级 Adapter 契约错误 transition。该记录不关联具体任务只用于排查回调包整体不合规。
*/

View File

@@ -0,0 +1,405 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4TaskIntakeService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
/**
* Reservation V4 入站写入实现。将 V4 回调结果写入订单任务、任务卡和来源通知表。
*/
@Service
public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskIntakeService {
private static final int SOURCE_MESSAGE_CARD_SORT_ORDER = 10;
private static final int BASIC_INFORMATION_CARD_SORT_ORDER = 20;
private static final int ROOM_INFORMATION_CARD_SORT_ORDER = 30;
private static final int TRACE_CARD_SORT_ORDER = 40;
private static final int ROOMING_LIST_CARD_SORT_ORDER = 50;
private static final int PAYMENT_CARD_SORT_ORDER = 60;
private final ObjectMapper objectMapper;
private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
/**
* 注入 V4 持久化边界和 JSON 工具Service 只编排入站写入,不直接访问 Mapper。
*/
public ReservationV4TaskIntakeServiceImpl(
ObjectMapper objectMapper,
ReservationV4WorkflowRepository workflowRepository,
ReservationV4SourceNotificationRepository sourceNotificationRepository) {
this.objectMapper = objectMapper;
this.workflowRepository = workflowRepository;
this.sourceNotificationRepository = sourceNotificationRepository;
}
/**
* 将普通 V4 业务包按 order_ref 写入订单任务、来源邮件展示卡、Basic Information 卡和业务卡。
*/
@Override
public void createBusinessPackageTasks(
SourceMessageInboxSnapshot sourceMessage,
Long aiBatchId,
JsonNode root,
List<ReservationV4AcceptedEventDraft> acceptedEvents,
LocalDateTime now) {
if (acceptedEvents == null || acceptedEvents.isEmpty()) {
return;
}
Map<String, List<ReservationV4AcceptedEventDraft>> eventsByOrderRef = groupEventsByOrderRef(acceptedEvents);
JsonNode orderContexts = root.path("order_contexts");
for (int index = 0; index < orderContexts.size(); index++) {
JsonNode orderContext = orderContexts.get(index);
String orderRef = textAt(orderContext, "order_ref");
List<ReservationV4AcceptedEventDraft> orderEvents = eventsByOrderRef.get(orderRef);
if (orderEvents == null || orderEvents.isEmpty()) {
continue;
}
ReservationV4AcceptedEventDraft bindingEvent = selectOrderTaskBindingEvent(orderEvents);
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
sourceMessage.hotelId(),
sourceMessage.id(),
aiBatchId,
orderRef,
index + 1,
localOrderId(bindingEvent),
bindingEvent.targetBookingType(),
bindingEvent.targetLocatorType(),
bindingEvent.targetLocatorValue(),
targetResolutionStatus(bindingEvent),
ReservationV4OrderTaskStatus.OPEN.name(),
sourceReceivedAt(sourceMessage, now),
now
));
if (!workflowRepository.findTaskCardsByOrderTaskId(sourceMessage.hotelId(), orderTask.id()).isEmpty()) {
continue;
}
insertSourceMessageDisplayCard(sourceMessage, orderTask.id(), root.path("source_message"), now);
insertBasicInformationCard(sourceMessage, orderTask.id(), orderContext, now);
for (ReservationV4AcceptedEventDraft acceptedEvent : orderEvents) {
insertBusinessCard(sourceMessage, orderTask.id(), acceptedEvent, now);
}
}
}
/**
* 将 V4 S10/S99 写入来源通知模型;该记录后续由工作台和通知详情接口读取。
*/
@Override
public void createSourceNotification(
SourceMessageInboxSnapshot sourceMessage,
Long aiBatchId,
Long aiTransitionId,
String routeCode,
JsonNode root,
LocalDateTime now) {
sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft(
sourceMessage.hotelId(),
sourceMessage.id(),
aiBatchId,
aiTransitionId,
routeCode,
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
nodeJson(root),
sourceReceivedAt(sourceMessage, now),
now
));
}
/**
* 新增普通业务包固定的来源邮件展示卡。
*/
private void insertSourceMessageDisplayCard(
SourceMessageInboxSnapshot sourceMessage,
Long orderTaskId,
JsonNode sourceMessagePayload,
LocalDateTime now) {
ObjectNode displayPayload = objectMapper.createObjectNode();
displayPayload.put("card_type", ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name());
displayPayload.put("source_message_id", sourceMessage.externalMessageId());
displayPayload.put("conversation_id", textAt(sourceMessagePayload, "conversation_id"));
displayPayload.put("subject", textAt(sourceMessagePayload, "subject"));
displayPayload.put("sender", textAt(sourceMessagePayload, "sender"));
displayPayload.put("body_content_type", textAt(sourceMessagePayload, "body_content_type"));
displayPayload.set("attachments", attachmentSummaries(sourceMessagePayload.path("attachments")));
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
sourceMessage.hotelId(),
orderTaskId,
sourceMessage.id(),
null,
ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(),
null,
0,
SOURCE_MESSAGE_CARD_SORT_ORDER,
ReservationV4CardStatus.READONLY.name(),
null,
nodeJson(sourceMessagePayload),
nodeJson(displayPayload),
null,
now
));
}
/**
* 新增订单级 Basic Information 卡account_code 缺失时进入人工复核。
*/
private void insertBasicInformationCard(
SourceMessageInboxSnapshot sourceMessage,
Long orderTaskId,
JsonNode orderContext,
LocalDateTime now) {
JsonNode basicInformation = orderContext.path("basic_information");
boolean manualReviewRequired = isBooleanTrue(basicInformation.path("manual_review"));
ObjectNode displayPayload = objectMapper.createObjectNode();
displayPayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name());
displayPayload.put("order_ref", textAt(orderContext, "order_ref"));
displayPayload.set("basic_information", basicInformation);
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
sourceMessage.hotelId(),
orderTaskId,
sourceMessage.id(),
null,
ReservationV4CardType.BASIC_INFORMATION.name(),
null,
0,
BASIC_INFORMATION_CARD_SORT_ORDER,
cardStatus(manualReviewRequired),
reviewStatus(manualReviewRequired),
nodeJson(orderContext),
nodeJson(displayPayload),
null,
now
));
}
/**
* 新增 V4 event 对应的业务卡,人工复核 event 直接进入 REVIEW_REQUIRED。
*/
private void insertBusinessCard(
SourceMessageInboxSnapshot sourceMessage,
Long orderTaskId,
ReservationV4AcceptedEventDraft acceptedEvent,
LocalDateTime now) {
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
sourceMessage.hotelId(),
orderTaskId,
sourceMessage.id(),
acceptedEvent.aiTransitionId(),
cardType(acceptedEvent.eventType()),
acceptedEvent.eventType(),
acceptedEvent.sourceEventIndex(),
cardSortOrder(acceptedEvent.eventType()),
cardStatus(acceptedEvent.manualReviewRequired()),
reviewStatus(acceptedEvent.manualReviewRequired()),
acceptedEvent.aiPayloadJson(),
acceptedEvent.displayPayloadJson(),
null,
now
));
}
/**
* 按 order_ref 保留 V4 event 原始数组顺序。
*/
private Map<String, List<ReservationV4AcceptedEventDraft>> groupEventsByOrderRef(
List<ReservationV4AcceptedEventDraft> acceptedEvents) {
Map<String, List<ReservationV4AcceptedEventDraft>> grouped = new LinkedHashMap<>();
for (ReservationV4AcceptedEventDraft event : acceptedEvents) {
grouped.computeIfAbsent(event.orderRef(), ignored -> new ArrayList<>()).add(event);
}
return grouped;
}
/**
* 从同一 order_ref 下选择订单任务绑定来源:优先使用本系统当前能本地解析的 target其次保留完整但待复核的 target。
*/
private ReservationV4AcceptedEventDraft selectOrderTaskBindingEvent(
List<ReservationV4AcceptedEventDraft> orderEvents) {
for (ReservationV4AcceptedEventDraft event : orderEvents) {
if (isLocallyResolvableTarget(event)) {
return event;
}
}
for (ReservationV4AcceptedEventDraft event : orderEvents) {
if (hasCompleteTargetOrder(event)) {
return event;
}
}
return orderEvents.get(0);
}
/**
* 第一版只把本系统当前可直接定位的 Group Code / Confirmation Number 视为已解析。
*/
private String targetResolutionStatus(ReservationV4AcceptedEventDraft event) {
return isLocallyResolvableTarget(event) && event.orderId() != null
? ReservationV4TargetResolutionStatus.RESOLVED.name()
: ReservationV4TargetResolutionStatus.UNRESOLVED.name();
}
/**
* V4 order_task 的 order_id 只保存真实可定位的本地订单Booking Code 等待后续复核 / 绑定接口确认。
*/
private Long localOrderId(ReservationV4AcceptedEventDraft event) {
return isLocallyResolvableTarget(event) ? event.orderId() : null;
}
/**
* 判断 target_order 三元组是否完整。
*/
private boolean hasCompleteTargetOrder(ReservationV4AcceptedEventDraft event) {
return hasText(event.targetBookingType())
&& hasText(event.targetLocatorType())
&& hasText(event.targetLocatorValue());
}
/**
* 判断当前后端是否能不经人工复核直接解析目标订单归属。
*/
private boolean isLocallyResolvableTarget(ReservationV4AcceptedEventDraft event) {
if (!hasCompleteTargetOrder(event)) {
return false;
}
return "GROUP_CODE".equals(event.targetLocatorType())
|| "CONFIRMATION_NUMBER".equals(event.targetLocatorType());
}
/**
* 生成来源邮件展示卡的附件安全摘要,不在普通卡片展示 payload 中保存附件 URL。
*/
private ArrayNode attachmentSummaries(JsonNode attachments) {
ArrayNode summaries = objectMapper.createArrayNode();
if (attachments == null || !attachments.isArray()) {
return summaries;
}
for (JsonNode attachment : attachments) {
ObjectNode summary = objectMapper.createObjectNode();
summary.put("id", textAt(attachment, "id"));
summary.put("name", textAt(attachment, "name"));
summary.put("content_type", textAt(attachment, "content_type"));
if (attachment.path("size").isNumber()) {
summary.put("size", attachment.path("size").asLong());
} else {
summary.putNull("size");
}
summaries.add(summary);
}
return summaries;
}
/**
* 根据 V4 event_type 映射 V4 卡片类型。
*/
private String cardType(String eventType) {
return switch (nullToEmpty(eventType)) {
case "NEW_BOOKING", "UPDATE_BOOKING", "CANCEL_BOOKING" -> ReservationV4CardType.ROOM_INFORMATION.name();
case "TRACE_RESERVATION_NOTES" -> ReservationV4CardType.TRACE_RESERVATION_NOTES.name();
case "ROOMING_LIST" -> ReservationV4CardType.ROOMING_LIST.name();
case "PAYMENT" -> ReservationV4CardType.PAYMENT.name();
default -> throw new IllegalArgumentException("V4 event_type 暂不支持创建任务卡:" + eventType);
};
}
/**
* 根据 V4 event_type 返回同一订单任务内的固定展示顺序。
*/
private int cardSortOrder(String eventType) {
return switch (nullToEmpty(eventType)) {
case "NEW_BOOKING", "UPDATE_BOOKING", "CANCEL_BOOKING" -> ROOM_INFORMATION_CARD_SORT_ORDER;
case "TRACE_RESERVATION_NOTES" -> TRACE_CARD_SORT_ORDER;
case "ROOMING_LIST" -> ROOMING_LIST_CARD_SORT_ORDER;
case "PAYMENT" -> PAYMENT_CARD_SORT_ORDER;
default -> throw new IllegalArgumentException("V4 event_type 暂不支持排序:" + eventType);
};
}
/**
* 根据是否需要人工复核生成卡片处理状态。
*/
private String cardStatus(boolean manualReviewRequired) {
return manualReviewRequired
? ReservationV4CardStatus.REVIEW_REQUIRED.name()
: ReservationV4CardStatus.PENDING_CONFIRM.name();
}
/**
* 根据是否需要人工复核生成复核状态。
*/
private String reviewStatus(boolean manualReviewRequired) {
return manualReviewRequired ? ReservationReviewStatus.PENDING.name() : null;
}
/**
* 统一来源邮件接收时间;历史数据缺失时用当前 UTC 时间兜底。
*/
private LocalDateTime sourceReceivedAt(SourceMessageInboxSnapshot sourceMessage, LocalDateTime now) {
return sourceMessage.receivedAt() == null ? now : sourceMessage.receivedAt();
}
/**
* 将 V4 卡片或通知 payload 安全序列化为 JSON 字符串。
*/
private String nodeJson(JsonNode node) {
try {
return objectMapper.writeValueAsString(node == null || node.isMissingNode()
? objectMapper.createObjectNode()
: node);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("V4 入站 JSON 序列化失败。", exception);
}
}
/**
* 从对象节点读取文本字段,缺失或 null 时返回 null。
*/
private String textAt(JsonNode node, String fieldName) {
if (node == null || node.isMissingNode() || node.get(fieldName) == null || node.get(fieldName).isNull()) {
return null;
}
return node.get(fieldName).asText();
}
/**
* 判断 JSON 布尔节点是否显式为 true。
*/
private boolean isBooleanTrue(JsonNode node) {
return node != null && node.isBoolean() && node.booleanValue();
}
/**
* 判断字符串是否有有效文本。
*/
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
/**
* switch 前把空字符串统一为安全默认值。
*/
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
}

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 """
{