From 652c5c10c530e3d41c8054046db97309dbc65163 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 7 Jul 2026 19:56:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20SuperAgent=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../M002-ai-query-minimal-fields.md | 456 ++++++++++++++++++ .../M002-order-task-workflow-v2.md | 8 +- .../dto/ReservationAiQueryOrderSnapshot.java | 43 ++ .../dto/ReservationAiQueryTaskSnapshot.java | 57 +++ .../ReservationAiCaseContextQueryRequest.java | 38 ++ ...ReservationAiObjectDetailQueryRequest.java | 20 + .../ReservationAiCaseContextResult.java | 217 +++++++++ .../ReservationAiObjectDetailResult.java | 115 +++++ .../result/ReservationAiQueryErrorResult.java | 17 + .../result/ReservationAiQueryResponse.java | 47 ++ .../ReservationAiQueryWarningResult.java | 13 + .../control/ReservationAiQueryController.java | 62 +++ .../ReservationAiQueryControllerAdvice.java | 52 ++ ...ybatisReservationAiWorkflowRepository.java | 213 ++++++++ .../ReservationAiWorkflowRepository.java | 28 ++ .../service/ReservationAiQueryService.java | 22 + .../impl/ReservationAiQueryException.java | 41 ++ .../impl/ReservationAiQueryServiceImpl.java | 394 +++++++++++++++ .../ReservationAiQueryControllerTest.java | 227 +++++++++ 19 files changed, 2066 insertions(+), 4 deletions(-) create mode 100644 docs/project/requirements/M002-ai-query-minimal-fields.md create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiObjectDetailQueryRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryErrorResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryResponse.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryWarningResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryException.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java diff --git a/docs/project/requirements/M002-ai-query-minimal-fields.md b/docs/project/requirements/M002-ai-query-minimal-fields.md new file mode 100644 index 0000000..88df401 --- /dev/null +++ b/docs/project/requirements/M002-ai-query-minimal-fields.md @@ -0,0 +1,456 @@ +# M002 SuperAgent 查询上下文接口最小字段定义 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-07 | +| 状态 | 第一版后端实现依据与落地记录 | +| 适用范围 | SuperAgent / Main Agent 调用本系统查询订单和任务上下文 | +| 主要读者 | 后端、SuperAgent 对接方、测试、后续协作 agent | + +## 1. 文档定位 + +本文用于把导入文档中的查询接口契约,收敛为本项目当前可开发的第一版最小字段范围,并记录当前后端已落地的接口 1、接口 2 范围。 + +导入契约中定义了四个只读查询接口: + +| 逻辑接口 | 原始定位 | 本项目第一版处理 | +| --- | --- | --- | +| `query_case_context` | 根据业务 key 查询订单、任务和终止上下文 | 已实现 | +| `query_object_detail` | 根据对象 ID 查询更完整对象快照 | 已实现最小可用版 | +| `query_file_parse_context` | 查询附件、OCR、Excel、voucher、rooming list 解析结果 | 当前系统没有附件解析能力,暂不实现 | +| `query_parent_task_context` | 查询 linked task 的父任务上下文 | 后续改为“订单及其下面任务查询”后再定义 | + +本文只定义接口 1、接口 2 的最小请求字段、最小响应字段、字段用途和当前系统数据来源。 + +## 2. 总原则 + +- 两个接口都是只读接口,不创建任务、不写 OPERA、不修改订单、不确认字段。 +- 查询结果只作为 `routing_context` 或对象事实传给 Skill,不等同于最终业务结论。 +- `body_current` 才能触发业务动作;`body_thread` 只能作为目标绑定证据。 +- 如果查询 key 来自历史线程,调用方必须传 `target_key_source=body_thread_evidence` 和 `body_thread_used_only_as_evidence=true`。 +- 第一版不伪造 OPERA 字段。当前系统没有可靠来源的字段返回 `null`,并在 `warnings` 或 `hard_validation_warnings` 中说明。 +- 导入契约没有显式要求 `hotel_id`,但本系统订单、任务和 AI 过渡表均按 `hotel_id` 隔离。第一版建议请求体显式传 `hotel_id`;如果后续改为从鉴权或 `source_message_id` 解析酒店,需要在实现前统一。 + +## 3. Skill 对接口 1、2 的实际需要 + +| Skill | 接口 1:case-context 最小需要 | 接口 2:object-detail 最小需要 | +| --- | --- | --- | +| S01 New Booking | 判断同 key 是否已有 ACTIVE 订单、pending/open task、终止记录;判断是否允许继续生成 New Booking | 通常不需要。若同 key 已有对象,需要展示冲突对象摘要时可查 | +| S02 Update Booking | 根据 `group_code` / `confirmation_number` 绑定既有对象;判断是否有前置未完成任务、终止记录或冲突对象 | 需要既有对象快照,支撑 before/after、状态是否可更新、日期/房型/房量/价格等事实 | +| S03 Cancel Booking | 根据 key 定位待取消对象;判断对象是否存在、是否已终止、是否有前置未完成任务 | 需要对象状态、取消状态、是否可取消,以及对象展示摘要 | +| S04 Voucher Received | 校验凭证能否绑定有效订单;如果无有效订单,是否存在可承接的待完善 New Booking 任务 | 可选,用于目标展示和硬校验,不负责解析凭证文件 | +| S05 Rooming List | 校验名单目标 key 是否能绑定有效订单或待完善 New Booking 任务 | 可选,用于目标展示和硬校验;文件读取和名单解析不在接口 2 内 | +| S06 Amend Group Code | 用旧 `group_code` 定位既有对象;判断改团号任务是否有明显阻断 | 可选,用于展示旧对象状态;新旧 group code 关系主要来自 Skill 输出 | +| S07 Trace / Reservation Notes | 绑定目标对象;如果目标 key 只来自 history,需要保留 key 来源;如果是 linked task,父任务关系后续由接口 4 新定义 | extra bed 场景需要 `rate_code_price` 事实;当前拿不到时返回 `null` 和 warning | +| S08 TA Recorder | 绑定 S05 已确认的目标 `group_code`;父任务关系后续由接口 4 新定义 | 可选,用于目标展示和附件绑定后的硬校验 | + +结论: + +- 接口 1 是 S01-S08 都可能使用的路由级上下文接口。 +- 接口 2 不是所有 Skill 都必须调用,主要服务 S02、S03、S07,S04/S05/S08 只在目标展示或硬校验时需要。 +- 接口 3 的文件解析能力当前不具备,不应为了满足导入契约而返回假解析结果。 +- 接口 4 的原始 parent task 语义和本系统后续需要不完全一致,先不实现,后续改成订单及任务查询。 + +## 4. 通用请求与响应 + +### 4.1 通用请求头 + +安全方向上建议后续复用 SuperAgent 服务到服务鉴权思路,具体签名规则可参考任务结果接收接口。当前已落地的最小字段版暂不启用 HMAC,只强制 `X-Request-Id`,接口补签名规则前不得把该接口暴露到不可信网络。 + +| Header | 是否必填 | 中文说明 | +| --- | --- | --- | +| `Content-Type` | 是 | 固定 `application/json` | +| `X-Request-Id` | 是 | 调用方生成的请求 ID,用于日志串联 | +| `X-AI-Trace-Id` | 否 | AI 运行链路 ID | +| `X-Source-Message-Id` | 否 | 来源消息 ID,便于排查 | + +### 4.2 通用响应包 + +成功响应: + +```json +{ + "success": true, + "request_id": "req-001", + "trace_id": "trace-001", + "data": {}, + "warnings": [], + "error": null +} +``` + +失败响应: + +```json +{ + "success": false, + "request_id": "req-001", + "trace_id": "trace-001", + "data": null, + "warnings": [], + "error": { + "code": "BAD_REQUEST", + "message": "请求参数不合法", + "details": {} + } +} +``` + +### 4.3 ID 序列化口径 + +本系统数据库主键是 `BIGINT`。查询接口面向 SuperAgent / Main Agent 等外部运行环境,第一版建议对外 JSON 中的内部长整型 ID 使用字符串返回,避免 JavaScript 或其他运行时出现整数精度问题。 + +适用字段包括: + +- `source_message_id` +- `order_id` +- `task_id` +- `record_id` +- `created_from_task_id` + +如果后端实现决定沿用 Spring 默认数字序列化,接口 1、接口 2 必须保持一致,并在实现前更新本文档示例。 + +## 5. 接口 1:query_case_context + +### 5.1 路径 + +```text +POST /api/ai-query/v1/case-context +``` + +### 5.2 请求字段 + +| 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 | +| --- | --- | --- | --- | +| `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离 `workflow_reservation_*` 表 | +| `source_message_id` | 是 | 当前 SourceMessage ID | 串联来源消息、AI 过渡记录和任务 | +| `source_event_index` | 是 | 当前 current 事件序号 | 和 AI 拆分结果保持一致 | +| `group_code` | 条件必填 | Group / Allotment 优先业务 key | 查询 `GROUP_CODE` 类型订单和 AI 过渡记录 | +| `confirmation_number` | 条件必填 | FIT 优先业务 key | 查询 `CONFIRMATION_NUMBER` 类型订单和 AI 过渡记录 | +| `reservation_no` | 否 | OPERA reservation no | 当前无可靠表源,第一版不作为主查询条件 | +| `object_type_hint` | 否 | 调用方推测的对象类型 | 只作为查询提示,不作为事实 | +| `target_key_source` | 否 | key 来源:`body_current`、`body_thread_evidence`、`upstream_task_context`、`system_context` | 用于记录 current/history 边界 | +| `body_thread_used_only_as_evidence` | 否 | key 是否只来自历史线程证据 | 为 true 时,后端仍只返回事实,不触发任何业务动作 | + +`group_code`、`confirmation_number`、`reservation_no` 至少应有一个非空。第一版实际可查询能力优先支持 `group_code` 和 `confirmation_number`。 + +如果第一版请求只提供 `reservation_no`,后端不会把“查不到”解释为“可以创建新任务”,而是返回空事实列表,并在 `target_object_validation.needs_manual_review_reason` 中返回 `UNSUPPORTED_RESERVATION_NO_QUERY`,提示调用方转人工或等待后续 OPERA 投影表接入。 + +### 5.3 响应字段 + +#### `matched_order_records[]` + +返回当前系统中能通过 key 匹配到的订单摘要。 + +| 字段 | 是否必返 | 中文说明 | 当前系统来源 | +| --- | --- | --- | --- | +| `object_id` | 是 | 查询对象 ID,第一版建议格式 `ORDER:{order_id}` | `workflow_reservation_order.id` | +| `object_type` | 是 | 对象类型,第一版按 key 粗略映射 | `GROUP_CODE` 暂映射 `group_block`,`CONFIRMATION_NUMBER` 映射 `fit_reservation`,`TEMPORARY` 映射 `temporary_order` | +| `order_id` | 是 | 本系统订单 ID | `workflow_reservation_order.id` | +| `order_key_type` | 是 | 业务号类型 | `workflow_reservation_order.order_key_type` | +| `group_code` | 否 | Group Code | 当 `order_key_type=GROUP_CODE` 时来自 `active_business_key` 或 `order_business_key` | +| `confirmation_number` | 否 | Confirmation Number | 当 `order_key_type=CONFIRMATION_NUMBER` 时来自 `active_business_key` 或 `order_business_key` | +| `temporary_order_code` | 是 | 临时订单编号 | `workflow_reservation_order.temporary_order_code` | +| `display_name` | 是 | 用户可读展示名 | `workflow_reservation_order.display_name` | +| `status` | 是 | 订单状态 | `workflow_reservation_order.order_status` | +| `business_key_source` | 否 | 业务号来源 | `workflow_reservation_order.business_key_source` | +| `source_table` | 是 | 来源表名 | 固定 `workflow_reservation_order` | +| `last_updated_at` | 是 | 最近更新时间 | `workflow_reservation_order.updated_at` | + +第一版不在 `matched_order_records[]` 中强行返回 `block_id`、`reservation_no`、`room_items`、`rate_code_price`。这些字段如果后续有 OPERA 同步表或订单投影表,再进入接口 2。 + +#### `pending_or_open_tasks[]` + +返回同 key 或同订单下尚未完成、需要 Skill 识别阻断关系的任务摘要。 + +| 字段 | 是否必返 | 中文说明 | 当前系统来源 | +| --- | --- | --- | --- | +| `task_id` | 是 | 本系统任务 ID | `workflow_reservation_task.id` | +| `order_id` | 是 | 任务当前挂靠订单 | `workflow_reservation_task.order_id` | +| `source_message_id` | 是 | 任务来源消息 ID | `workflow_reservation_task.source_message_id` | +| `source_event_index` | 是 | AI current 事件序号 | `workflow_reservation_ai_transition.source_event_index` | +| `catalog_code` | 否 | Skill 目录代码 | `workflow_reservation_ai_transition.catalog_code` | +| `skill_id` | 否 | Skill 标识 | `workflow_reservation_ai_transition.skill_id` | +| `result_type` | 是 | AI 结果类型 | `workflow_reservation_task.result_type` | +| `task_type` | 是 | AI 原始任务类型 | `workflow_reservation_task.ai_task_type` | +| `system_task_type` | 是 | 系统主任务类型 | `workflow_reservation_task.system_task_type` | +| `task_card_type` | 是 | 任务卡类型 | `workflow_reservation_task.task_card_type` | +| `task_subtype` | 否 | 业务动作 subtype | `workflow_reservation_task.task_subtype` | +| `task_status` | 是 | 任务状态 | `workflow_reservation_task.task_status` | +| `queue_participation` | 是 | 是否参与订单执行队列 | `workflow_reservation_task.queue_participation` | +| `execution_order` | 是 | 同订单执行顺序 | `workflow_reservation_task.execution_order` | +| `parent_task_id` | 否 | 父任务 ID | `workflow_reservation_task.parent_task_id` | +| `parent_source_event_index` | 否 | 父事件序号 | `workflow_reservation_task.parent_source_event_index` | +| `linked_task_group_id` | 否 | 联动任务组 ID | `workflow_reservation_task.linked_task_group_id` | +| `blocked_until_parent_completed` | 是 | 是否等待父任务完成 | `workflow_reservation_task.blocked_until_parent_completed` | +| `last_updated_at` | 是 | 最近更新时间 | `workflow_reservation_task.updated_at` | + +第一版 `pending_or_open_tasks[]` 至少包含状态为 `PENDING_CONFIRM`、`READY`、`EXECUTING` 的任务。`FAILED` 在本系统第一版视为结束状态,不阻塞后续任务。 + +#### `active_workflows[]` + +导入契约要求路由上下文覆盖 active workflow。当前本系统没有独立 workflow 表,订单任务流转事实保存在订单、任务和 OPERA 模拟操作表中。 + +第一版接口保留该字段,但默认返回空数组;如果实现方希望表达“正在处理的工作流”,应优先通过 `pending_or_open_tasks[]` 返回,不额外编造 workflow 记录。 + +| 字段 | 是否必返 | 中文说明 | 当前系统来源 | +| --- | --- | --- | --- | +| `active_workflows[]` | 是 | 活跃工作流摘要 | 第一版固定空数组 | + +#### `terminated_records[]` + +返回能影响 Skill 判断的终止记录摘要。 + +| 字段 | 是否必返 | 中文说明 | 当前系统来源 | +| --- | --- | --- | --- | +| `record_type` | 是 | `order` 或 `task` | 系统派生 | +| `record_id` | 是 | 订单 ID 或任务 ID | 对应表主键 | +| `status` | 是 | 终止状态 | 订单 `ENDED` / `LOGIC_DELETED`,任务 `FAILED` / `COMPLETED` | +| `reason` | 否 | 终止原因 | 订单 `logic_deleted_reason` 或任务 `last_failure_reason` | +| `occurred_at` | 否 | 终止时间 | 订单 `ended_at` / `logic_deleted_at`,任务 `completed_at` | +| `last_updated_at` | 是 | 最近更新时间 | 对应表 `updated_at` | + +#### `target_object_validation` + +返回后端可基于事实给出的目标对象校验摘要,但不返回“应该创建哪个任务”的最终结论。 + +| 字段 | 是否必返 | 中文说明 | +| --- | --- | --- | +| `status` | 是 | `none`、`single`、`multiple`、`conflict` | +| `matched_object_id` | 否 | 唯一匹配对象 ID | +| `matched_object_type` | 否 | 唯一匹配对象类型 | +| `can_create_new_booking_task` | 是 | 是否未发现明显阻断 New Booking 的对象或任务 | +| `can_create_update_task` | 是 | 是否存在可更新目标且无明显阻断 | +| `can_create_cancel_task` | 是 | 是否存在可取消目标且无明显阻断 | +| `can_attach_voucher` | 是 | 是否存在有效目标或可承接的待完善 New Booking 任务 | +| `can_attach_rooming_list` | 是 | 是否存在有效目标或可承接的待完善 New Booking 任务 | +| `needs_manual_review_reason` | 否 | 后端发现多对象、冲突、终止等事实时的原因码 | + +#### `key_relationships` + +| 字段 | 是否必返 | 中文说明 | +| --- | --- | --- | +| `group_code_and_confirmation_same_object` | 否 | 同时传入两个 key 时,是否能证明属于同一对象 | +| `relationship_evidence` | 否 | 证明关系的简短说明 | + +当前系统没有 Group Code 与 Confirmation Number 的关系表,大多数情况下该字段返回 `null`。 + +### 5.4 最小响应示例 + +```json +{ + "success": true, + "request_id": "req-001", + "trace_id": "trace-001", + "data": { + "matched_order_records": [ + { + "object_id": "ORDER:1900000000000000100", + "object_type": "group_block", + "order_id": "1900000000000000100", + "order_key_type": "GROUP_CODE", + "group_code": "LT260417VIPA", + "confirmation_number": null, + "temporary_order_code": "TMP-20260707-0001", + "display_name": "LT260417VIPA", + "status": "ACTIVE", + "business_key_source": "AI_CANDIDATE", + "source_table": "workflow_reservation_order", + "last_updated_at": "2026-07-07T08:30:00Z" + } + ], + "pending_or_open_tasks": [], + "active_workflows": [], + "terminated_records": [], + "target_object_validation": { + "status": "single", + "matched_object_id": "ORDER:1900000000000000100", + "matched_object_type": "group_block", + "can_create_new_booking_task": false, + "can_create_update_task": true, + "can_create_cancel_task": true, + "can_attach_voucher": true, + "can_attach_rooming_list": true, + "needs_manual_review_reason": null + }, + "key_relationships": { + "group_code_and_confirmation_same_object": null, + "relationship_evidence": "" + } + }, + "warnings": [], + "error": null +} +``` + +## 6. 接口 2:query_object_detail + +### 6.1 路径 + +```text +POST /api/ai-query/v1/object-detail +``` + +### 6.2 请求字段 + +| 字段 | 是否必填 | 中文说明 | 当前系统来源或用途 | +| --- | --- | --- | --- | +| `hotel_id` | 是 | 酒店或业务上下文 ID | 用于隔离订单和任务 | +| `object_id` | 是 | 接口 1 返回的对象 ID | 第一版支持 `ORDER:{order_id}` | +| `object_type` | 否 | 对象类型提示 | 用于校验调用方预期和实际对象类型 | + +### 6.3 响应字段 + +接口 2 返回的是对象详情快照。第一版先以本系统订单表为主,OPERA 同步类字段先作为 nullable 业务快照字段。 + +#### 基础对象字段 + +| 字段 | 是否必返 | 中文说明 | 当前系统来源 | +| --- | --- | --- | --- | +| `object_id` | 是 | 查询对象 ID | `ORDER:{workflow_reservation_order.id}` | +| `object_type` | 是 | 对象类型 | 根据 `order_key_type` 派生 | +| `order_id` | 是 | 本系统订单 ID | `workflow_reservation_order.id` | +| `order_key_type` | 是 | 业务号类型 | `workflow_reservation_order.order_key_type` | +| `group_code` | 否 | Group Code | `GROUP_CODE` 订单的 `active_business_key` 或 `order_business_key` | +| `confirmation_number` | 否 | Confirmation Number | `CONFIRMATION_NUMBER` 订单的 `active_business_key` 或 `order_business_key` | +| `reservation_no` | 否 | OPERA reservation no | 当前无可靠表源,返回 `null` | +| `block_id` | 否 | OPERA block ID | 当前无可靠表源,返回 `null` | +| `temporary_order_code` | 是 | 临时订单编号 | `workflow_reservation_order.temporary_order_code` | +| `display_name` | 是 | 用户可读展示名 | `workflow_reservation_order.display_name` | +| `status` | 是 | 订单状态 | `workflow_reservation_order.order_status` | +| `source_message_id` | 是 | 首次创建订单的来源消息 | `workflow_reservation_order.source_message_id` | +| `created_from_task_id` | 否 | 首次创建订单的任务 ID | `workflow_reservation_order.created_from_task_id` | +| `created_at` | 是 | 创建时间 | `workflow_reservation_order.created_at` | +| `last_updated_at` | 是 | 最近更新时间 | `workflow_reservation_order.updated_at` | + +#### 业务快照字段 + +这些字段是 S02、S03、S07 后续会用到的最小业务事实,但当前系统没有完整 OPERA 投影表。第一版可以返回字段名和 `null` 值,同时在 `hard_validation_warnings[]` 中说明缺失原因。 + +| 字段 | 是否必返 | Skill 用途 | 当前第一版 | +| --- | --- | --- | --- | +| `arrival_date` | 否 | S02 before/after、S04/S05 目标展示 | 当前无结构化订单投影,返回 `null` | +| `departure_date` | 否 | S02 before/after、S04/S05 目标展示 | 当前无结构化订单投影,返回 `null` | +| `nights` | 否 | S02 晚数修改判断 | 当前无结构化订单投影,返回 `null` | +| `guest_count` | 否 | S02 人数修改判断 | 当前无结构化订单投影,返回 `null` | +| `room_items[]` | 是 | S02 房型/房量 before/after,展示目标房型摘要 | 当前返回空数组 | +| `room_items[].room_type` | 否 | 原始房型或系统房型 | 当前无可靠表源 | +| `room_items[].pms_room_type_code` | 否 | PMS 房型代码 | 当前无可靠表源 | +| `room_items[].room_quantity` | 否 | 房量 | 当前无可靠表源 | +| `room_items[].rate_code` | 否 | Rate Code | 当前无可靠表源 | +| `room_items[].rate_code_price` | 否 | S07 extra bed 改价事实来源 | 当前无可靠表源 | +| `rate_code` | 否 | 对象级 Rate Code 摘要 | 当前无可靠表源 | +| `rate_code_price` | 否 | S07 extra bed 使用,Skill 不自行猜 | 当前无可靠表源,返回 `null` | +| `reservation_type` | 否 | S04 voucher 后续动作、S08 TA Recorder 展示 | 当前无可靠表源 | +| `cancel_status` | 否 | S03 判断是否已取消 | 当前可根据订单状态粗略派生,真实 OPERA 取消状态后续补充 | +| `can_update` | 是 | S02 是否存在明显更新阻断 | 根据订单状态和队列阻断粗略派生 | +| `can_cancel` | 是 | S03 是否存在明显取消阻断 | 根据订单状态和队列阻断粗略派生 | +| `hard_validation_warnings[]` | 是 | 告知 Skill 哪些事实当前无法确认 | 系统派生 | + +### 6.4 最小响应示例 + +```json +{ + "success": true, + "request_id": "req-002", + "trace_id": "trace-001", + "data": { + "object_id": "ORDER:1900000000000000100", + "object_type": "group_block", + "order_id": "1900000000000000100", + "order_key_type": "GROUP_CODE", + "group_code": "LT260417VIPA", + "confirmation_number": null, + "reservation_no": null, + "block_id": null, + "temporary_order_code": "TMP-20260707-0001", + "display_name": "LT260417VIPA", + "status": "ACTIVE", + "source_message_id": "1900000000000000001", + "created_from_task_id": null, + "created_at": "2026-07-07T08:00:00Z", + "last_updated_at": "2026-07-07T08:30:00Z", + "arrival_date": null, + "departure_date": null, + "nights": null, + "guest_count": null, + "room_items": [], + "rate_code": null, + "rate_code_price": null, + "reservation_type": null, + "cancel_status": "not_cancelled", + "can_update": true, + "can_cancel": true, + "hard_validation_warnings": [ + { + "code": "OPERA_PROJECTION_UNAVAILABLE", + "message": "当前系统尚未接入 OPERA 对象投影,日期、房型、房价等字段无法确认。" + } + ] + }, + "warnings": [], + "error": null +} +``` + +## 7. 第一版字段来源与缺口 + +### 7.1 当前可稳定提供 + +| 能力 | 当前来源 | +| --- | --- | +| 按 `hotel_id + GROUP_CODE` 查询 ACTIVE 订单 | `workflow_reservation_order.order_key_type`、`active_business_key` | +| 按 `hotel_id + CONFIRMATION_NUMBER` 查询 ACTIVE 订单 | `workflow_reservation_order.order_key_type`、`active_business_key` | +| 查询临时订单、终止订单、逻辑删除订单 | `workflow_reservation_order.order_status` | +| 查询同订单任务队列 | `workflow_reservation_task.order_id`、`queue_participation`、`execution_order` | +| 查询 pending/open task | `workflow_reservation_task.task_status` | +| 查询 `source_event_index`、Skill、AI 原始任务类型 | `workflow_reservation_ai_transition` | +| 判断 Message Notification 是否不阻塞 | `queue_participation=false` | + +### 7.2 当前不能稳定提供 + +| 字段或能力 | 原因 | 第一版处理 | +| --- | --- | --- | +| `reservation_no` | 未接入 OPERA reservation 投影 | 返回 `null` | +| `block_id` | 未接入 OPERA group block 投影 | 返回 `null` | +| `arrival_date` / `departure_date` / `nights` | 订单表未保存结构化入住信息 | 返回 `null` | +| `room_items[]` | 订单表未保存结构化房型/房量明细 | 返回空数组 | +| `rate_code` / `rate_code_price` | 未接入 OPERA 或价格投影 | 返回 `null` 并 warning | +| 凭证、OCR、Excel、rooming list 解析结果 | 当前无文件解析能力 | 接口 3 暂不实现 | +| linked task 的父任务专用查询 | 后续改成订单及任务查询 | 接口 4 暂不实现 | + +## 8. 后续实现提醒 + +- 实现接口 1、2 前,应先复用当前后端分层规范:`control`、`service`、`service.impl`、`domain`、`mapper`、`repository`、`common.request`、`common.result`、`common.dto`、`common.enums`。 +- Controller、Service、ServiceImpl 方法必须有中文注释;Entity 字段必须有中文注释。 +- 不要新增 `api`、`application`、`persistence` 包。 +- 不要为了导入契约中的 `block_id`、`reservation_no`、`rate_code_price` 等字段编造数据。 +- SuperAgent 查询上下文接口是之前排期靠后的事项,现在已有导入契约,后续梳理未完成事项时需要持续提醒。 +- 普通任务切换订单、用户身份/权限、前端页面、真实 OPERA/OHIP 仍不在本接口第一版范围内。 + +## 9. 当前后端落地状态 + +已落地接口: + +- `POST /api/ai-query/v1/case-context` +- `POST /api/ai-query/v1/object-detail` + +已落地能力: + +- 接口 1 可按 `hotel_id + group_code` 或 `hotel_id + confirmation_number` 查询订单上下文。 +- 接口 1 返回 `matched_order_records`、`pending_or_open_tasks`、`active_workflows`、`terminated_records`、`target_object_validation` 和 `key_relationships`。 +- `active_workflows` 当前无独立表源,固定返回空数组。 +- 接口 2 支持 `ORDER:{order_id}` 查询本系统订单快照。 +- 对外 JSON 中内部长整型 ID 按字符串返回。 +- `reservation_no`、`block_id`、`room_items`、`rate_code_price` 等当前无可靠来源字段按本文约定返回 `null`、空数组或 warning。 + +仍未落地能力: + +- 接口 3 `query_file_parse_context`。 +- 接口 4 `query_parent_task_context`,后续改为订单及其下面任务查询后再定义。 +- SuperAgent 查询接口的 HMAC 鉴权。当前第一版只要求 `X-Request-Id` 作为请求追踪头。 +- 附件解析、OCR、Excel、voucher、rooming list 解析。 +- 真实 OPERA / OHIP 对象投影。 diff --git a/docs/project/requirements/M002-order-task-workflow-v2.md b/docs/project/requirements/M002-order-task-workflow-v2.md index bc6b551..187185b 100644 --- a/docs/project/requirements/M002-order-task-workflow-v2.md +++ b/docs/project/requirements/M002-order-task-workflow-v2.md @@ -14,7 +14,7 @@ 本文是 `M002-order-task-workflow-v1.md` 的第二版修正,目标是把本项目已经讨论确认的订单任务主流程,与 2026-07-06 导入的 AI 任务卡契约对齐。 -本文定义业务边界、数据语义和后续实现约束。当前后端已经按拆分 checkpoint 实现了 AI 结果接收、订单任务基础流转、任务草稿保存、最终确认、审计列表和 OPERA 模拟骨架;前端页面、真实 OPERA、普通任务切换订单和 SuperAgent 查询上下文接口仍未实现。 +本文定义业务边界、数据语义和后续实现约束。当前后端已经按拆分 checkpoint 实现了 AI 结果接收、订单任务基础流转、任务草稿保存、最终确认、审计列表、OPERA 模拟骨架,以及 SuperAgent 查询上下文接口 1、2 的最小字段版;前端页面、真实 OPERA、普通任务切换订单、查询接口 3 和查询接口 4 仍未实现。 ## 2. 本版核心修正 @@ -471,7 +471,7 @@ Fallback 处理规则: 本版暂不定义: -- SuperAgent 查询上下文接口。 +- SuperAgent 查询上下文接口 3、4;接口 1、2 的第一版最小字段已单独定义并落地在 `M002-ai-query-minimal-fields.md`。 - OPERA 模拟结果 JSON 字段名。 - 真实 OHIP / OPERA 接口地址、鉴权和返回结构。 - 前端具体页面布局和交互细节。 @@ -483,9 +483,9 @@ Fallback 处理规则: ## 18. 待确认问题 - SuperAgent 创建任务接口的 URL、Method、Header、鉴权和错误响应格式。 -- SuperAgent 查询上下文接口暂不在本 checkpoint 实现,但 SuperAgent 侧已经在整理,后续梳理未完成事项时必须持续提醒。 +- SuperAgent 查询上下文接口 1、2 已实现最小字段版;接口 3 文件解析和接口 4 订单及任务查询仍需后续确认与实现,后续梳理未完成事项时必须持续提醒。 - `source_event_index`、批次 item index、`execution_order` 的最终编号规则是否都从 1 开始。 -- SuperAgent 查询上下文接口的 URL、入参、返回字段和鉴权方式。 +- SuperAgent 查询上下文接口 3、4 的 URL、入参、返回字段和鉴权方式;接口 1、2 后续是否补 HMAC 鉴权也需确认。 - `Message Notification` 是否需要在前端订单列表上单独标识为只读提醒。 - OPERA 模拟结果中 Confirmation No.、Group Code、Block Code、Allotment Code 的具体字段路径。 - 临时订单在无任务后是否立即逻辑删除,还是保留一段时间便于追溯。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java new file mode 100644 index 0000000..06f6c01 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryOrderSnapshot.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java new file mode 100644 index 0000000..96c26e7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java new file mode 100644 index 0000000..06ee151 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiCaseContextQueryRequest.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiObjectDetailQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiObjectDetailQueryRequest.java new file mode 100644 index 0000000..9a32afc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationAiObjectDetailQueryRequest.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java new file mode 100644 index 0000000..334a36c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiCaseContextResult.java @@ -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 matchedOrderRecords, + @JsonProperty("pending_or_open_tasks") + List pendingOrOpenTasks, + @JsonProperty("active_workflows") + List activeWorkflows, + @JsonProperty("terminated_records") + List 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 + ) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java new file mode 100644 index 0000000..a894c94 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiObjectDetailResult.java @@ -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 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 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 + ) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryErrorResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryErrorResult.java new file mode 100644 index 0000000..a7e0115 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryErrorResult.java @@ -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 details +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryResponse.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryResponse.java new file mode 100644 index 0000000..fbdb4ac --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryResponse.java @@ -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( + boolean success, + @JsonProperty("request_id") + String requestId, + @JsonProperty("trace_id") + String traceId, + T data, + List warnings, + ReservationAiQueryErrorResult error +) { + + /** + * 构造成功响应,warnings 允许为空列表但不返回 null。 + */ + public static ReservationAiQueryResponse success( + String requestId, + String traceId, + T data, + List warnings) { + return new ReservationAiQueryResponse<>(true, requestId, traceId, data, warnings == null ? List.of() : warnings, null); + } + + /** + * 构造失败响应,data 固定为空,避免调用方误读部分结果。 + */ + public static ReservationAiQueryResponse failure( + String requestId, + String traceId, + ReservationAiQueryErrorResult error) { + return new ReservationAiQueryResponse<>(false, requestId, traceId, null, List.of(), error); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryWarningResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryWarningResult.java new file mode 100644 index 0000000..575a498 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationAiQueryWarningResult.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +/** + * SuperAgent 查询接口警告项。用于说明当前系统无法确认但不应导致查询失败的事实缺口。 + * + * @param code 稳定警告码 + * @param message 警告说明 + */ +public record ReservationAiQueryWarningResult( + String code, + String message +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java new file mode 100644 index 0000000..836fc43 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryController.java @@ -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 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 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()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java new file mode 100644 index 0000000..d5e9c11 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerAdvice.java @@ -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> 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> 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()))); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index 8db1cd2..848091b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -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 findAiQueryOrdersByBusinessKeys( + String hotelId, + String groupCode, + String confirmationNumber) { + Map orders = new LinkedHashMap<>(); + if (hasText(groupCode)) { + orderMapper.selectList(Wrappers.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.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 findAiQueryOrderById(String hotelId, Long orderId) { + ReservationOrderEntity entity = orderMapper.selectOne(Wrappers.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 findAiQueryTasksByOrderIds(String hotelId, List orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + return List.of(); + } + List tasks = taskMapper.selectList(Wrappers.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 findAiQueryTasksByBusinessKeys( + String hotelId, + String groupCode, + String confirmationNumber) { + List transitions = findAiQueryTransitionsByBusinessKeys( + hotelId, + groupCode, + confirmationNumber); + if (transitions.isEmpty()) { + return List.of(); + } + List transitionIds = transitions.stream().map(ReservationAiTransitionEntity::getId).toList(); + List tasks = taskMapper.selectList(Wrappers.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 findAiQueryTransitionsByBusinessKeys( + String hotelId, + String groupCode, + String confirmationNumber) { + if (!hasText(groupCode) && !hasText(confirmationNumber)) { + return List.of(); + } + return transitionMapper.selectList(Wrappers.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 toAiQueryTaskSnapshots( + String hotelId, + List tasks) { + if (tasks == null || tasks.isEmpty()) { + return List.of(); + } + Map 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 findTransitionsByIds(String hotelId, Collection transitionIds) { + if (transitionIds == null || transitionIds.isEmpty()) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + transitionMapper.selectList(Wrappers.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()); + } + /** * 转换任务卡实体为快照。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java index 171dd1a..24a66d7 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -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 findOrderById(String hotelId, Long orderId); + /** + * 按 SuperAgent 查询 key 查找订单上下文,包含 ACTIVE、ENDED、LOGIC_DELETED 和 TEMPORARY。 + */ + List findAiQueryOrdersByBusinessKeys( + String hotelId, + String groupCode, + String confirmationNumber); + + /** + * 按订单 ID 查询 SuperAgent 查询接口使用的订单详情快照。 + */ + Optional findAiQueryOrderById(String hotelId, Long orderId); + + /** + * 查询指定订单下全部任务,并带上对应 AI transition 的路由字段。 + */ + List findAiQueryTasksByOrderIds(String hotelId, List orderIds); + + /** + * 按 AI transition 冗余业务 key 查询任务,用于查到待处理任务但尚无正式订单的场景。 + */ + List findAiQueryTasksByBusinessKeys( + String hotelId, + String groupCode, + String confirmationNumber); + /** * 将临时订单激活为带真实业务号的 ACTIVE 订单。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java new file mode 100644 index 0000000..3b53298 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationAiQueryService.java @@ -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); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryException.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryException.java new file mode 100644 index 0000000..4eea294 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryException.java @@ -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 details; + + public ReservationAiQueryException(HttpStatus status, String errorCode, String message) { + this(status, errorCode, message, Map.of()); + } + + public ReservationAiQueryException( + HttpStatus status, + String errorCode, + String message, + Map 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 getDetails() { + return details; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java new file mode 100644 index 0000000..53a3feb --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiQueryServiceImpl.java @@ -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 OPEN_TASK_STATUSES = Set.of( + ReservationTaskStatus.PENDING_CONFIRM.name(), + ReservationTaskStatus.READY.name(), + ReservationTaskStatus.EXECUTING.name()); + private static final Set 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 orders = repository.findAiQueryOrdersByBusinessKeys( + hotelId, + groupCode, + confirmationNumber); + List orderIds = orders.stream().map(ReservationAiQueryOrderSnapshot::id).toList(); + List tasks = mergeTasks( + repository.findAiQueryTasksByOrderIds(hotelId, orderIds), + repository.findAiQueryTasksByBusinessKeys(hotelId, groupCode, confirmationNumber)); + + List 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 terminatedRecords = terminatedRecords(orders, tasks); + List 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 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 terminatedRecords( + List orders, + List tasks) { + List 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 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 activeOrders, + List openTasks, + List 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 mergeTasks( + List first, + List second) { + Map 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(); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java new file mode 100644 index 0000000..2a193f4 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationAiQueryControllerTest.java @@ -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.", + "Please handle booking message.", + "{\"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); + } +}