实现M002 V4入站写入新模型
This commit is contained in:
@@ -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,
|
||||
/** 旧过渡链路已经解析出的本地订单投影 ID,V4 第一版复用该绑定。 */
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -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 ID,S10 第一版可为空。 */
|
||||
/** AI transition ID,来源通知第一版可为空。 */
|
||||
Long aiTransitionId,
|
||||
/** 包级路由码,第一版用于 S10。 */
|
||||
/** 包级路由码,当前用于 S10/S99。 */
|
||||
String routeCode,
|
||||
/** 通知状态。 */
|
||||
String notificationStatus,
|
||||
|
||||
@@ -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 ID,S10 第一版可为空。 */
|
||||
/** AI transition ID,来源通知第一版可为空。 */
|
||||
Long aiTransitionId,
|
||||
/** 包级路由码。 */
|
||||
String routeCode,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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。该记录不关联具体任务,只用于排查回调包整体不合规。
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 """
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user