实现 SuperAgent 查询上下文接口
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询接口使用的订单快照。该 DTO 比普通订单快照包含更多只读展示和终止字段。
|
||||
*
|
||||
* @param id 本系统订单主键
|
||||
* @param hotelId 酒店上下文 ID
|
||||
* @param orderKeyType 订单业务号类型,例如 GROUP_CODE、CONFIRMATION_NUMBER、TEMPORARY
|
||||
* @param orderBusinessKey 原始订单业务号
|
||||
* @param activeBusinessKey 当前生效业务号
|
||||
* @param temporaryOrderCode 临时订单号
|
||||
* @param orderStatus 订单状态
|
||||
* @param businessKeySource 业务号来源
|
||||
* @param displayName 前端和 Skill 可读展示名
|
||||
* @param sourceMessageId 订单来源 SourceMessage ID
|
||||
* @param createdFromTaskId 创建该订单的任务 ID
|
||||
* @param endedAt 订单结束时间
|
||||
* @param logicDeletedAt 订单逻辑删除时间
|
||||
* @param logicDeletedReason 订单逻辑删除原因
|
||||
* @param createdAt 创建时间
|
||||
* @param updatedAt 最近更新时间
|
||||
*/
|
||||
public record ReservationAiQueryOrderSnapshot(
|
||||
Long id,
|
||||
String hotelId,
|
||||
String orderKeyType,
|
||||
String orderBusinessKey,
|
||||
String activeBusinessKey,
|
||||
String temporaryOrderCode,
|
||||
String orderStatus,
|
||||
String businessKeySource,
|
||||
String displayName,
|
||||
Long sourceMessageId,
|
||||
Long createdFromTaskId,
|
||||
LocalDateTime endedAt,
|
||||
LocalDateTime logicDeletedAt,
|
||||
String logicDeletedReason,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询接口使用的任务快照。包含任务队列字段和对应 AI transition 的路由字段。
|
||||
*
|
||||
* @param id 本系统任务主键
|
||||
* @param hotelId 酒店上下文 ID
|
||||
* @param orderId 任务当前挂靠订单 ID
|
||||
* @param sourceMessageId 任务来源 SourceMessage ID
|
||||
* @param aiTransitionId 对应 AI transition ID
|
||||
* @param resultType AI 结果类型
|
||||
* @param aiTaskType AI 原始任务类型
|
||||
* @param systemTaskType 系统主任务类型
|
||||
* @param taskCardType 前端任务卡类型
|
||||
* @param taskSubtype 业务动作 subtype
|
||||
* @param taskStatus 任务状态
|
||||
* @param queueParticipation 是否参与订单执行队列
|
||||
* @param executionOrder 同订单执行顺序
|
||||
* @param parentTaskId 父任务 ID
|
||||
* @param parentSourceEventIndex 父事件序号
|
||||
* @param linkedTaskGroupId 联动任务组 ID
|
||||
* @param blockedUntilParentCompleted 是否等待父任务完成
|
||||
* @param lastFailureReason 最近失败原因
|
||||
* @param completedAt 任务完成时间
|
||||
* @param updatedAt 最近更新时间
|
||||
* @param transitionSourceEventIndex AI transition 来源事件序号
|
||||
* @param catalogCode Skill 目录代码
|
||||
* @param skillId Skill 标识
|
||||
*/
|
||||
public record ReservationAiQueryTaskSnapshot(
|
||||
Long id,
|
||||
String hotelId,
|
||||
Long orderId,
|
||||
Long sourceMessageId,
|
||||
Long aiTransitionId,
|
||||
String resultType,
|
||||
String aiTaskType,
|
||||
String systemTaskType,
|
||||
String taskCardType,
|
||||
String taskSubtype,
|
||||
String taskStatus,
|
||||
Boolean queueParticipation,
|
||||
Integer executionOrder,
|
||||
Long parentTaskId,
|
||||
Integer parentSourceEventIndex,
|
||||
String linkedTaskGroupId,
|
||||
Boolean blockedUntilParentCompleted,
|
||||
String lastFailureReason,
|
||||
LocalDateTime completedAt,
|
||||
LocalDateTime updatedAt,
|
||||
Integer transitionSourceEventIndex,
|
||||
String catalogCode,
|
||||
String skillId
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询订单上下文请求。该请求只用于只读查询,不触发任务创建或 OPERA 写入。
|
||||
*
|
||||
* @param hotelId 酒店上下文 ID,用于隔离订单、任务和 AI transition 数据
|
||||
* @param sourceMessageId 当前 SourceMessage ID,外部以字符串传入避免长整型精度问题
|
||||
* @param sourceEventIndex 当前 AI 事件序号,用于和拆分结果保持一致
|
||||
* @param groupCode Group Code / Allotment Code 查询 key
|
||||
* @param confirmationNumber Confirmation Number 查询 key
|
||||
* @param reservationNo OPERA reservation no,第一版无可靠表源,仅参与入参完整性校验
|
||||
* @param objectTypeHint 调用方推测的对象类型,只作为提示不作为业务事实
|
||||
* @param targetKeySource key 来源,例如 body_current 或 body_thread_evidence
|
||||
* @param bodyThreadUsedOnlyAsEvidence 历史线程 key 是否仅作为证据使用
|
||||
*/
|
||||
public record ReservationAiCaseContextQueryRequest(
|
||||
@JsonProperty("hotel_id")
|
||||
String hotelId,
|
||||
@JsonProperty("source_message_id")
|
||||
String sourceMessageId,
|
||||
@JsonProperty("source_event_index")
|
||||
Integer sourceEventIndex,
|
||||
@JsonProperty("group_code")
|
||||
String groupCode,
|
||||
@JsonProperty("confirmation_number")
|
||||
String confirmationNumber,
|
||||
@JsonProperty("reservation_no")
|
||||
String reservationNo,
|
||||
@JsonProperty("object_type_hint")
|
||||
String objectTypeHint,
|
||||
@JsonProperty("target_key_source")
|
||||
String targetKeySource,
|
||||
@JsonProperty("body_thread_used_only_as_evidence")
|
||||
Boolean bodyThreadUsedOnlyAsEvidence
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询对象详情请求。第一版仅支持通过 ORDER:{orderId} 查询本系统订单快照。
|
||||
*
|
||||
* @param hotelId 酒店上下文 ID,用于隔离订单和任务数据
|
||||
* @param objectId 查询对象 ID,第一版格式为 ORDER:{orderId}
|
||||
* @param objectType 调用方传入的对象类型提示,第一版不作为强校验条件
|
||||
*/
|
||||
public record ReservationAiObjectDetailQueryRequest(
|
||||
@JsonProperty("hotel_id")
|
||||
String hotelId,
|
||||
@JsonProperty("object_id")
|
||||
String objectId,
|
||||
@JsonProperty("object_type")
|
||||
String objectType
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* query_case_context 返回数据。只表达订单、任务和终止事实,不输出最终业务结论。
|
||||
*
|
||||
* @param matchedOrderRecords 按查询 key 匹配到的订单摘要
|
||||
* @param pendingOrOpenTasks 同 key 或同订单下尚未结束的任务摘要
|
||||
* @param activeWorkflows 活跃工作流摘要,第一版无独立 workflow 表所以固定为空列表
|
||||
* @param terminatedRecords 影响 Skill 判断的终止订单或终止任务事实
|
||||
* @param targetObjectValidation 后端基于事实给出的目标对象校验摘要
|
||||
* @param keyRelationships Group Code 与 Confirmation Number 的关系证据
|
||||
*/
|
||||
public record ReservationAiCaseContextResult(
|
||||
@JsonProperty("matched_order_records")
|
||||
List<MatchedOrderRecord> matchedOrderRecords,
|
||||
@JsonProperty("pending_or_open_tasks")
|
||||
List<PendingOrOpenTask> pendingOrOpenTasks,
|
||||
@JsonProperty("active_workflows")
|
||||
List<ActiveWorkflow> activeWorkflows,
|
||||
@JsonProperty("terminated_records")
|
||||
List<TerminatedRecord> terminatedRecords,
|
||||
@JsonProperty("target_object_validation")
|
||||
TargetObjectValidation targetObjectValidation,
|
||||
@JsonProperty("key_relationships")
|
||||
KeyRelationships keyRelationships
|
||||
) {
|
||||
|
||||
/**
|
||||
* 订单匹配摘要。object_id 使用 ORDER:{orderId},便于 object-detail 接口继续查询。
|
||||
*
|
||||
* @param objectId 查询对象 ID
|
||||
* @param objectType 查询对象类型
|
||||
* @param orderId 本系统订单 ID
|
||||
* @param orderKeyType 订单业务号类型
|
||||
* @param groupCode Group Code
|
||||
* @param confirmationNumber Confirmation Number
|
||||
* @param temporaryOrderCode 临时订单号
|
||||
* @param displayName 用户可读展示名
|
||||
* @param status 订单状态
|
||||
* @param businessKeySource 业务号来源
|
||||
* @param sourceTable 来源表名
|
||||
* @param lastUpdatedAt 最近更新时间
|
||||
*/
|
||||
public record MatchedOrderRecord(
|
||||
@JsonProperty("object_id")
|
||||
String objectId,
|
||||
@JsonProperty("object_type")
|
||||
String objectType,
|
||||
@JsonProperty("order_id")
|
||||
String orderId,
|
||||
@JsonProperty("order_key_type")
|
||||
String orderKeyType,
|
||||
@JsonProperty("group_code")
|
||||
String groupCode,
|
||||
@JsonProperty("confirmation_number")
|
||||
String confirmationNumber,
|
||||
@JsonProperty("temporary_order_code")
|
||||
String temporaryOrderCode,
|
||||
@JsonProperty("display_name")
|
||||
String displayName,
|
||||
String status,
|
||||
@JsonProperty("business_key_source")
|
||||
String businessKeySource,
|
||||
@JsonProperty("source_table")
|
||||
String sourceTable,
|
||||
@JsonProperty("last_updated_at")
|
||||
LocalDateTime lastUpdatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 未完成任务摘要。FAILED 和 COMPLETED 不进入该列表,避免误阻塞后续任务。
|
||||
*
|
||||
* @param taskId 本系统任务 ID
|
||||
* @param orderId 任务当前挂靠订单 ID
|
||||
* @param sourceMessageId 任务来源消息 ID
|
||||
* @param sourceEventIndex AI current 事件序号
|
||||
* @param catalogCode Skill 目录代码
|
||||
* @param skillId Skill 标识
|
||||
* @param resultType AI 结果类型
|
||||
* @param taskType AI 原始任务类型
|
||||
* @param systemTaskType 系统主任务类型
|
||||
* @param taskCardType 任务卡类型
|
||||
* @param taskSubtype 业务动作 subtype
|
||||
* @param taskStatus 任务状态
|
||||
* @param queueParticipation 是否参与订单执行队列
|
||||
* @param executionOrder 同订单执行顺序
|
||||
* @param parentTaskId 父任务 ID
|
||||
* @param parentSourceEventIndex 父事件序号
|
||||
* @param linkedTaskGroupId 联动任务组 ID
|
||||
* @param blockedUntilParentCompleted 是否等待父任务完成
|
||||
* @param lastUpdatedAt 最近更新时间
|
||||
*/
|
||||
public record PendingOrOpenTask(
|
||||
@JsonProperty("task_id")
|
||||
String taskId,
|
||||
@JsonProperty("order_id")
|
||||
String orderId,
|
||||
@JsonProperty("source_message_id")
|
||||
String sourceMessageId,
|
||||
@JsonProperty("source_event_index")
|
||||
Integer sourceEventIndex,
|
||||
@JsonProperty("catalog_code")
|
||||
String catalogCode,
|
||||
@JsonProperty("skill_id")
|
||||
String skillId,
|
||||
@JsonProperty("result_type")
|
||||
String resultType,
|
||||
@JsonProperty("task_type")
|
||||
String taskType,
|
||||
@JsonProperty("system_task_type")
|
||||
String systemTaskType,
|
||||
@JsonProperty("task_card_type")
|
||||
String taskCardType,
|
||||
@JsonProperty("task_subtype")
|
||||
String taskSubtype,
|
||||
@JsonProperty("task_status")
|
||||
String taskStatus,
|
||||
@JsonProperty("queue_participation")
|
||||
Boolean queueParticipation,
|
||||
@JsonProperty("execution_order")
|
||||
Integer executionOrder,
|
||||
@JsonProperty("parent_task_id")
|
||||
String parentTaskId,
|
||||
@JsonProperty("parent_source_event_index")
|
||||
Integer parentSourceEventIndex,
|
||||
@JsonProperty("linked_task_group_id")
|
||||
String linkedTaskGroupId,
|
||||
@JsonProperty("blocked_until_parent_completed")
|
||||
Boolean blockedUntilParentCompleted,
|
||||
@JsonProperty("last_updated_at")
|
||||
LocalDateTime lastUpdatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 活跃工作流占位结构。当前系统没有独立 workflow 表,所以接口只返回空列表。
|
||||
*/
|
||||
public record ActiveWorkflow() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 终止事实摘要。用于让 Skill 看到 ENDED、LOGIC_DELETED、FAILED 和 COMPLETED。
|
||||
*
|
||||
* @param recordType 终止事实类型,order 或 task
|
||||
* @param recordId 终止记录 ID
|
||||
* @param status 终止状态
|
||||
* @param reason 终止原因
|
||||
* @param occurredAt 终止发生时间
|
||||
* @param lastUpdatedAt 最近更新时间
|
||||
*/
|
||||
public record TerminatedRecord(
|
||||
@JsonProperty("record_type")
|
||||
String recordType,
|
||||
@JsonProperty("record_id")
|
||||
String recordId,
|
||||
String status,
|
||||
String reason,
|
||||
@JsonProperty("occurred_at")
|
||||
LocalDateTime occurredAt,
|
||||
@JsonProperty("last_updated_at")
|
||||
LocalDateTime lastUpdatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标对象校验摘要。字段名保持导入契约语义,但不替代 Skill 的最终判断。
|
||||
*
|
||||
* @param status 校验状态,none、single、multiple 或 conflict
|
||||
* @param matchedObjectId 唯一匹配对象 ID
|
||||
* @param matchedObjectType 唯一匹配对象类型
|
||||
* @param canCreateNewBookingTask 是否允许继续创建 New Booking 任务
|
||||
* @param canCreateUpdateTask 是否允许创建 Update Booking 任务
|
||||
* @param canCreateCancelTask 是否允许创建 Cancel Booking 任务
|
||||
* @param canAttachVoucher 是否允许绑定 voucher 类任务
|
||||
* @param canAttachRoomingList 是否允许绑定 rooming list 类任务
|
||||
* @param needsManualReviewReason 需要人工复核的原因码
|
||||
*/
|
||||
public record TargetObjectValidation(
|
||||
String status,
|
||||
@JsonProperty("matched_object_id")
|
||||
String matchedObjectId,
|
||||
@JsonProperty("matched_object_type")
|
||||
String matchedObjectType,
|
||||
@JsonProperty("can_create_new_booking_task")
|
||||
Boolean canCreateNewBookingTask,
|
||||
@JsonProperty("can_create_update_task")
|
||||
Boolean canCreateUpdateTask,
|
||||
@JsonProperty("can_create_cancel_task")
|
||||
Boolean canCreateCancelTask,
|
||||
@JsonProperty("can_attach_voucher")
|
||||
Boolean canAttachVoucher,
|
||||
@JsonProperty("can_attach_rooming_list")
|
||||
Boolean canAttachRoomingList,
|
||||
@JsonProperty("needs_manual_review_reason")
|
||||
String needsManualReviewReason
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务 key 关系。当前系统没有关系投影,多数场景保持 null。
|
||||
*
|
||||
* @param groupCodeAndConfirmationSameObject 是否可证明两个 key 属于同一对象
|
||||
* @param relationshipEvidence 关系判断证据说明
|
||||
*/
|
||||
public record KeyRelationships(
|
||||
@JsonProperty("group_code_and_confirmation_same_object")
|
||||
Boolean groupCodeAndConfirmationSameObject,
|
||||
@JsonProperty("relationship_evidence")
|
||||
String relationshipEvidence
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* query_object_detail 返回数据。第一版以本系统订单快照为主,OPERA 投影字段允许为空。
|
||||
*
|
||||
* @param objectId 查询对象 ID
|
||||
* @param objectType 查询对象类型
|
||||
* @param orderId 本系统订单 ID
|
||||
* @param orderKeyType 订单业务号类型
|
||||
* @param groupCode Group Code
|
||||
* @param confirmationNumber Confirmation Number
|
||||
* @param reservationNo OPERA reservation no,当前无可靠来源时为空
|
||||
* @param blockId OPERA block id,当前无可靠来源时为空
|
||||
* @param temporaryOrderCode 临时订单号
|
||||
* @param displayName 用户可读展示名
|
||||
* @param status 订单状态
|
||||
* @param sourceMessageId 来源 SourceMessage ID
|
||||
* @param createdFromTaskId 创建该订单的任务 ID
|
||||
* @param createdAt 创建时间
|
||||
* @param lastUpdatedAt 最近更新时间
|
||||
* @param arrivalDate 到店日期,当前无可靠来源时为空
|
||||
* @param departureDate 离店日期,当前无可靠来源时为空
|
||||
* @param nights 间夜数,当前无可靠来源时为空
|
||||
* @param guestCount 客人数,当前无可靠来源时为空
|
||||
* @param roomItems 房型明细,当前无可靠来源时为空数组
|
||||
* @param rateCode 房价代码,当前无可靠来源时为空
|
||||
* @param rateCodePrice 房价,当前无可靠来源时为空
|
||||
* @param reservationType 预订类型,当前无可靠来源时为空
|
||||
* @param cancelStatus 取消状态摘要
|
||||
* @param canUpdate 是否允许继续创建更新类任务
|
||||
* @param canCancel 是否允许继续创建取消类任务
|
||||
* @param hardValidationWarnings 对象详情硬校验缺口警告
|
||||
*/
|
||||
public record ReservationAiObjectDetailResult(
|
||||
@JsonProperty("object_id")
|
||||
String objectId,
|
||||
@JsonProperty("object_type")
|
||||
String objectType,
|
||||
@JsonProperty("order_id")
|
||||
String orderId,
|
||||
@JsonProperty("order_key_type")
|
||||
String orderKeyType,
|
||||
@JsonProperty("group_code")
|
||||
String groupCode,
|
||||
@JsonProperty("confirmation_number")
|
||||
String confirmationNumber,
|
||||
@JsonProperty("reservation_no")
|
||||
String reservationNo,
|
||||
@JsonProperty("block_id")
|
||||
String blockId,
|
||||
@JsonProperty("temporary_order_code")
|
||||
String temporaryOrderCode,
|
||||
@JsonProperty("display_name")
|
||||
String displayName,
|
||||
String status,
|
||||
@JsonProperty("source_message_id")
|
||||
String sourceMessageId,
|
||||
@JsonProperty("created_from_task_id")
|
||||
String createdFromTaskId,
|
||||
@JsonProperty("created_at")
|
||||
LocalDateTime createdAt,
|
||||
@JsonProperty("last_updated_at")
|
||||
LocalDateTime lastUpdatedAt,
|
||||
@JsonProperty("arrival_date")
|
||||
String arrivalDate,
|
||||
@JsonProperty("departure_date")
|
||||
String departureDate,
|
||||
Integer nights,
|
||||
@JsonProperty("guest_count")
|
||||
Integer guestCount,
|
||||
@JsonProperty("room_items")
|
||||
List<RoomItem> roomItems,
|
||||
@JsonProperty("rate_code")
|
||||
String rateCode,
|
||||
@JsonProperty("rate_code_price")
|
||||
String rateCodePrice,
|
||||
@JsonProperty("reservation_type")
|
||||
String reservationType,
|
||||
@JsonProperty("cancel_status")
|
||||
String cancelStatus,
|
||||
@JsonProperty("can_update")
|
||||
Boolean canUpdate,
|
||||
@JsonProperty("can_cancel")
|
||||
Boolean canCancel,
|
||||
@JsonProperty("hard_validation_warnings")
|
||||
List<ReservationAiQueryWarningResult> hardValidationWarnings
|
||||
) {
|
||||
|
||||
/**
|
||||
* 房型明细摘要。当前系统没有 OPERA 投影时返回空数组。
|
||||
*
|
||||
* @param roomType 房型展示名
|
||||
* @param pmsRoomTypeCode PMS 房型代码
|
||||
* @param roomQuantity 房间数量
|
||||
* @param rateCode 房价代码
|
||||
* @param rateCodePrice 房价
|
||||
*/
|
||||
public record RoomItem(
|
||||
@JsonProperty("room_type")
|
||||
String roomType,
|
||||
@JsonProperty("pms_room_type_code")
|
||||
String pmsRoomTypeCode,
|
||||
@JsonProperty("room_quantity")
|
||||
Integer roomQuantity,
|
||||
@JsonProperty("rate_code")
|
||||
String rateCode,
|
||||
@JsonProperty("rate_code_price")
|
||||
String rateCodePrice
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询接口错误项。错误内容不回显邮件正文、附件地址或 Secret。
|
||||
*
|
||||
* @param code 稳定错误码
|
||||
* @param message 用户或调用方可读错误说明
|
||||
* @param details 结构化错误明细,不能包含敏感信息
|
||||
*/
|
||||
public record ReservationAiQueryErrorResult(
|
||||
String code,
|
||||
String message,
|
||||
Map<String, Object> details
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询接口统一响应包。成功和失败都使用该结构,便于 Skill 侧稳定解析。
|
||||
*
|
||||
* @param success 是否查询成功
|
||||
* @param requestId 调用方请求 ID
|
||||
* @param traceId AI 运行链路 ID
|
||||
* @param data 成功响应数据
|
||||
* @param warnings 非阻断警告列表
|
||||
* @param error 失败响应错误项
|
||||
*/
|
||||
public record ReservationAiQueryResponse<T>(
|
||||
boolean success,
|
||||
@JsonProperty("request_id")
|
||||
String requestId,
|
||||
@JsonProperty("trace_id")
|
||||
String traceId,
|
||||
T data,
|
||||
List<ReservationAiQueryWarningResult> warnings,
|
||||
ReservationAiQueryErrorResult error
|
||||
) {
|
||||
|
||||
/**
|
||||
* 构造成功响应,warnings 允许为空列表但不返回 null。
|
||||
*/
|
||||
public static <T> ReservationAiQueryResponse<T> success(
|
||||
String requestId,
|
||||
String traceId,
|
||||
T data,
|
||||
List<ReservationAiQueryWarningResult> warnings) {
|
||||
return new ReservationAiQueryResponse<>(true, requestId, traceId, data, warnings == null ? List.of() : warnings, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造失败响应,data 固定为空,避免调用方误读部分结果。
|
||||
*/
|
||||
public static <T> ReservationAiQueryResponse<T> failure(
|
||||
String requestId,
|
||||
String traceId,
|
||||
ReservationAiQueryErrorResult error) {
|
||||
return new ReservationAiQueryResponse<>(false, requestId, traceId, null, List.of(), error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询接口警告项。用于说明当前系统无法确认但不应导致查询失败的事实缺口。
|
||||
*
|
||||
* @param code 稳定警告码
|
||||
* @param message 警告说明
|
||||
*/
|
||||
public record ReservationAiQueryWarningResult(
|
||||
String code,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
|
||||
import java.util.List;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* SuperAgent 只读查询 Controller。提供订单上下文和对象详情,不创建任务、不写 OPERA。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/ai-query/v1")
|
||||
public class ReservationAiQueryController {
|
||||
|
||||
private final ReservationAiQueryService aiQueryService;
|
||||
|
||||
/**
|
||||
* 注入只读查询服务,Controller 只负责请求响应契约映射。
|
||||
*/
|
||||
public ReservationAiQueryController(ReservationAiQueryService aiQueryService) {
|
||||
this.aiQueryService = aiQueryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单路由上下文,返回匹配订单、未完成任务、终止记录和目标对象校验摘要。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/case-context",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ReservationAiQueryResponse<ReservationAiCaseContextResult> queryCaseContext(
|
||||
@RequestHeader("X-Request-Id") String requestId,
|
||||
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
|
||||
@RequestBody(required = false) ReservationAiCaseContextQueryRequest request) {
|
||||
ReservationAiCaseContextResult result = aiQueryService.queryCaseContext(request);
|
||||
return ReservationAiQueryResponse.success(requestId, traceId, result, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询对象详情。第一版只支持 ORDER:{orderId},OPERA 投影缺口以 warning 表达。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/object-detail",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ReservationAiQueryResponse<ReservationAiObjectDetailResult> queryObjectDetail(
|
||||
@RequestHeader("X-Request-Id") String requestId,
|
||||
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId,
|
||||
@RequestBody(required = false) ReservationAiObjectDetailQueryRequest request) {
|
||||
ReservationAiObjectDetailResult result = aiQueryService.queryObjectDetail(request);
|
||||
return ReservationAiQueryResponse.success(requestId, traceId, result, List.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiQueryException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询接口异常处理。统一返回 success=false,不暴露内部堆栈或敏感上下文。
|
||||
*/
|
||||
@RestControllerAdvice(assignableTypes = ReservationAiQueryController.class)
|
||||
public class ReservationAiQueryControllerAdvice {
|
||||
|
||||
/**
|
||||
* 处理查询服务抛出的受控参数错误和对象不存在错误。
|
||||
*/
|
||||
@ExceptionHandler(ReservationAiQueryException.class)
|
||||
public ResponseEntity<ReservationAiQueryResponse<Object>> handleAiQueryException(
|
||||
ReservationAiQueryException exception,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) {
|
||||
return ResponseEntity.status(exception.getStatus())
|
||||
.body(ReservationAiQueryResponse.failure(
|
||||
requestId,
|
||||
traceId,
|
||||
new ReservationAiQueryErrorResult(
|
||||
exception.getErrorCode(),
|
||||
exception.getMessage(),
|
||||
exception.getDetails())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理缺少 X-Request-Id 等必要请求头的错误。
|
||||
*/
|
||||
@ExceptionHandler(MissingRequestHeaderException.class)
|
||||
public ResponseEntity<ReservationAiQueryResponse<Object>> handleMissingHeader(
|
||||
MissingRequestHeaderException exception,
|
||||
@RequestHeader(value = "X-AI-Trace-Id", required = false) String traceId) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ReservationAiQueryResponse.failure(
|
||||
null,
|
||||
traceId,
|
||||
new ReservationAiQueryErrorResult(
|
||||
"REQUEST_HEADER_REQUIRED",
|
||||
exception.getHeaderName() + " 请求头不能为空",
|
||||
java.util.Map.of())));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package cn.nianxx.thhotel.workflows.reservation.repository;
|
||||
|
||||
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.ReservationAiQueryOrderSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot;
|
||||
@@ -15,6 +17,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDra
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiBatchEntity;
|
||||
@@ -35,7 +38,10 @@ import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskCardMapper;
|
||||
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -300,6 +306,92 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
return Optional.ofNullable(entity).map(this::toOrderSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按业务 key 查询订单上下文。包含 ACTIVE 和已终止订单,供 Skill 判断冲突或终止事实。
|
||||
*/
|
||||
@Override
|
||||
public List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersByBusinessKeys(
|
||||
String hotelId,
|
||||
String groupCode,
|
||||
String confirmationNumber) {
|
||||
Map<Long, ReservationAiQueryOrderSnapshot> orders = new LinkedHashMap<>();
|
||||
if (hasText(groupCode)) {
|
||||
orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
|
||||
.eq(ReservationOrderEntity::getHotelId, hotelId)
|
||||
.eq(ReservationOrderEntity::getOrderKeyType, ReservationOrderKeyType.GROUP_CODE.name())
|
||||
.and(wrapper -> wrapper
|
||||
.eq(ReservationOrderEntity::getActiveBusinessKey, groupCode)
|
||||
.or()
|
||||
.eq(ReservationOrderEntity::getOrderBusinessKey, groupCode))
|
||||
.orderByDesc(ReservationOrderEntity::getUpdatedAt))
|
||||
.forEach(entity -> orders.put(entity.getId(), toAiQueryOrderSnapshot(entity)));
|
||||
}
|
||||
if (hasText(confirmationNumber)) {
|
||||
orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
|
||||
.eq(ReservationOrderEntity::getHotelId, hotelId)
|
||||
.eq(ReservationOrderEntity::getOrderKeyType, ReservationOrderKeyType.CONFIRMATION_NUMBER.name())
|
||||
.and(wrapper -> wrapper
|
||||
.eq(ReservationOrderEntity::getActiveBusinessKey, confirmationNumber)
|
||||
.or()
|
||||
.eq(ReservationOrderEntity::getOrderBusinessKey, confirmationNumber))
|
||||
.orderByDesc(ReservationOrderEntity::getUpdatedAt))
|
||||
.forEach(entity -> orders.put(entity.getId(), toAiQueryOrderSnapshot(entity)));
|
||||
}
|
||||
return List.copyOf(orders.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单 ID 查询 AI 查询接口使用的订单快照。
|
||||
*/
|
||||
@Override
|
||||
public Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(String hotelId, Long orderId) {
|
||||
ReservationOrderEntity entity = orderMapper.selectOne(Wrappers.<ReservationOrderEntity>lambdaQuery()
|
||||
.eq(ReservationOrderEntity::getHotelId, hotelId)
|
||||
.eq(ReservationOrderEntity::getId, orderId)
|
||||
.last("LIMIT 1"));
|
||||
return Optional.ofNullable(entity).map(this::toAiQueryOrderSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定订单下全部任务,并补充对应 AI transition 的事件序号和 Skill 信息。
|
||||
*/
|
||||
@Override
|
||||
public List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByOrderIds(String hotelId, List<Long> orderIds) {
|
||||
if (orderIds == null || orderIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ReservationTaskEntity> tasks = taskMapper.selectList(Wrappers.<ReservationTaskEntity>lambdaQuery()
|
||||
.eq(ReservationTaskEntity::getHotelId, hotelId)
|
||||
.in(ReservationTaskEntity::getOrderId, orderIds)
|
||||
.orderByAsc(ReservationTaskEntity::getOrderId)
|
||||
.orderByAsc(ReservationTaskEntity::getExecutionOrder));
|
||||
return toAiQueryTaskSnapshots(hotelId, tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 AI transition 中冗余的 Group Code 或 Confirmation Number 查询任务。
|
||||
*/
|
||||
@Override
|
||||
public List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByBusinessKeys(
|
||||
String hotelId,
|
||||
String groupCode,
|
||||
String confirmationNumber) {
|
||||
List<ReservationAiTransitionEntity> transitions = findAiQueryTransitionsByBusinessKeys(
|
||||
hotelId,
|
||||
groupCode,
|
||||
confirmationNumber);
|
||||
if (transitions.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Long> transitionIds = transitions.stream().map(ReservationAiTransitionEntity::getId).toList();
|
||||
List<ReservationTaskEntity> tasks = taskMapper.selectList(Wrappers.<ReservationTaskEntity>lambdaQuery()
|
||||
.eq(ReservationTaskEntity::getHotelId, hotelId)
|
||||
.in(ReservationTaskEntity::getAiTransitionId, transitionIds)
|
||||
.orderByAsc(ReservationTaskEntity::getOrderId)
|
||||
.orderByAsc(ReservationTaskEntity::getExecutionOrder));
|
||||
return toAiQueryTaskSnapshots(hotelId, tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将临时订单激活为有真实业务号的 ACTIVE 订单。唯一约束冲突由 Service 转换为业务错误。
|
||||
*/
|
||||
@@ -580,6 +672,72 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按业务 key 查询 AI transition。该查询只用于补充待处理任务上下文,不替代订单查询。
|
||||
*/
|
||||
private List<ReservationAiTransitionEntity> findAiQueryTransitionsByBusinessKeys(
|
||||
String hotelId,
|
||||
String groupCode,
|
||||
String confirmationNumber) {
|
||||
if (!hasText(groupCode) && !hasText(confirmationNumber)) {
|
||||
return List.of();
|
||||
}
|
||||
return transitionMapper.selectList(Wrappers.<ReservationAiTransitionEntity>lambdaQuery()
|
||||
.eq(ReservationAiTransitionEntity::getHotelId, hotelId)
|
||||
.and(wrapper -> {
|
||||
boolean hasPrevious = false;
|
||||
if (hasText(groupCode)) {
|
||||
wrapper.eq(ReservationAiTransitionEntity::getGroupCode, groupCode);
|
||||
hasPrevious = true;
|
||||
}
|
||||
if (hasText(confirmationNumber)) {
|
||||
if (hasPrevious) {
|
||||
wrapper.or();
|
||||
}
|
||||
wrapper.eq(ReservationAiTransitionEntity::getConfirmationNumber, confirmationNumber);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任务实体批量转换为 AI 查询任务快照,补充 transition 中的 Skill 元数据。
|
||||
*/
|
||||
private List<ReservationAiQueryTaskSnapshot> toAiQueryTaskSnapshots(
|
||||
String hotelId,
|
||||
List<ReservationTaskEntity> tasks) {
|
||||
if (tasks == null || tasks.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, ReservationAiTransitionEntity> transitions = findTransitionsByIds(
|
||||
hotelId,
|
||||
tasks.stream().map(ReservationTaskEntity::getAiTransitionId).toList());
|
||||
return tasks.stream()
|
||||
.map(task -> toAiQueryTaskSnapshot(task, transitions.get(task.getAiTransitionId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 transition ID 批量查询,并按 ID 建立索引。
|
||||
*/
|
||||
private Map<Long, ReservationAiTransitionEntity> findTransitionsByIds(String hotelId, Collection<Long> transitionIds) {
|
||||
if (transitionIds == null || transitionIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, ReservationAiTransitionEntity> result = new LinkedHashMap<>();
|
||||
transitionMapper.selectList(Wrappers.<ReservationAiTransitionEntity>lambdaQuery()
|
||||
.eq(ReservationAiTransitionEntity::getHotelId, hotelId)
|
||||
.in(ReservationAiTransitionEntity::getId, transitionIds))
|
||||
.forEach(entity -> result.put(entity.getId(), entity));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文本是否可参与查询条件。
|
||||
*/
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换批次实体为快照。
|
||||
*/
|
||||
@@ -607,6 +765,29 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
entity.getOrderStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换订单实体为 AI 查询订单快照。
|
||||
*/
|
||||
private ReservationAiQueryOrderSnapshot toAiQueryOrderSnapshot(ReservationOrderEntity entity) {
|
||||
return new ReservationAiQueryOrderSnapshot(
|
||||
entity.getId(),
|
||||
entity.getHotelId(),
|
||||
entity.getOrderKeyType(),
|
||||
entity.getOrderBusinessKey(),
|
||||
entity.getActiveBusinessKey(),
|
||||
entity.getTemporaryOrderCode(),
|
||||
entity.getOrderStatus(),
|
||||
entity.getBusinessKeySource(),
|
||||
entity.getDisplayName(),
|
||||
entity.getSourceMessageId(),
|
||||
entity.getCreatedFromTaskId(),
|
||||
entity.getEndedAt(),
|
||||
entity.getLogicDeletedAt(),
|
||||
entity.getLogicDeletedReason(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换任务实体为快照。
|
||||
*/
|
||||
@@ -632,6 +813,38 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
entity.getConfirmedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换任务实体为 AI 查询任务快照,transition 为空时仍返回任务自身事实。
|
||||
*/
|
||||
private ReservationAiQueryTaskSnapshot toAiQueryTaskSnapshot(
|
||||
ReservationTaskEntity entity,
|
||||
ReservationAiTransitionEntity transition) {
|
||||
return new ReservationAiQueryTaskSnapshot(
|
||||
entity.getId(),
|
||||
entity.getHotelId(),
|
||||
entity.getOrderId(),
|
||||
entity.getSourceMessageId(),
|
||||
entity.getAiTransitionId(),
|
||||
entity.getResultType(),
|
||||
entity.getAiTaskType(),
|
||||
entity.getSystemTaskType(),
|
||||
entity.getTaskCardType(),
|
||||
entity.getTaskSubtype(),
|
||||
entity.getTaskStatus(),
|
||||
entity.getQueueParticipation(),
|
||||
entity.getExecutionOrder(),
|
||||
entity.getParentTaskId(),
|
||||
entity.getParentSourceEventIndex(),
|
||||
entity.getLinkedTaskGroupId(),
|
||||
entity.getBlockedUntilParentCompleted(),
|
||||
entity.getLastFailureReason(),
|
||||
entity.getCompletedAt(),
|
||||
entity.getUpdatedAt(),
|
||||
transition == null ? null : transition.getSourceEventIndex(),
|
||||
transition == null ? null : transition.getCatalogCode(),
|
||||
transition == null ? null : transition.getSkillId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换任务卡实体为快照。
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,8 @@ package cn.nianxx.thhotel.workflows.reservation.repository;
|
||||
|
||||
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.ReservationAiQueryOrderSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot;
|
||||
@@ -87,6 +89,32 @@ public interface ReservationAiWorkflowRepository {
|
||||
*/
|
||||
Optional<ReservationOrderSnapshot> findOrderById(String hotelId, Long orderId);
|
||||
|
||||
/**
|
||||
* 按 SuperAgent 查询 key 查找订单上下文,包含 ACTIVE、ENDED、LOGIC_DELETED 和 TEMPORARY。
|
||||
*/
|
||||
List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersByBusinessKeys(
|
||||
String hotelId,
|
||||
String groupCode,
|
||||
String confirmationNumber);
|
||||
|
||||
/**
|
||||
* 按订单 ID 查询 SuperAgent 查询接口使用的订单详情快照。
|
||||
*/
|
||||
Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(String hotelId, Long orderId);
|
||||
|
||||
/**
|
||||
* 查询指定订单下全部任务,并带上对应 AI transition 的路由字段。
|
||||
*/
|
||||
List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByOrderIds(String hotelId, List<Long> orderIds);
|
||||
|
||||
/**
|
||||
* 按 AI transition 冗余业务 key 查询任务,用于查到待处理任务但尚无正式订单的场景。
|
||||
*/
|
||||
List<ReservationAiQueryTaskSnapshot> findAiQueryTasksByBusinessKeys(
|
||||
String hotelId,
|
||||
String groupCode,
|
||||
String confirmationNumber);
|
||||
|
||||
/**
|
||||
* 将临时订单激活为带真实业务号的 ACTIVE 订单。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
|
||||
|
||||
/**
|
||||
* Reservation AI 查询服务。为 SuperAgent / Main Agent 提供只读上下文,不产生业务写入。
|
||||
*/
|
||||
public interface ReservationAiQueryService {
|
||||
|
||||
/**
|
||||
* 查询订单路由上下文,返回订单、未完成任务、终止记录和目标校验摘要。
|
||||
*/
|
||||
ReservationAiCaseContextResult queryCaseContext(ReservationAiCaseContextQueryRequest request);
|
||||
|
||||
/**
|
||||
* 查询单个对象详情。第一版只支持 ORDER:{orderId} 形式的本系统订单对象。
|
||||
*/
|
||||
ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* SuperAgent 查询接口受控异常。用于把参数错误和对象不存在转换为稳定 JSON 响应。
|
||||
*/
|
||||
public class ReservationAiQueryException extends RuntimeException {
|
||||
|
||||
private final HttpStatus status;
|
||||
private final String errorCode;
|
||||
private final Map<String, Object> details;
|
||||
|
||||
public ReservationAiQueryException(HttpStatus status, String errorCode, String message) {
|
||||
this(status, errorCode, message, Map.of());
|
||||
}
|
||||
|
||||
public ReservationAiQueryException(
|
||||
HttpStatus status,
|
||||
String errorCode,
|
||||
String message,
|
||||
Map<String, Object> details) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.errorCode = errorCode;
|
||||
this.details = details == null ? Map.of() : details;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public Map<String, Object> getDetails() {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiCaseContextQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjectDetailQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiCaseContextResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiObjectDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryWarningResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Reservation AI 查询服务实现。只读取订单、任务和 AI 过渡层事实,不执行业务状态流转。
|
||||
*/
|
||||
@Service
|
||||
public class ReservationAiQueryServiceImpl implements ReservationAiQueryService {
|
||||
|
||||
private static final String OBJECT_ID_PREFIX_ORDER = "ORDER:";
|
||||
private static final String SOURCE_TABLE_ORDER = "workflow_reservation_order";
|
||||
private static final String WARNING_OPERA_PROJECTION_UNAVAILABLE = "OPERA_PROJECTION_UNAVAILABLE";
|
||||
private static final Set<String> OPEN_TASK_STATUSES = Set.of(
|
||||
ReservationTaskStatus.PENDING_CONFIRM.name(),
|
||||
ReservationTaskStatus.READY.name(),
|
||||
ReservationTaskStatus.EXECUTING.name());
|
||||
private static final Set<String> TERMINATED_TASK_STATUSES = Set.of(
|
||||
ReservationTaskStatus.FAILED.name(),
|
||||
ReservationTaskStatus.COMPLETED.name());
|
||||
|
||||
private final ReservationAiWorkflowRepository repository;
|
||||
|
||||
/**
|
||||
* 注入 Reservation 工作流持久化边界,Service 不直接依赖 Mapper。
|
||||
*/
|
||||
public ReservationAiQueryServiceImpl(ReservationAiWorkflowRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单上下文。按业务 key 查订单,并合并同订单任务与同 key AI transition 任务。
|
||||
*/
|
||||
@Override
|
||||
public ReservationAiCaseContextResult queryCaseContext(ReservationAiCaseContextQueryRequest request) {
|
||||
validateCaseContextRequest(request);
|
||||
String hotelId = trimToNull(request.hotelId());
|
||||
String groupCode = trimToNull(request.groupCode());
|
||||
String confirmationNumber = trimToNull(request.confirmationNumber());
|
||||
if (groupCode == null && confirmationNumber == null) {
|
||||
return unsupportedReservationNoOnlyResult();
|
||||
}
|
||||
|
||||
List<ReservationAiQueryOrderSnapshot> orders = repository.findAiQueryOrdersByBusinessKeys(
|
||||
hotelId,
|
||||
groupCode,
|
||||
confirmationNumber);
|
||||
List<Long> orderIds = orders.stream().map(ReservationAiQueryOrderSnapshot::id).toList();
|
||||
List<ReservationAiQueryTaskSnapshot> tasks = mergeTasks(
|
||||
repository.findAiQueryTasksByOrderIds(hotelId, orderIds),
|
||||
repository.findAiQueryTasksByBusinessKeys(hotelId, groupCode, confirmationNumber));
|
||||
|
||||
List<ReservationAiCaseContextResult.PendingOrOpenTask> openTasks = tasks.stream()
|
||||
.filter(task -> OPEN_TASK_STATUSES.contains(task.taskStatus()))
|
||||
.sorted(Comparator
|
||||
.comparing(ReservationAiQueryTaskSnapshot::orderId, Comparator.nullsLast(Long::compareTo))
|
||||
.thenComparing(ReservationAiQueryTaskSnapshot::executionOrder, Comparator.nullsLast(Integer::compareTo)))
|
||||
.map(this::toPendingOrOpenTask)
|
||||
.toList();
|
||||
List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords = terminatedRecords(orders, tasks);
|
||||
List<ReservationAiQueryOrderSnapshot> activeOrders = orders.stream()
|
||||
.filter(order -> ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus()))
|
||||
.toList();
|
||||
|
||||
return new ReservationAiCaseContextResult(
|
||||
orders.stream().map(this::toMatchedOrderRecord).toList(),
|
||||
openTasks,
|
||||
List.of(),
|
||||
terminatedRecords,
|
||||
targetObjectValidation(activeOrders, openTasks, terminatedRecords),
|
||||
new ReservationAiCaseContextResult.KeyRelationships(null, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* reservation_no 当前没有可靠表源时,不返回“可创建新任务”的误导性结论。
|
||||
*/
|
||||
private ReservationAiCaseContextResult unsupportedReservationNoOnlyResult() {
|
||||
return new ReservationAiCaseContextResult(
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
new ReservationAiCaseContextResult.TargetObjectValidation(
|
||||
"conflict",
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"UNSUPPORTED_RESERVATION_NO_QUERY"),
|
||||
new ReservationAiCaseContextResult.KeyRelationships(null, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询对象详情。当前仅解析 ORDER:{orderId},OPERA 投影字段返回 null 和 warning。
|
||||
*/
|
||||
@Override
|
||||
public ReservationAiObjectDetailResult queryObjectDetail(ReservationAiObjectDetailQueryRequest request) {
|
||||
validateObjectDetailRequest(request);
|
||||
String hotelId = trimToNull(request.hotelId());
|
||||
Long orderId = parseOrderObjectId(request.objectId());
|
||||
ReservationAiQueryOrderSnapshot order = repository.findAiQueryOrderById(hotelId, orderId)
|
||||
.orElseThrow(() -> new ReservationAiQueryException(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"OBJECT_NOT_FOUND",
|
||||
"查询对象不存在"));
|
||||
List<ReservationAiQueryTaskSnapshot> tasks = repository.findAiQueryTasksByOrderIds(hotelId, List.of(order.id()));
|
||||
boolean hasOpenTask = tasks.stream().anyMatch(task -> OPEN_TASK_STATUSES.contains(task.taskStatus()));
|
||||
boolean active = ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus());
|
||||
boolean canMutate = active && !hasOpenTask;
|
||||
|
||||
return new ReservationAiObjectDetailResult(
|
||||
objectId(order.id()),
|
||||
objectType(order),
|
||||
idString(order.id()),
|
||||
order.orderKeyType(),
|
||||
groupCode(order),
|
||||
confirmationNumber(order),
|
||||
null,
|
||||
null,
|
||||
order.temporaryOrderCode(),
|
||||
order.displayName(),
|
||||
order.orderStatus(),
|
||||
idString(order.sourceMessageId()),
|
||||
idString(order.createdFromTaskId()),
|
||||
order.createdAt(),
|
||||
order.updatedAt(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
cancelStatus(order),
|
||||
canMutate,
|
||||
canMutate,
|
||||
List.of(new ReservationAiQueryWarningResult(
|
||||
WARNING_OPERA_PROJECTION_UNAVAILABLE,
|
||||
"当前系统尚未接入 OPERA 对象投影,日期、房型、房价等字段无法确认。")));
|
||||
}
|
||||
|
||||
private ReservationAiCaseContextResult.MatchedOrderRecord toMatchedOrderRecord(
|
||||
ReservationAiQueryOrderSnapshot order) {
|
||||
return new ReservationAiCaseContextResult.MatchedOrderRecord(
|
||||
objectId(order.id()),
|
||||
objectType(order),
|
||||
idString(order.id()),
|
||||
order.orderKeyType(),
|
||||
groupCode(order),
|
||||
confirmationNumber(order),
|
||||
order.temporaryOrderCode(),
|
||||
order.displayName(),
|
||||
order.orderStatus(),
|
||||
order.businessKeySource(),
|
||||
SOURCE_TABLE_ORDER,
|
||||
order.updatedAt());
|
||||
}
|
||||
|
||||
private ReservationAiCaseContextResult.PendingOrOpenTask toPendingOrOpenTask(
|
||||
ReservationAiQueryTaskSnapshot task) {
|
||||
return new ReservationAiCaseContextResult.PendingOrOpenTask(
|
||||
idString(task.id()),
|
||||
idString(task.orderId()),
|
||||
idString(task.sourceMessageId()),
|
||||
task.transitionSourceEventIndex(),
|
||||
task.catalogCode(),
|
||||
task.skillId(),
|
||||
task.resultType(),
|
||||
task.aiTaskType(),
|
||||
task.systemTaskType(),
|
||||
task.taskCardType(),
|
||||
task.taskSubtype(),
|
||||
task.taskStatus(),
|
||||
task.queueParticipation(),
|
||||
task.executionOrder(),
|
||||
idString(task.parentTaskId()),
|
||||
task.parentSourceEventIndex(),
|
||||
task.linkedTaskGroupId(),
|
||||
task.blockedUntilParentCompleted(),
|
||||
task.updatedAt());
|
||||
}
|
||||
|
||||
private List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords(
|
||||
List<ReservationAiQueryOrderSnapshot> orders,
|
||||
List<ReservationAiQueryTaskSnapshot> tasks) {
|
||||
List<ReservationAiCaseContextResult.TerminatedRecord> orderRecords = orders.stream()
|
||||
.filter(order -> ReservationOrderStatus.ENDED.name().equals(order.orderStatus())
|
||||
|| ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus()))
|
||||
.map(order -> new ReservationAiCaseContextResult.TerminatedRecord(
|
||||
"order",
|
||||
idString(order.id()),
|
||||
order.orderStatus(),
|
||||
order.logicDeletedReason(),
|
||||
terminatedAt(order),
|
||||
order.updatedAt()))
|
||||
.toList();
|
||||
List<ReservationAiCaseContextResult.TerminatedRecord> taskRecords = tasks.stream()
|
||||
.filter(task -> TERMINATED_TASK_STATUSES.contains(task.taskStatus()))
|
||||
.map(task -> new ReservationAiCaseContextResult.TerminatedRecord(
|
||||
"task",
|
||||
idString(task.id()),
|
||||
task.taskStatus(),
|
||||
task.lastFailureReason(),
|
||||
task.completedAt(),
|
||||
task.updatedAt()))
|
||||
.toList();
|
||||
return Stream.concat(orderRecords.stream(), taskRecords.stream()).toList();
|
||||
}
|
||||
|
||||
private ReservationAiCaseContextResult.TargetObjectValidation targetObjectValidation(
|
||||
List<ReservationAiQueryOrderSnapshot> activeOrders,
|
||||
List<ReservationAiCaseContextResult.PendingOrOpenTask> openTasks,
|
||||
List<ReservationAiCaseContextResult.TerminatedRecord> terminatedRecords) {
|
||||
boolean hasOpenTask = !openTasks.isEmpty();
|
||||
boolean hasNewBookingOpenTask = openTasks.stream()
|
||||
.anyMatch(task -> ReservationSystemTaskType.NEW_BOOKING.name().equals(task.systemTaskType()));
|
||||
if (activeOrders.size() > 1) {
|
||||
return new ReservationAiCaseContextResult.TargetObjectValidation(
|
||||
"multiple",
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"MULTIPLE_ACTIVE_OBJECTS");
|
||||
}
|
||||
if (activeOrders.size() == 1) {
|
||||
ReservationAiQueryOrderSnapshot order = activeOrders.get(0);
|
||||
boolean canOperate = !hasOpenTask;
|
||||
return new ReservationAiCaseContextResult.TargetObjectValidation(
|
||||
hasOpenTask ? "conflict" : "single",
|
||||
objectId(order.id()),
|
||||
objectType(order),
|
||||
false,
|
||||
canOperate,
|
||||
canOperate,
|
||||
true,
|
||||
true,
|
||||
hasOpenTask ? "OPEN_TASK_EXISTS" : null);
|
||||
}
|
||||
return new ReservationAiCaseContextResult.TargetObjectValidation(
|
||||
terminatedRecords.isEmpty() ? "none" : "conflict",
|
||||
null,
|
||||
null,
|
||||
!hasOpenTask,
|
||||
false,
|
||||
false,
|
||||
hasNewBookingOpenTask,
|
||||
hasNewBookingOpenTask,
|
||||
terminatedRecords.isEmpty() ? null : "TERMINATED_RECORD_EXISTS");
|
||||
}
|
||||
|
||||
private List<ReservationAiQueryTaskSnapshot> mergeTasks(
|
||||
List<ReservationAiQueryTaskSnapshot> first,
|
||||
List<ReservationAiQueryTaskSnapshot> second) {
|
||||
Map<Long, ReservationAiQueryTaskSnapshot> merged = new LinkedHashMap<>();
|
||||
first.forEach(task -> merged.put(task.id(), task));
|
||||
second.forEach(task -> merged.putIfAbsent(task.id(), task));
|
||||
return List.copyOf(merged.values());
|
||||
}
|
||||
|
||||
private void validateCaseContextRequest(ReservationAiCaseContextQueryRequest request) {
|
||||
if (request == null) {
|
||||
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
|
||||
}
|
||||
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
|
||||
parseLong(request.sourceMessageId(), "SOURCE_MESSAGE_ID_INVALID", "source_message_id 必须是数字字符串");
|
||||
if (request.sourceEventIndex() == null || request.sourceEventIndex() <= 0) {
|
||||
throw badRequest("SOURCE_EVENT_INDEX_INVALID", "source_event_index 必须是正整数");
|
||||
}
|
||||
if (trimToNull(request.groupCode()) == null
|
||||
&& trimToNull(request.confirmationNumber()) == null
|
||||
&& trimToNull(request.reservationNo()) == null) {
|
||||
throw badRequest("QUERY_KEY_REQUIRED", "group_code、confirmation_number、reservation_no 至少需要一个");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateObjectDetailRequest(ReservationAiObjectDetailQueryRequest request) {
|
||||
if (request == null) {
|
||||
throw badRequest("MISSING_REQUEST_BODY", "请求体不能为空");
|
||||
}
|
||||
requireText(request.hotelId(), "HOTEL_ID_REQUIRED", "hotel_id 不能为空");
|
||||
requireText(request.objectId(), "OBJECT_ID_REQUIRED", "object_id 不能为空");
|
||||
}
|
||||
|
||||
private ReservationAiQueryException badRequest(String code, String message) {
|
||||
return new ReservationAiQueryException(HttpStatus.BAD_REQUEST, code, message);
|
||||
}
|
||||
|
||||
private void requireText(String value, String code, String message) {
|
||||
if (trimToNull(value) == null) {
|
||||
throw badRequest(code, message);
|
||||
}
|
||||
}
|
||||
|
||||
private Long parseOrderObjectId(String objectId) {
|
||||
String text = trimToNull(objectId);
|
||||
if (text == null || !text.startsWith(OBJECT_ID_PREFIX_ORDER)) {
|
||||
throw badRequest("OBJECT_ID_INVALID", "object_id 第一版仅支持 ORDER:{orderId}");
|
||||
}
|
||||
return parseLong(text.substring(OBJECT_ID_PREFIX_ORDER.length()), "OBJECT_ID_INVALID", "object_id 中的订单 ID 不合法");
|
||||
}
|
||||
|
||||
private Long parseLong(String value, String code, String message) {
|
||||
try {
|
||||
return Long.valueOf(Objects.requireNonNull(trimToNull(value)));
|
||||
} catch (RuntimeException ex) {
|
||||
throw badRequest(code, message);
|
||||
}
|
||||
}
|
||||
|
||||
private String objectId(Long orderId) {
|
||||
return OBJECT_ID_PREFIX_ORDER + orderId;
|
||||
}
|
||||
|
||||
private String objectType(ReservationAiQueryOrderSnapshot order) {
|
||||
if (ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
|
||||
return "group_block";
|
||||
}
|
||||
if (ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
|
||||
return "fit_reservation";
|
||||
}
|
||||
return "temporary_order";
|
||||
}
|
||||
|
||||
private String groupCode(ReservationAiQueryOrderSnapshot order) {
|
||||
if (!ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
|
||||
return null;
|
||||
}
|
||||
return Optional.ofNullable(trimToNull(order.activeBusinessKey())).orElse(order.orderBusinessKey());
|
||||
}
|
||||
|
||||
private String confirmationNumber(ReservationAiQueryOrderSnapshot order) {
|
||||
if (!ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
|
||||
return null;
|
||||
}
|
||||
return Optional.ofNullable(trimToNull(order.activeBusinessKey())).orElse(order.orderBusinessKey());
|
||||
}
|
||||
|
||||
private String cancelStatus(ReservationAiQueryOrderSnapshot order) {
|
||||
if (ReservationOrderStatus.ENDED.name().equals(order.orderStatus())
|
||||
|| ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus())) {
|
||||
return "cancelled";
|
||||
}
|
||||
return ReservationOrderStatus.ACTIVE.name().equals(order.orderStatus()) ? "not_cancelled" : "unknown";
|
||||
}
|
||||
|
||||
private LocalDateTime terminatedAt(ReservationAiQueryOrderSnapshot order) {
|
||||
if (order.logicDeletedAt() != null) {
|
||||
return order.logicDeletedAt();
|
||||
}
|
||||
return order.endedAt();
|
||||
}
|
||||
|
||||
private String idString(Long id) {
|
||||
return id == null ? null : id.toString();
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(classes = ThHotelApplication.class)
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ReservationAiQueryControllerTest {
|
||||
|
||||
private static final String HOTEL_ID = "HOTEL-TEST";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
void shouldReturnCaseContextWithMatchedOrderAndPendingTask() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-case-001");
|
||||
insertActiveGroupOrder(920000000000000101L, source.inboxId(), "GRP-AIQUERY-001");
|
||||
insertTransition(920000000000000201L, source.inboxId(), 1, "GRP-AIQUERY-001");
|
||||
insertTask(920000000000000301L, 920000000000000101L, source.inboxId(), 920000000000000201L, "PENDING_CONFIRM");
|
||||
|
||||
mockMvc.perform(post("/api/ai-query/v1/case-context")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Request-Id", "req-ai-query-case-001")
|
||||
.header("X-AI-Trace-Id", "trace-ai-query-case-001")
|
||||
.content("""
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "%s",
|
||||
"source_event_index": 1,
|
||||
"group_code": "GRP-AIQUERY-001",
|
||||
"target_key_source": "body_current",
|
||||
"body_thread_used_only_as_evidence": false
|
||||
}
|
||||
""".formatted(source.inboxId())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.request_id").value("req-ai-query-case-001"))
|
||||
.andExpect(jsonPath("$.trace_id").value("trace-ai-query-case-001"))
|
||||
.andExpect(jsonPath("$.data.matched_order_records[0].object_id").value("ORDER:920000000000000101"))
|
||||
.andExpect(jsonPath("$.data.matched_order_records[0].object_type").value("group_block"))
|
||||
.andExpect(jsonPath("$.data.matched_order_records[0].order_id").value("920000000000000101"))
|
||||
.andExpect(jsonPath("$.data.matched_order_records[0].group_code").value("GRP-AIQUERY-001"))
|
||||
.andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_id").value("920000000000000301"))
|
||||
.andExpect(jsonPath("$.data.pending_or_open_tasks[0].source_event_index").value(1))
|
||||
.andExpect(jsonPath("$.data.pending_or_open_tasks[0].task_status").value("PENDING_CONFIRM"))
|
||||
.andExpect(jsonPath("$.data.active_workflows.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.terminated_records.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.status").value("conflict"))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(false))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.can_create_update_task").value(false))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.needs_manual_review_reason").value("OPEN_TASK_EXISTS"))
|
||||
.andExpect(jsonPath("$.data.key_relationships.group_code_and_confirmation_same_object").value(nullValue()))
|
||||
.andExpect(content().string(not(containsString("Please handle booking message"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnSuccessfulEmptyCaseContextWhenNoObjectMatches() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-empty-001");
|
||||
|
||||
mockMvc.perform(post("/api/ai-query/v1/case-context")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Request-Id", "req-ai-query-empty-001")
|
||||
.content("""
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "%s",
|
||||
"source_event_index": 1,
|
||||
"group_code": "GRP-AIQUERY-NOT-FOUND"
|
||||
}
|
||||
""".formatted(source.inboxId())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.pending_or_open_tasks.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.active_workflows.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.terminated_records.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.status").value("none"))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotAllowCreationWhenOnlyUnsupportedReservationNoProvided() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-reservation-no-001");
|
||||
|
||||
mockMvc.perform(post("/api/ai-query/v1/case-context")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Request-Id", "req-ai-query-reservation-no-001")
|
||||
.content("""
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"source_message_id": "%s",
|
||||
"source_event_index": 1,
|
||||
"reservation_no": "RESV-AIQUERY-001"
|
||||
}
|
||||
""".formatted(source.inboxId())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.matched_order_records.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.pending_or_open_tasks.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.status").value("conflict"))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.can_create_new_booking_task").value(false))
|
||||
.andExpect(jsonPath("$.data.target_object_validation.needs_manual_review_reason")
|
||||
.value("UNSUPPORTED_RESERVATION_NO_QUERY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnObjectDetailWithNullableOperaProjectionWarning() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-ai-query-detail-001");
|
||||
insertActiveGroupOrder(920000000000000401L, source.inboxId(), "GRP-AIQUERY-DETAIL-001");
|
||||
|
||||
mockMvc.perform(post("/api/ai-query/v1/object-detail")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Request-Id", "req-ai-query-detail-001")
|
||||
.header("X-AI-Trace-Id", "trace-ai-query-detail-001")
|
||||
.content("""
|
||||
{
|
||||
"hotel_id": "HOTEL-TEST",
|
||||
"object_id": "ORDER:920000000000000401",
|
||||
"object_type": "group_block"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.object_id").value("ORDER:920000000000000401"))
|
||||
.andExpect(jsonPath("$.data.object_type").value("group_block"))
|
||||
.andExpect(jsonPath("$.data.order_id").value("920000000000000401"))
|
||||
.andExpect(jsonPath("$.data.group_code").value("GRP-AIQUERY-DETAIL-001"))
|
||||
.andExpect(jsonPath("$.data.reservation_no").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.block_id").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.room_items.length()").value(0))
|
||||
.andExpect(jsonPath("$.data.rate_code_price").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.can_update").value(true))
|
||||
.andExpect(jsonPath("$.data.can_cancel").value(true))
|
||||
.andExpect(jsonPath("$.data.hard_validation_warnings[0].code").value("OPERA_PROJECTION_UNAVAILABLE"));
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
HOTEL_ID,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-ai-query",
|
||||
Instant.parse("2026-07-07T08:00:00Z"),
|
||||
"guest@example.test",
|
||||
"M002 AI Query",
|
||||
"Please handle booking message.",
|
||||
"<html><body>Please handle booking message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO workflow_reservation_order (
|
||||
id, hotel_id, order_key_type, order_business_key, active_business_key,
|
||||
temporary_order_code, order_status, business_key_source, display_name,
|
||||
source_message_id, version, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'AI_CANDIDATE', ?, ?, 0,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", orderId, HOTEL_ID, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId);
|
||||
}
|
||||
|
||||
private void insertTransition(Long transitionId, Long sourceMessageId, Integer sourceEventIndex, String groupCode) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO workflow_reservation_ai_transition (
|
||||
id, hotel_id, batch_id, source_message_id, source_event_index, array_index,
|
||||
execution_order, catalog_code, skill_id, result_type, ai_task_type,
|
||||
system_task_type, task_card_type, task_subtype, current_or_history,
|
||||
group_code, item_payload_sha256, item_idempotency_key, blocked_until_parent_completed,
|
||||
ai_payload_json, case_keys_json, extracted_fields_json, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 1, 1, 'S02', 'update_booking_amendment_skill',
|
||||
'normal_task', 'Update Booking', 'UPDATE_BOOKING', 'UPDATE_BOOKING',
|
||||
'update_stay_dates', 'current', ?, ?, ?, 0, '{}', '{}', '{}',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", transitionId, HOTEL_ID, transitionId - 1, sourceMessageId, sourceEventIndex, groupCode,
|
||||
"0".repeat(64), "1".repeat(64));
|
||||
}
|
||||
|
||||
private void insertTask(Long taskId, Long orderId, Long sourceMessageId, Long transitionId, String taskStatus) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO workflow_reservation_task (
|
||||
id, hotel_id, order_id, source_message_id, ai_transition_id,
|
||||
result_type, ai_task_type, system_task_type, task_card_type, task_subtype,
|
||||
task_status, queue_participation, execution_order, blocked_until_parent_completed,
|
||||
version, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 'normal_task', 'Update Booking', 'UPDATE_BOOKING',
|
||||
'UPDATE_BOOKING', 'update_stay_dates', ?, 1, 1, 0, 0,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, taskStatus);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user