From e59ac2f3bf153684d768b6a5dd668ec8537d9bfc Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 00:54:58 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0M002=20V4=E5=85=A5=E7=AB=99?= =?UTF-8?q?=E5=86=99=E5=85=A5=E6=96=B0=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 18 +- docs/project/README.md | 6 +- .../backend-to-frontend-notes.md | 21 +- .../M002-order-task-workflow-v3.md | 18 +- .../M002-v4-agent-callback-field-contract.md | 40 +- ...002-v4-order-task-card-domain-model-cp2.md | 74 +-- .../security-access-control-boundary.md | 6 +- .../dto/ReservationV4AcceptedEventDraft.java | 31 ++ .../ReservationV4SourceNotificationDraft.java | 6 +- ...servationV4SourceNotificationSnapshot.java | 4 +- ...ReservationV4SourceNotificationEntity.java | 2 +- ...rvationV4SourceNotificationRepository.java | 4 +- ...rvationV4SourceNotificationRepository.java | 6 +- .../ReservationV4TaskIntakeService.java | 34 ++ .../ReservationAiTaskIntakeServiceImpl.java | 179 +++++- .../ReservationV4TaskIntakeServiceImpl.java | 405 ++++++++++++++ .../SuperAgentTaskResultControllerTest.java | 517 +++++++++++++++++- 17 files changed, 1256 insertions(+), 115 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AcceptedEventDraft.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4TaskIntakeService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index b62e484..7915112 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-18 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站基线、多卡模型与持久化基线并行 | -| 当前重点 | M002 V4 CP3 表结构和 Repository 基线已落地,后续准备做 V4 入站写入新模型 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线和入站写入新模型并行 | +| 当前重点 | M002 V4 CP4 已把 SuperAgent V4 入站结果写入 V4 订单任务、多卡和 S10/S99 来源通知模型,后续准备做 V4 查询接口 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP3-order-task-card-repository-baseline` -- 状态:Done,已新增 V4 订单任务表、任务卡表、来源通知表、Entity、Mapper、Repository 和 Repository 集成测试;已覆盖 `order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 event 卡 index 校验。 -- 目标:在 CP1 入站解析和 CP2 多卡模型设计之后,落地 V4 订单任务、多卡和 S10 来源通知的持久化基线。 -- 边界:不改 SuperAgent 入站写入逻辑;不实现 V4 前端查询接口、卡片确认 / 复核 / S10 ack 接口;不做 OPERA / OHIP、不做前端页面、不做历史数据迁移。 +- 名称:`M002-V4-CP4-v4-intake-write-new-model` +- 状态:Done,已在 SuperAgent V4 正式回调中写入 V4 新模型:普通 V4 业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 写入来源通知,不再创建隐藏技术订单或旧任务;V4 包级 / event 级契约错误仍只写 AI transition,不创建用户可处理卡。 +- 目标:在 CP1 入站解析、CP2 多卡模型设计、CP3 表结构和 Repository 基线之后,让 V4 回调真正落到新订单任务 + 多卡 + 来源通知持久化模型。 +- 边界:普通 V4 业务包暂时仍保留旧 V3 任务链路兼容,便于当前前端过渡;不实现 V4 前端查询接口、卡片确认 / 复核 / S10/S99 ack 接口;不做 OPERA / OHIP、不做前端页面、不做历史数据迁移。 ## 2. 当前优先级 @@ -34,12 +34,12 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10 来源通知表结构与 Repository 基线。Basic Information 独立卡的入站写入、V4 查询 / 写接口和 V4 前端页面仍未完成代码实现。 -- M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型。V4 查询 / 写接口和 V4 前端页面仍未完成代码实现。 +- M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP4`:将 SuperAgent V4 入站结果写入新模型,真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10 来源通知。 +- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP5`:实现 V4 工作台统一列表、订单任务列表 / 详情、S10/S99 来源通知详情等查询接口。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/docs/project/README.md b/docs/project/README.md index ebbe24d..6f5bc26 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -45,8 +45,8 @@ | `requirements/M002-order-task-workflow-v2.md` | 阶段记录 | M002 订单任务主流程 V2,记录当前已阶段实现的 AI 过渡层、S000/S999 兼容、订单任务流转、任务确认和 OPERA 模拟骨架。 | | `requirements/M002-order-task-workflow-v3.md` | 当前有效 | M002 订单任务主流程 V3,基于 2026-07-11 P0 冻结基线和 2026-07-12 P0.1 Parent Group 修订,记录 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed 边界。 | | `requirements/M002-task-field-control-contract-v1.md` | 当前有效 | M002 任务卡字段控件契约 V1,记录任务详情 `fields[]` 控件元数据、人工复核控件复用和前后端开发边界。 | -| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10 和校验口径;后端已完成 V4 入站解析 CP1 和 CP3 持久化基线,完整 V4 多卡主流程仍待后续实现。 | -| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3 表结构、Entity、Mapper、Repository 已落地状态;后续仍需实现 V4 入站写入新模型、查询接口、卡片确认 / 复核和前端页面。 | +| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10/S99 和校验口径;后端已完成 V4 入站解析 CP1、CP3 持久化基线和 CP4 入站写入新模型,V4 查询 / 写接口仍待后续实现。 | +| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3 表结构、Entity、Mapper、Repository 和 CP4 入站写入新模型已落地状态;后续仍需实现 V4 查询接口、卡片确认 / 复核和前端页面。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-ai-query-minimal-fields.md` | 阶段记录 | M002 SuperAgent 查询上下文接口 1、2 最小字段落地记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-backend-data-model-design.md` | 阶段记录 | M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 | @@ -91,6 +91,6 @@ - 接口暴露、权限、酒店隔离和审计边界以 `security-access-control-boundary.md` 为总检查清单;具体 SuperAgent / MCP / AgentBus 请求响应契约仍以 `integrations/` 下对应文档为准。 - AI-NSES 的通用标准以 `../import/reusable/ai-native-software-engineering-standard.md` 为复用来源;本项目采用方式以 `ai-native-adoption.md` 为准。 - M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 -- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,后续仍需实现入站写入新模型、查询接口、卡片确认 / 复核和前端页面模型。 +- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,后续仍需实现查询接口、卡片确认 / 复核和前端页面模型。 - 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表、0712 字段控件说明和 `requirements/M002-task-field-control-contract-v1.md` 为白名单和控件契约基线;后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 - 时间点语义以 `backend-time-design.md` 为准;数据库时间点按 UTC 理解,API 返回带 `Z` 的 UTC 时间,页面再按酒店或用户时区展示。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 9a9f0b4..6d2e851 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -37,7 +37,7 @@ - 同一订单下,前置任务未结束时,后续任务只能查看,不能编辑、确认或执行 OPERA 模拟操作。 - 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。 - M002 V3 新入口采用结构化 `S10/S99`:`S10` 表示未匹配当前支持的业务事件,`S99` 表示输入不足或无法形成业务素材包;旧 `S000/S999` 继续按历史数据兼容展示。 -- `S10/S99` 后端会创建只读源邮件通知卡,任务列表可见,订单列表不可见;当前代码中的旧 `SOURCE_MESSAGE_ONLY` 任务仍按同一只读语义展示。 +- V3 S10/S99 会创建旧 `SOURCE_MESSAGE_ONLY` 只读源邮件通知卡,任务列表可见,订单列表不可见;V4 S10/S99 已改为独立来源通知模型,等待后续 V4 工作台 / 来源通知接口展示。 - 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 - type-known manual review 已支持同卡复核解阻第一版:应展示为原业务任务卡的复核模式,不应统一展示成 Fallback。只有业务类型或 subtype 本身未知时才进入 Fallback。 - 复核场景下允许用户确认订单归属;当前第一版只允许确认当前任务所属订单,不等于开放普通任务任意切换订单。 @@ -53,9 +53,9 @@ | `GET /api/auth/me` | 恢复当前登录态 | 前端启动后带 `Authorization: Bearer ` 调用;401 时清理 token 并进入登录页。 | | `POST /api/auth/logout` | 登出当前 session | 带 `Authorization: Bearer `;成功后前端必须清理本地 token 和当前用户上下文。 | | `GET /api/reservation/orders` | 查询订单列表 | 必须带 `Authorization: Bearer `,需要 `RESERVATION_ORDER_READ`;默认返回全部订单状态;按后端维护的订单最近业务活动时间倒序,当前落库字段为 `workflow_reservation_order.latest_activity_at`,前端不要自行重排;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 | -| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和新 S10/S99 都以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。 | +| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和 V3 S10/S99 以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。V4 S10/S99 不再进入该旧任务表,后续从 V4 工作台来源通知接口展示。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | -| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;源邮件只读通知卡字段列表和 OPERA 操作列表为空;结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 | +| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;旧源邮件只读通知卡字段列表和 OPERA 操作列表为空;V3 结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 最终确认任务 | 后端会做第一版字段校验,通过后进入 `READY`。 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | Fallback 人工转换 | 只用于 manual_review / fallback,不用于普通任务切换订单。 | @@ -150,9 +150,9 @@ POST /api/auth/logout - `next_processable_task_id` 是后端按同订单队列实时计算出的下一条可处理任务;前端可以用它做“继续处理”入口。 - `display_order_key` 是前端优先展示的订单业务号或临时订单号;`group_code` 和 `confirmation_number` 只有在当前订单业务号类型匹配时返回。 - 订单 ID、任务 ID、SourceMessage ID 在这些前端接口中按字符串返回,前端不要转换成 JavaScript number。 -- 当前 V3 / 过渡实现中,源邮件只读通知卡背后有隐藏技术订单用于满足后端任务外键,但订单列表不会返回该订单;任务列表中该类任务的 `display_order_key`、`temporary_order_no`、`group_code`、`confirmation_number` 可能为空,前端不要因此隐藏整条任务。V4 S10 目标模型已改为独立来源通知,不再挂隐藏技术订单。 -- 当前前端已按 `SOURCE_MESSAGE_ONLY` 展示旧 S000/S999;后端回调已支持结构化 `route_code=S10/S99`、`result_type=source_message_review_notification` 的新入口通知,并继续只在任务列表和任务详情提供只读查看入口;`INFORMATIONAL_MESSAGE` 仅作为历史 Message Notification 兼容路径保留。 -- 任务列表里旧 `task_type=SOURCE_MESSAGE_ONLY`、`task_subtype=S000/S999` 或新 `task_subtype=S10/S99` 的记录只展示邮件来源和 SuperAgent 入口结果,不展示处理按钮。 +- 当前 V3 / 过渡实现中,源邮件只读通知卡背后有隐藏技术订单用于满足后端任务外键,但订单列表不会返回该订单;任务列表中该类任务的 `display_order_key`、`temporary_order_no`、`group_code`、`confirmation_number` 可能为空,前端不要因此隐藏整条任务。V4 S10/S99 目标模型已改为独立来源通知,不再挂隐藏技术订单。 +- 当前前端已按 `SOURCE_MESSAGE_ONLY` 展示旧 S000/S999 和 V3 S10/S99;V4 `route_code=S10/S99` 已写入独立来源通知模型,不再通过旧任务列表和旧任务详情展示;`INFORMATIONAL_MESSAGE` 仅作为历史 Message Notification 兼容路径保留。 +- 任务列表里旧 `task_type=SOURCE_MESSAGE_ONLY`、`task_subtype=S000/S999` 或 V3 `task_subtype=S10/S99` 的记录只展示邮件来源和 SuperAgent 入口结果,不展示处理按钮。 - 任务详情里 `source_message_only_result` 仅对 `SOURCE_MESSAGE_ONLY` 返回,包含 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id`、`result_type`、`route_code`、`agent_assessment`、`notification`、`manual_review` 和 `raw_answer`;普通业务任务该字段为空。 - 任务列表、订单任务时间线和任务详情顶层已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。前端展示任务卡标题和标签时优先用这些稳定 code,不要只靠旧 `task_type` 判断。 - P0.1 后,Parent split 父事件不再是独立 Parent Cancel Booking 卡;前端应展示为 `Parent Group / Cancel Allotment / cancel_allotment_control_block`。`route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL` 是普通业务卡,`route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW` 是同卡人工复核业务卡,不应展示成 `adapter_contract_error`。`linked_parent_release_after_child_split` 只作为关系字段或详情信息,不作为任务 subtype 筛选项。 @@ -454,13 +454,14 @@ RESERVATION_ROOMING_LIST_GENERATE - 普通任务切换订单接口继续后置。 - M002 V4 入站解析与数据模型基线已完成第一版:后端可接收 `source_message + order_contexts[] + message_events[]`,识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,并保存 V4 原始 payload、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。前端暂不需要直接调用 V4 回调接口。 -- V4 可映射 event 现阶段仍复用现有任务详情结构;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。 +- M002 V4 CP4 已完成入站写入新模型:普通 V4 业务包会写入 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;但 V4 工作台、订单任务详情、来源通知详情和卡片确认接口还没开放,前端当前不要调用 CP2 草案路径。 +- V4 可映射 event 现阶段仍保留现有任务详情结构作为过渡兼容;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。 - V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。 - V4 包级契约错误只会保存在 AI transition 中,不会出现在普通任务列表;V4 event 级契约错误如果同批次存在其它业务任务,前端仍按任务详情里的 `adapter_contract_errors[]` 只读展示诊断信息。 -- M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表草案为 `GET /api/reservation/workbench-items`,业务订单任务草案为 `/api/reservation/order-tasks/**`,S10 来源通知详情草案为 `GET /api/reservation/source-notifications/{notificationId}`,但当前还没有实现,不要提前接入草案路径。 -- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10 来源通知三张表和 Repository 基线;这只是后端持久层准备,不代表 V4 工作台、订单任务详情、卡片确认或 S10 ack API 已经可用。前端当前仍不要调用 CP2 草案路径。 +- M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表草案为 `GET /api/reservation/workbench-items`,业务订单任务草案为 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `GET /api/reservation/source-notifications/{notificationId}`,但当前还没有实现,不要提前接入草案路径。 +- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表。当前仍不代表 V4 工作台、订单任务详情、卡片确认或 S10/S99 ack API 已经可用,前端仍不要调用 CP2 草案路径。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 -- V4 S10 后续采用来源通知模型:任务列表 / 工作台展示,进入纯通知详情页后只显示邮件展示卡和确认按钮;不再挂隐藏技术订单,不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。当前代码里旧 `SOURCE_MESSAGE_ONLY` 只读任务仍属于过渡实现。 +- V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;但对应工作台 / 来源通知详情 / ack 接口还没开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 - 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 - 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 3da894f..dff580a 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -42,7 +42,7 @@ V3 以以下资料和决策为输入: - M002 V3 正式采用 0711 P0 基线,并从 2026-07-12 起采用 P0.1 Parent Group / Allotment 增量修订。 - 旧数据 `S000/S999` 继续在任务列表可见;新数据迁移为 `S10/S99`。 -- M002 V3 / P0.1 阶段 `S10/S99` 继续复用隐藏技术订单 + 任务列表只读卡,不进入订单列表和订单执行队列;M002 V4 新模型已确认 S10 改为来源通知模型,不再挂隐藏技术订单。 +- M002 V3 / P0.1 阶段 `S10/S99` 继续复用隐藏技术订单 + 任务列表只读卡,不进入订单列表和订单执行队列;M002 V4 新模型已确认 S10/S99 改为来源通知模型,不再挂隐藏技术订单。 - 缺少 `source_message.source_message_id` 时,后端已按 `HTTP 400 + infrastructure_input_error + retryable=true` 的技术错误响应返回,不创建 SourceMessage、AI transition、订单、任务或通知卡。 - 内部任务模型采用“方案 C”:完整保存 AI 三元组,系统处理分类和前端展示分类单独维护。 - type-known manual review 使用同一张业务卡复核解阻,不生成第二张 normal task。 @@ -152,7 +152,7 @@ V3 接收端按根结构分流: - 不允许保存草稿、最终确认、复核转换、普通切换订单、执行 OPERA、重试 OPERA。 - 任务详情展示来源邮件、邮件会话、附件、SuperAgent 原始返回、`route_code` 和入口说明。 -以上是 M002 V3 / P0.1 当前实现口径。M002 V4 新模型落地时,S10 改为来源通知模型:任务列表 / 工作台展示,点击进入纯通知详情页,只显示邮件展示卡和确认按钮;不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。 +以上是 M002 V3 / P0.1 当前实现口径。M002 V4 新模型落地时,S10/S99 改为来源通知模型:任务列表 / 工作台展示,点击进入纯通知详情页,只显示邮件展示卡和确认按钮;不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。 ### 6.3 旧 S000 / S999 兼容 @@ -250,7 +250,7 @@ V3 内部模型采用方案 C,避免把 SuperAgent 的任务三元组直接等 - 有 `group_code`、`confirmation_number` 等可定位字段时,优先挂靠或创建相应订单。 - 同一个 `hotel_id + GROUP_CODE` 只能有一个 `ACTIVE` 订单。 - 同一个 `hotel_id + CONFIRMATION_NUMBER` 只能有一个 `ACTIVE` 订单。 -- M002 V3 / P0.1 当前实现中,`S10/S99` 使用隐藏技术订单,不进入订单列表;M002 V4 S10 目标模型改为独立来源通知,不再挂订单。 +- M002 V3 / P0.1 当前实现中,`S10/S99` 使用隐藏技术订单,不进入订单列表;M002 V4 S10/S99 目标模型改为独立来源通知,不再挂订单。 P0 新增明确:复核场景下需要支持用户确认订单归属。它不是普通任务切换订单: @@ -438,9 +438,11 @@ V3 P0.1 不做以下事项: - `S000/S999` 文本结果兼容处理。 - 结构化 `S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务。 -- V4 包级 `route_code=S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务;普通 V4 业务包要求 `route_code=null`。 +- V4 包级 `route_code=S10/S99` 已识别;M002 V4 CP4 后,新 V4 S10/S99 写入来源通知模型,普通 V4 业务包要求 `route_code=null`。 - V4 业务根 `source_message + order_contexts[] + message_events[]` 基础解析;`source_message.source_message_id` 按 SourceMessage Inbox 的 `external_message_id` 反查邮件。 - V4 第一版识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,可映射 event 先复用现有订单 / 任务 / 任务卡链路,并保存 `catalog_code=M002V4`、`skill_id=booking-desk-event-v4`、`field_contract_version=20260718-v4` 和 V4 原始三元组 / 原始 event payload。 +- M002 V4 CP4 已补充新模型写入:普通 V4 业务包会额外创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;只有契约错误、没有合法业务 event 的包不会创建 V4 订单任务。 +- M002 V4 CP4 后,V4 `route_code=S10/S99` 写入 `workflow_reservation_v4_source_notification`,不再创建隐藏技术订单或旧任务;V3 S10/S99 和旧 S000/S999 仍保留历史兼容链路。 - V4 包级契约错误在 `source_message.source_message_id` 可定位时只写 `adapter_contract_error` transition,不创建订单、任务或用户可处理卡;`source_message_id` 缺失或 SourceMessage 不存在时仍返回明确错误。 - V4 `PAYMENT.attachment_ids[]` 必须匹配 `source_message.attachments[].id`;V4 `UPDATE_BOOKING` 不接受 `rate_code` 或 `after.rate_code`;这类契约错误只落 `adapter_contract_error` transition,不创建用户可处理业务任务。 - 40 条 P0.1 路由枚举 / 稳定配置。 @@ -472,14 +474,14 @@ V3 P0.1 不做以下事项: M002 V4 CP2 设计文档已落地: - 文档路径:`docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`。 -- 设计内容:SourceMessage 邮件展示卡、S10 来源通知、`source_message_id + order_ref` 订单任务、Basic Information 独立卡、每个 V4 event 的业务卡、卡片确认 / 复核 / 锁定、同订单阻塞、表结构草案和后续接口草案。 -- 已确认:V4 工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10 来源通知使用 `/api/reservation/source-notifications/**`;S10 采用来源通知模型;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认,其它业务卡第一版不强制逐张确认;Account / Market / Source 目录第一版使用后端固定种子数据。 -- 当前状态:只完成文档设计,未新增表、Entity、Repository、接口或前端页面。 +- 设计内容:SourceMessage 邮件展示卡、S10/S99 来源通知、`source_message_id + order_ref` 订单任务、Basic Information 独立卡、每个 V4 event 的业务卡、卡片确认 / 复核 / 锁定、同订单阻塞、表结构草案和后续接口草案。 +- 已确认:V4 工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知使用 `/api/reservation/source-notifications/**`;S10/S99 采用来源通知模型;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认,其它业务卡第一版不强制逐张确认;Account / Market / Source 目录第一版使用后端固定种子数据。 +- 当前状态:CP3 表结构 / Repository 和 CP4 入站写入新模型已完成;V4 查询接口、卡片确认 / 复核和前端页面仍未实现。 仍需后续 checkpoint 实现: - V4 表结构和 Repository 落地已完成第一版:新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,并提供 Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 version 乐观锁更新基础方法。 -- V4 入站写入新模型,真正创建 SourceMessage 展示卡、Basic Information 卡和业务卡。 +- V4 入站写入新模型已完成第一版:真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知。 - V4 查询接口、卡片确认 / 复核 / 锁定、同订单阻塞和业务审计。 - V4 前端页面模型、任务详情字段矩阵和目录校验完全切换。 - 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。 diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index 66774e6..cf3aca5 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 1.4 | | 日期 | 2026-07-18 | -| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计和 CP3 持久化基线,完整 V4 多卡入站写入与接口仍需后续 checkpoint | +| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线和 CP4 入站写入新模型,V4 查询 / 写接口仍需后续 checkpoint | | 适用范围 | 0718 业务基线下,Agent → Adapter / MCP → 信息系统的业务回调字段 | | 不适用范围 | 数据库表设计、前端视觉细节、真实 PMS API、技术失败后台重试、旧 M002 V3 数据兼容 | @@ -16,7 +16,7 @@ 本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前后端已按本文完成 V4 入站解析基线:能识别 V4 包、校验关键契约、保存 AI transition / 任务卡原始 payload,并把可映射的六类 event 先接入现有订单任务链路。 -V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP3,表结构、Entity、Mapper、Repository 基线已经实现;SuperAgent 入站写入新模型、V4 前端查询接口和卡片写操作接口仍未实现。 +V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP4,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 前端查询接口和卡片写操作接口仍未实现。 当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。 @@ -84,7 +84,7 @@ V4 正式采用并冻结以下 key: | key | 中文说明 | | --- | --- | -| `route_code` | 包级路由。普通业务为 `null`,纯通知为 `S10` | +| `route_code` | 包级路由。普通业务为 `null`,纯通知为 `S10` 或 `S99` | | `source_message` | 当前触发邮件的包级来源事实,只出现一次 | | `order_contexts` | 订单级上下文集合,每个 `order_ref` 一项 | | `message_events` | 业务事件数组,承载六类 Event | @@ -251,9 +251,9 @@ PAYMENT Basic Information 不是 Event。邮件展示卡不是 Event,由 `source_message` 固定生成。 -S10 是包级纯通知路由,不属于上述业务枚举。 +S10/S99 是包级来源通知路由,不属于上述业务枚举。S10 表示纯信息类邮件,S99 表示无法形成业务素材包;两者都不创建订单或业务卡。 -S99、Fallback、`Need Manual Review`、独立 Voucher、Payment Evidence、Voucher Received 均不再作为新 Agent 输出。 +Fallback、`Need Manual Review`、独立 Voucher、旧 Payment Evidence、Voucher Received 均不再作为新 Agent 输出。 ## 10. target_order @@ -567,7 +567,7 @@ true 具体字段错误路径、错误码和页面提示由信息系统根据 Schema、目录和业务规则生成。 -## 18. S10 纯通知 +## 18. S10/S99 来源通知 ### 18.1 结构 @@ -590,7 +590,7 @@ true - 用户点击“确认”后任务完成。 - 不提供人工终止。 - 不调用 PMS,不修改订单。 -- 不输出 S99、Fallback、原因码、通知说明或 `Need Manual Review`。 +- S10/S99 第一版只表达来源通知类型,不输出 Fallback、原因码、通知说明或 `Need Manual Review`。 ## 19. 技术异常 @@ -614,6 +614,8 @@ true - 如果 V4 包级结构不符合契约,但 `source_message.source_message_id` 能按 SourceMessage Inbox 的 `external_message_id` 定位到邮件,后端会创建 AI batch,并写入一条 `catalog_code=M002V4`、`system_process_category=ADAPTER_CONTRACT_ERROR` 的 transition;不创建订单、任务或酒店用户可处理卡。 - 如果 `source_message.source_message_id` 缺失、无法解析或无法定位 SourceMessage,后端仍返回明确请求错误,不创建 AI batch / transition。 - 单个 `message_events[i]` 的契约错误只影响该 event,同包其它合法 event 继续按数组顺序处理。 +- 普通 V4 业务包中,只有至少有一个合法业务 event 的 `order_ref` 会创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和对应业务卡;全包只有契约错误 event 时不创建 V4 订单任务或用户可处理卡。 +- V4 `route_code=S10/S99` 会写入 `workflow_reservation_v4_source_notification`,不再创建隐藏技术订单或旧 `workflow_reservation_task`;旧 V3 S10/S99 和旧文本 S000/S999 仍保留历史兼容链路。 当前项目可以保留 `platform_superagent_dispatch_run` 或同类技术运行记录作为主链路技术状态载体,后续另行设计查询、告警、超时和重试能力。 @@ -687,10 +689,10 @@ AI 回调包 ## 24. 当前开发结论 - 0718 业务基线覆盖 M002 V3 的任务级草稿、READY、OPERA 模拟、Fallback、S99 和旧 Need Manual Review 页面语义。 -- 新数据只按 `S10` 表达纯通知。 +- 新数据按 `S10/S99` 表达来源通知;两者都采用同一来源通知模型。 - 用户可见任务按订单任务 + 多卡建模。 - 普通业务邮件的来源邮件展示卡只读展示,不需要用户确认。 -- S10 因为没有业务卡,邮件展示卡需要确认按钮,用于记录已读 / 已处理。 +- S10/S99 因为没有业务卡,邮件展示卡需要确认按钮,用于记录已读 / 已处理。 - Basic Information 必须先确认;其它业务卡第一版可以独立确认,不强制逐张顺序确认。 - 每张业务卡独立确认、确认后永久锁定。 - 不保存草稿。 @@ -704,7 +706,7 @@ AI 回调包 - `POST /api/integrations/superagent/task-results` 接收 V4 JSON 包:`route_code`、`source_message`、`order_contexts[]`、`message_events[]`。 - `source_message.source_message_id` 按 SourceMessage Inbox 的 `external_message_id` 定位当前邮件;SuperAgent 不传内部数据库 ID。 -- `route_code=S10/S99` 复用现有 `SOURCE_MESSAGE_ONLY` 只读特殊任务机制;任务列表可见,订单列表不可见,不可编辑和执行。 +- V4 `route_code=S10/S99` 写入 `workflow_reservation_v4_source_notification` 来源通知模型;旧 `SOURCE_MESSAGE_ONLY` 只读特殊任务仅保留给 V3 S10/S99 和旧 S000/S999 兼容数据。 - 普通业务包要求 `route_code=null`,并按 `message_events[]` 数组顺序处理。 - 第一版识别六类 `event_type`:`NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`。 - 能映射到现有稳定任务卡的 event 会创建业务任务,并在 `ai_payload_json` 中保存 `v4_source_message`、`v4_order_context`、`v4_message_event`、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。 @@ -714,10 +716,16 @@ AI 回调包 - `UPDATE_BOOKING` 中出现 `rate_code` 或 `after.rate_code` 时按 `UPDATE_RATE_CODE_NOT_ALLOWED` 写入 `adapter_contract_error` transition。 - `manual_review` 只接受 `null` 或布尔 `true`;`true` 必须能由当前对象中可识别的未解决字段解释。 -当前 CP1 仍未完成: +当前 CP4 已完成: -- 尚未重建 V4 订单任务 + 多卡领域模型;Basic Information 仍只是保存在 V4 原始 payload / order context 中,未作为独立可确认任务卡落地。 -- 尚未取消 V3 草稿 / OPERA 模拟骨架;现有可映射 event 仍复用 M002 V3 任务状态和任务卡创建链路。 +- 普通 V4 业务包按 `source_message_id + order_ref` 写入 `workflow_reservation_v4_order_task`。 +- 普通 V4 业务包固定创建 `SOURCE_MESSAGE_DISPLAY` 只读卡和 `BASIC_INFORMATION` 可确认 / 可复核卡。 +- 合法 V4 event 按 `event_type` 创建 `ROOM_INFORMATION`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST` 或 `PAYMENT` 业务卡;event 契约错误只落 AI transition。 +- V4 S10/S99 写入 `workflow_reservation_v4_source_notification`,状态为 `ACK_REQUIRED`。 + +当前仍未完成: + +- 普通 V4 业务包暂时仍保留旧 V3 任务状态、草稿和 OPERA 模拟骨架兼容,便于前端过渡;V4 新查询 / 写接口完成后再逐步废弃旧链路。 - 尚未接入真实 PMS / OPERA / OHIP。 - 尚未改造前端 V4 页面模型;前端第一版只能通过现有任务详情字段和原始 payload 观察 V4 入站结果。 @@ -729,12 +737,12 @@ AI 回调包 - 普通业务包固定展示来源邮件卡,但该卡只读、不阻塞、不替代邮件会话接口。 - 每个 `order_ref` 创建一张 Basic Information 卡。 - 每个 `message_events[]` event 创建一张业务卡,卡片按固定业务顺序展示。 -- S10 后续按来源通知模型实现,不再挂隐藏技术订单;只在任务列表 / 工作台展示,并进入纯通知详情页确认已读 / 已处理。 +- S10/S99 后续按来源通知模型实现,不再挂隐藏技术订单;只在任务列表 / 工作台展示,并进入纯通知详情页确认已读 / 已处理。 - `FIT + BOOKING_CODE` 不建立 ACTIVE 唯一约束;业务绑定查到多条时进入人工复核。 -- V4 前端工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`。 +- V4 前端工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`。 - V4 新数据不再保存后端草稿;用户只提交最终确认或复核解阻。 - 卡片确认后锁定,错误修正后续通过审计和未来纠错流程表达,不覆盖原确认。 - 技术异常只进入 AI transition / 技术运行记录,不进入用户可处理卡。 - CP3 已新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,继续复用 SourceMessage、AI batch、AI transition、Reservation Order 和业务审计表。 -CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。后续开发应从 CP4 入站写入新模型开始,并继续补 V4 查询接口、卡片确认 / 复核、S10 ack 和前端页面。 +CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。CP4 已把 V4 回调写入新模型。后续开发应继续补 V4 查询接口、卡片确认 / 复核、S10/S99 ack 和前端页面。 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index e227334..c107c00 100644 --- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md +++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md @@ -6,9 +6,9 @@ | --- | --- | | 文档版本 | 0.2 | | 日期 | 2026-07-18 | -| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现 | +| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | -| 不适用范围 | V4 入站写入新模型、V4 前端查询接口、卡片确认 / 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | +| 不适用范围 | V4 前端查询接口、卡片确认 / 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | ## 1. 文档定位 @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP3,后端已实现本文第 10、11 节中的持久化基线:新增 `workflow_reservation_v4_order_task`、`workflow_reservation_v4_task_card`、`workflow_reservation_v4_source_notification` 三张表,以及对应 Entity、Mapper、Repository 和基础测试。CP3 仍未把 SuperAgent V4 入站结果写入这些新表,也未开放 V4 前端查询或写操作接口。 +截至 CP4,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前仍未开放 V4 前端查询或写操作接口。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -24,10 +24,10 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 | 主题 | CP1 当前实现 | V4 目标模型差距 | | --- | --- | --- | -| 入站识别 | 已识别 `route_code`、`source_message`、`order_contexts[]`、`message_events[]` | 还没有把 `order_ref` 建成订单任务聚合 | -| SourceMessage | 已按 `source_message.source_message_id` 反查 SourceMessage Inbox | 还没有固定生成业务包内邮件展示卡 | -| Basic Information | 只保存在 `v4_order_context` 原始 payload 中 | 还没有作为每个 `order_ref` 的独立可确认、可锁定卡 | -| 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task` | 还没有一 event 一业务卡的 V4 多卡模型 | +| 入站识别 | 已识别 `route_code`、`source_message`、`order_contexts[]`、`message_events[]` | CP4 已把有合法 event 的 `order_ref` 建成订单任务聚合;V4 查询接口仍未实现 | +| SourceMessage | 已按 `source_message.source_message_id` 反查 SourceMessage Inbox | CP4 已固定生成普通业务包内邮件展示卡;邮件正文完整读取仍走 SourceMessage 会话接口 | +| Basic Information | 已写入 V4 Basic Information 独立卡 | 目录校验、确认 / 复核写接口仍待 CP6 / CP7 | +| 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task`,并已额外创建 V4 业务卡 | 旧任务链路仍作前端过渡兼容,后续 V4 查询和写接口完成后再逐步废弃 | | 技术错误 | 已落 `adapter_contract_error` transition | 已符合目标方向:不创建用户可处理卡 | | 草稿 / READY / OPERA | 仍复用 V3 草稿、READY 和 OPERA 模拟骨架 | V4 新数据确认口径是不保存草稿、确认后锁定、当前不生成 OPERA | | 前端查询 | 复用旧任务列表和任务详情 | 需要新订单任务详情接口返回邮件卡、Basic Information 卡和业务卡数组 | @@ -98,7 +98,7 @@ V4 package / event contract error - Basic Information 不是 event,但必须是独立任务卡。 - 来源邮件展示卡不是 event,普通业务包内只读,不参与订单执行阻塞。 -- S10 采用来源通知模型:任务列表 / 工作台可见,点击进入纯通知详情页;只显示邮件展示卡和确认按钮,不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。 +- S10/S99 采用来源通知模型:任务列表 / 工作台可见,点击进入纯通知详情页;只显示邮件展示卡和确认按钮,不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。 - 同一 `order_ref` 下如果有多个相同 `event_type`,第一版按 event 数组项分别建卡;页面排序按固定卡片顺序,再按 `source_event_index` 排序。 ## 6. 状态设计 @@ -124,8 +124,8 @@ V4 package / event contract error | `PENDING_CONFIRM` | 待确认 | 若没有前置阻塞,允许提交最终确认 | | `REVIEW_REQUIRED` | 待人工复核 | 若没有前置阻塞,允许提交复核修正并确认 | | `CONFIRMED` | 已确认锁定 | 只能查看,不允许再次编辑或覆盖 | -| `ACK_REQUIRED` | 通知待确认 | 仅用于 S10 来源通知,落在 `workflow_reservation_v4_source_notification.notification_status` | -| `ACKED` | 通知已确认 | 仅用于 S10 来源通知,落在 `workflow_reservation_v4_source_notification.notification_status` | +| `ACK_REQUIRED` | 通知待确认 | 仅用于 S10/S99 来源通知,落在 `workflow_reservation_v4_source_notification.notification_status` | +| `ACKED` | 通知已确认 | 仅用于 S10/S99 来源通知,落在 `workflow_reservation_v4_source_notification.notification_status` | ### 6.3 可操作性派生 @@ -254,7 +254,7 @@ V4 当前不做 OPERA / PMS 执行,但仍需要保留同订单处理顺序, - 按来源邮件接收时间、AI batch 接收时间、订单任务创建时间排序。 - 更早订单任务仍有未完成可确认卡时,后续订单任务只能查看。 - 技术错误 transition 不参与阻塞。 -- S10 来源通知不参与阻塞,也不被阻塞。 +- S10/S99 来源通知不参与阻塞,也不被阻塞。 ## 10. 数据表设计草案 @@ -306,7 +306,7 @@ uk_reservation_v4_order_task_source_index(hotel_id, source_message_id, order_con | --- | --- | | `id` | V4 任务卡 ID | | `hotel_id` | 酒店 ID | -| `v4_order_task_id` | 所属 V4 订单任务 ID;S10 来源通知不挂订单任务,后续使用独立通知模型承载 | +| `v4_order_task_id` | 所属 V4 订单任务 ID;S10/S99 来源通知不挂订单任务,后续使用独立通知模型承载 | | `source_message_id` | 来源消息 ID,便于查邮件会话 | | `ai_transition_id` | 对应 event 的 AI transition ID;Basic Information 和来源邮件展示卡可为空 | | `card_type` | 卡片类型 | @@ -339,7 +339,7 @@ uk_reservation_v4_task_card_slot(hotel_id, v4_order_task_id, card_sort_order, so ### 10.4 已新增表:`workflow_reservation_v4_source_notification` -一条记录表示一个 S10 来源通知。它不挂订单任务、不创建订单、不进入订单列表,只用于任务列表 / 工作台和纯通知详情页。 +一条记录表示一个 S10/S99 来源通知。它不挂订单任务、不创建订单、不进入订单列表,只用于任务列表 / 工作台和纯通知详情页。 | 字段 | 中文说明 | | --- | --- | @@ -347,10 +347,10 @@ uk_reservation_v4_task_card_slot(hotel_id, v4_order_task_id, card_sort_order, so | `hotel_id` | 酒店 ID,第一版使用系统默认酒店或 SourceMessage 所属酒店 | | `source_message_id` | SourceMessage Inbox 内部 ID | | `ai_batch_id` | AI 回调批次 ID | -| `ai_transition_id` | S10 对应 AI transition ID | -| `route_code` | 固定为 `S10` | +| `ai_transition_id` | S10/S99 对应 AI transition ID | +| `route_code` | `S10` 或 `S99` | | `notification_status` | `ACK_REQUIRED` / `ACKED` | -| `raw_payload_json` | S10 原始 AI 片段或包级摘要 | +| `raw_payload_json` | S10/S99 原始 AI 片段或包级摘要 | | `ack_by` / `ack_at` | 确认人和确认 UTC 时间 | | `source_received_at` | 来源邮件接收 UTC 时间,用于任务列表 / 工作台排序 | | `version` | 乐观锁版本,用于确认按钮并发控制 | @@ -410,7 +410,7 @@ CP3 Repository 已封装: - 按 SourceMessage + order_ref 幂等创建订单任务。 - 按 `order_context_index` 保留同一 SourceMessage 下多个 `order_contexts[]` 的稳定顺序。 - 创建 Basic Information / SourceMessage / Event 卡的基础插入方法;非 event 卡 `source_event_index` 固定写入 `0`。 -- 按 SourceMessage + AI batch 幂等创建 S10 来源通知。 +- 按 SourceMessage + AI batch 幂等创建 S10/S99 来源通知。 - 查询订单任务详情和卡片列表。 - 查询来源通知列表和详情。 - 卡片状态的 version 乐观锁更新基础方法。 @@ -425,18 +425,18 @@ Service 不直接访问 Mapper。 建议新增或拆分: -- `ReservationV4TaskIntakeService`:V4 入站从 AI transition 落 V4 订单任务和卡片。 +- `ReservationV4TaskIntakeService`:已实现,V4 入站从 AI transition 落 V4 订单任务、卡片和 S10/S99 来源通知。 - `ReservationV4OrderTaskQueryService`:前端查询订单任务列表和详情。 - `ReservationV4TaskCardCommandService`:处理卡片确认、复核和订单归属确认。 -- `ReservationV4SourceNotificationService`:处理 S10 来源通知查询和确认。 +- `ReservationV4SourceNotificationService`:处理 S10/S99 来源通知查询和确认。 -当前 `ReservationAiTaskIntakeServiceImpl` 后续应只负责入站编排和调用 V4 service,不继续膨胀成 V4 领域服务。 +当前 `ReservationAiTaskIntakeServiceImpl` 已在 V4 分支调用 `ReservationV4TaskIntakeService` 完成新模型写入;后续查询、确认和复核仍应继续拆到独立 V4 service,避免主入站类继续膨胀。 ## 12. 前端查询接口草案 以下只是接口草案,CP2 不实现。 -V4 前端接口不继续扩展旧 `/api/reservation/tasks/**` 作为 V4 主模型入口。第一版草案中,工作台统一列表使用 `/api/reservation/workbench-items`,业务订单任务使用 `/api/reservation/order-tasks/**`,S10 来源通知详情和确认使用 `/api/reservation/source-notifications/**`。 +V4 前端接口不继续扩展旧 `/api/reservation/tasks/**` 作为 V4 主模型入口。第一版草案中,工作台统一列表使用 `/api/reservation/workbench-items`,业务订单任务使用 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情和确认使用 `/api/reservation/source-notifications/**`。 ### 12.1 工作台统一列表 @@ -449,7 +449,7 @@ GET /api/reservation/workbench-items 用途: - 作为 V4 任务列表 / 工作台的第一版统一入口。 -- 同时返回业务订单任务和 S10 来源通知。 +- 同时返回业务订单任务和 S10/S99 来源通知。 - 前端按 `item_type` 区分跳转目标。 返回摘要应包含: @@ -457,10 +457,10 @@ GET /api/reservation/workbench-items - `item_type`:`ORDER_TASK` / `SOURCE_NOTIFICATION`。 - `target_id`:订单任务 ID 或来源通知 ID。 - `source_message_summary` -- `display_order_key`:S10 来源通知为空。 -- `card_counts`:S10 来源通知为空或只返回通知状态。 -- `next_action_card_id`:S10 来源通知为空。 -- `notification_status`:仅 S10 来源通知返回 `ACK_REQUIRED` / `ACKED`。 +- `display_order_key`:S10/S99 来源通知为空。 +- `card_counts`:S10/S99 来源通知为空或只返回通知状态。 +- `next_action_card_id`:S10/S99 来源通知为空。 +- `notification_status`:仅 S10/S99 来源通知返回 `ACK_REQUIRED` / `ACKED`。 - `order_task_status`:仅业务订单任务返回 `OPEN` / `COMPLETED`。 - `display_status`:可返回 `OPEN` / `BLOCKED` / `COMPLETED` / `ACK_REQUIRED` / `ACKED`。 - `readonly_reason_code` @@ -503,7 +503,7 @@ GET /api/reservation/order-tasks 说明: -- 该接口只返回业务订单任务,不返回 S10 来源通知。 +- 该接口只返回业务订单任务,不返回 S10/S99 来源通知。 - V4 任务列表 / 工作台页面第一版优先使用 `GET /api/reservation/workbench-items`。 ### 12.3 订单任务详情 @@ -530,7 +530,7 @@ GET /api/reservation/order-tasks/{orderTaskId} 前端应以返回的 `cards[]` 和 `availability` 为准渲染,不自行拼完整字段矩阵。 -### 12.4 S10 来源通知详情 +### 12.4 S10/S99 来源通知详情 ```text GET /api/reservation/source-notifications/{notificationId} @@ -551,7 +551,7 @@ GET /api/reservation/source-notifications/{notificationId} 说明: -- 只用于 S10 纯通知详情页。 +- 只用于 S10/S99 来源通知详情页。 - 不返回 `order_task`、`bound_order`、`basic_information_card` 或 `business_cards`。 - 邮件正文、附件 URL 和会话原文读取仍按 SourceMessage 权限和原文读取审计规则处理。 @@ -607,7 +607,7 @@ POST /api/reservation/source-notifications/{notificationId}/ack 权限:RESERVATION_TASK_CONFIRM ``` -S10 已确认采用来源通知模型,不继续复用隐藏技术订单或旧 `SOURCE_MESSAGE_ONLY` 任务确认方式。第一版通知详情只显示邮件展示卡和确认按钮,确认动作表示已读 / 已处理。 +S10/S99 已确认采用来源通知模型,不继续复用隐藏技术订单或旧 `SOURCE_MESSAGE_ONLY` 任务确认方式。第一版通知详情只显示邮件展示卡和确认按钮,确认动作表示已读 / 已处理。 请求要点: @@ -657,7 +657,7 @@ S10 已确认采用来源通知模型,不继续复用隐藏技术订单或旧 | 查询工作台、订单任务列表 / 详情、来源通知详情 | `FRONTEND_USER` | `RESERVATION_TASK_READ` | 只读默认不写业务审计 | | 确认卡片 | `FRONTEND_USER` | `RESERVATION_TASK_CONFIRM` | 写业务审计 | | 复核解阻 | `FRONTEND_USER` | `RESERVATION_MANUAL_REVIEW_RESOLVE` | 写业务审计 | -| 确认 S10 来源通知 | `FRONTEND_USER` | `RESERVATION_TASK_CONFIRM` | 写业务审计 | +| 确认 S10/S99 来源通知 | `FRONTEND_USER` | `RESERVATION_TASK_CONFIRM` | 写业务审计 | | 读取邮件正文 / 附件 | `FRONTEND_USER` | `SOURCE_MESSAGE_READ` + `SOURCE_MESSAGE_ORIGINAL_READ` | 写原文读取审计 | | SuperAgent V4 回调 | `THIRD_PARTY_SUPERAGENT` | HMAC 机器鉴权 | 写 AI batch / transition | @@ -668,22 +668,22 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | Checkpoint | 目标 | 主要交付 | | --- | --- | --- | | M002-V4-CP3 | V4 表结构和基础 Repository | 新增 V4 order task / card / source notification 表、Entity、Mapper、Repository、测试 | -| M002-V4-CP4 | V4 入站落新模型 | SuperAgent V4 回调创建订单任务、Basic Information 卡、业务卡、邮件展示卡和 S10 来源通知 | +| M002-V4-CP4 | V4 入站落新模型 | 已完成:SuperAgent V4 回调创建订单任务、Basic Information 卡、业务卡、邮件展示卡和 S10/S99 来源通知 | | M002-V4-CP5 | V4 查询接口 | 工作台统一列表、订单任务列表、详情、订单详情时间线和来源通知详情查询接口 | -| M002-V4-CP6 | V4 卡片确认和复核 | 不保存草稿,支持确认、复核、锁定、审计、阻塞规则和 S10 来源通知确认 | +| M002-V4-CP6 | V4 卡片确认和复核 | 不保存草稿,支持确认、复核、锁定、审计、阻塞规则和 S10/S99 来源通知确认 | | M002-V4-CP7 | 受控目录第一版 | Account、RoomType、RateCode、Department 固定目录或版本化快照校验 | | M002-V4-CP8 | V4 前端契约收口 | 字段、控件、availability、错误展示和旧任务入口切换 | | M002-V4-CP9 | 旧 V3 / V2 能力收口评估 | 明确哪些兼容入口可以关闭,哪些仍保留只读历史 | ## 17. 已确认设计决策 -1. V4 前端接口不继续扩展旧 `/api/reservation/tasks/**`;工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10 来源通知使用 `/api/reservation/source-notifications/**`。 +1. V4 前端接口不继续扩展旧 `/api/reservation/tasks/**`;工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知使用 `/api/reservation/source-notifications/**`。 2. `FIT + BOOKING_CODE` 第一版不建立 ACTIVE 唯一约束;业务绑定时要求匹配结果至多一条,匹配多条进入人工复核。 3. `BOOKING_CODE` 只是拿到 `CONFIRMATION_NUMBER` 前的临时定位字段,后续不作为 PMS 永久主键。 -4. S10 采用来源通知模型:任务列表 / 工作台展示,不挂隐藏技术订单;通知详情只显示邮件展示卡和确认按钮。 -5. S10 不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。 +4. S10/S99 采用来源通知模型:任务列表 / 工作台展示,不挂隐藏技术订单;通知详情只显示邮件展示卡和确认按钮。 +5. S10/S99 不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。 6. 第一版不强制所有业务卡逐张顺序确认,但 Basic Information 必须先确认。 7. Basic Information 的 Account / Market / Source 目录第一版使用后端固定种子数据。 8. 普通业务邮件的来源邮件展示卡只读展示,不需要用户确认;用户只确认 Basic Information 和具体业务卡。 -9. S10 因为没有业务卡,邮件展示卡需要确认按钮,用来记录已读 / 已处理。 +9. S10/S99 因为没有业务卡,邮件展示卡需要确认按钮,用来记录已读 / 已处理。 10. V4 新模型落地并完成前端切换后,旧 V2/V3 任务详情、草稿保存和最终确认接口可以逐步废弃。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 5deb448..d1183bb 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -47,8 +47,8 @@ | `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 | -| `GET /api/reservation/workbench-items` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL 或 AI 原始 payload | -| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10 来源通知 | 只读查询默认不写业务审计 | +| `GET /api/reservation/workbench-items` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL 或 AI 原始 payload | +| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计 | | `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限 | | `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限 | | `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 | @@ -103,7 +103,7 @@ | `POST /api/ai-query/v1/object-detail` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;返回最小必要上下文 | 记录请求 ID、client_id 和安全错误 | | `POST /api/ai-query/v1/conversation-tasks` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;不返回邮件原文 | 记录请求 ID、client_id 和安全错误 | | `POST /api/ai-query/v1/conversation-source` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;只按契约返回需要字段 | 记录请求 ID、client_id 和安全错误 | -| `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持;V4 / V3 / V2 共存期均必须用外部 `source_message_id` 匹配 Inbox,技术契约错误只落 adapter error,不创建用户可处理任务 | 记录 batch、transition、adapter error、幂等结果和安全错误 | +| `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持;V4 / V3 / V2 共存期均必须用外部 `source_message_id` 匹配 Inbox,技术契约错误只落 adapter error,不创建用户可处理任务;V4 普通业务包额外写入 V4 订单任务 / 多卡模型,V4 S10/S99 写入来源通知且不创建旧任务 | 记录 batch、transition、adapter error、幂等结果和安全错误 | | `/mcp` | `THIRD_PARTY_MCP` | Bearer Token;提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果和业务入站结果 | | AgentBus WebSocket | `THIRD_PARTY_AGENTBUS` | AgentBus Token + capture 开关 | 保持;只入 SourceMessage,不直接建业务任务 | 记录 SourceMessage、payload hash 和 dispatch run | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AcceptedEventDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AcceptedEventDraft.java new file mode 100644 index 0000000..3b865a4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AcceptedEventDraft.java @@ -0,0 +1,31 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +/** + * Reservation V4 已通过入站校验并已写入 AI transition 的 event 草稿。 + * 用于把 SuperAgent V4 回调从 AI 过渡层继续写入 V4 订单任务和多卡模型。 + */ +public record ReservationV4AcceptedEventDraft( + /** V4 包内订单引用。 */ + String orderRef, + /** message_events[] 中的一基序号。 */ + Integer sourceEventIndex, + /** 当前 event 对应的 AI transition ID。 */ + Long aiTransitionId, + /** 旧过渡链路已经解析出的本地订单投影 ID,V4 第一版复用该绑定。 */ + Long orderId, + /** 目标订单业务类型,例如 GROUP、FIT。 */ + String targetBookingType, + /** 目标订单定位类型,例如 GROUP_CODE、BOOKING_CODE、CONFIRMATION_NUMBER。 */ + String targetLocatorType, + /** 目标订单定位值,未解决时为空。 */ + String targetLocatorValue, + /** V4 业务事件类型。 */ + String eventType, + /** 当前 event 是否需要人工复核。 */ + boolean manualReviewRequired, + /** 当前业务卡 AI 原始片段 JSON。 */ + String aiPayloadJson, + /** 当前业务卡展示快照 JSON。 */ + String displayPayloadJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationDraft.java index 8202013..b25bad1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationDraft.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationDraft.java @@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; import java.time.LocalDateTime; /** - * Reservation V4 来源通知创建草稿。第一版用于 S10 纯通知入库。 + * Reservation V4 来源通知创建草稿。第一版用于 S10/S99 纯通知入库。 */ public record ReservationV4SourceNotificationDraft( /** 酒店 ID。 */ @@ -12,9 +12,9 @@ public record ReservationV4SourceNotificationDraft( Long sourceMessageId, /** AI 回调批次 ID。 */ Long aiBatchId, - /** AI transition ID,S10 第一版可为空。 */ + /** AI transition ID,来源通知第一版可为空。 */ Long aiTransitionId, - /** 包级路由码,第一版用于 S10。 */ + /** 包级路由码,当前用于 S10/S99。 */ String routeCode, /** 通知状态。 */ String notificationStatus, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationSnapshot.java index 587a17b..f3f8fce 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationSnapshot.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationSnapshot.java @@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; import java.time.LocalDateTime; /** - * Reservation V4 来源通知快照。用于后续 S10 通知详情和确认接口。 + * Reservation V4 来源通知快照。用于后续 S10/S99 通知详情和确认接口。 */ public record ReservationV4SourceNotificationSnapshot( /** V4 来源通知 ID。 */ @@ -14,7 +14,7 @@ public record ReservationV4SourceNotificationSnapshot( Long sourceMessageId, /** AI 回调批次 ID。 */ Long aiBatchId, - /** AI transition ID,S10 第一版可为空。 */ + /** AI transition ID,来源通知第一版可为空。 */ Long aiTransitionId, /** 包级路由码。 */ String routeCode, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4SourceNotificationEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4SourceNotificationEntity.java index 7e3e76d..c48fc4f 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4SourceNotificationEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4SourceNotificationEntity.java @@ -6,7 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime; /** - * Reservation V4 来源通知实体。第一版用于保存 S10 纯通知,不挂订单任务。 + * Reservation V4 来源通知实体。第一版用于保存 S10/S99 来源通知,不挂订单任务。 */ @TableName("workflow_reservation_v4_source_notification") public class ReservationV4SourceNotificationEntity { diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java index 28e6bc9..26c487e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java @@ -80,7 +80,7 @@ public class MybatisReservationV4SourceNotificationRepository implements Reserva } /** - * 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。 + * 幂等创建 S10/S99 来源通知;并发重复插入时返回已存在记录。 */ @Override public ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification( @@ -114,7 +114,7 @@ public class MybatisReservationV4SourceNotificationRepository implements Reserva } /** - * 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。 + * 按 version 乐观锁更新来源通知状态,常用于 S10/S99 确认已读 / 已处理。 */ @Override public boolean updateNotificationStatusWithVersion( diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java index 2f34d98..7f3f90d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.Optional; /** - * Reservation V4 来源通知持久化边界。第一版用于 S10 纯通知的幂等创建、查询和确认。 + * Reservation V4 来源通知持久化边界。第一版用于 S10/S99 纯通知的幂等创建、查询和确认。 */ public interface ReservationV4SourceNotificationRepository { @@ -34,12 +34,12 @@ public interface ReservationV4SourceNotificationRepository { Long sourceMessageId); /** - * 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。 + * 幂等创建 S10/S99 来源通知;并发重复插入时返回已存在记录。 */ ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(ReservationV4SourceNotificationDraft draft); /** - * 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。 + * 按 version 乐观锁更新来源通知状态,常用于 S10/S99 确认已读 / 已处理。 */ boolean updateNotificationStatusWithVersion( String hotelId, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4TaskIntakeService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4TaskIntakeService.java new file mode 100644 index 0000000..4a8ecd3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4TaskIntakeService.java @@ -0,0 +1,34 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Reservation V4 入站写入服务。负责把已校验的 SuperAgent V4 结果写入订单任务、多卡和来源通知模型。 + */ +public interface ReservationV4TaskIntakeService { + + /** + * 将普通 V4 业务包写入 V4 订单任务模型;只为至少存在一个合法业务 event 的 order_ref 建卡。 + */ + void createBusinessPackageTasks( + SourceMessageInboxSnapshot sourceMessage, + Long aiBatchId, + JsonNode root, + List acceptedEvents, + LocalDateTime now); + + /** + * 将 V4 S10/S99 纯通知写入来源通知模型,不创建旧任务、订单任务或业务卡。 + */ + void createSourceNotification( + SourceMessageInboxSnapshot sourceMessage, + Long aiBatchId, + Long aiTransitionId, + String routeCode, + JsonNode root, + LocalDateTime now); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java index e03ce75..fced303 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -6,6 +6,7 @@ import cn.nianxx.thhotel.platform.common.enums.SourceMessageOnlyResultCode; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft; @@ -27,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResul import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultWarning; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4TaskIntakeService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -147,6 +149,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private final ObjectMapper objectMapper; private final SourceMessageInboxRepository sourceMessageInboxRepository; private final ReservationAiWorkflowRepository workflowRepository; + private final ReservationV4TaskIntakeService v4TaskIntakeService; private final ReservationAiV4CallbackValidator v4CallbackValidator; private final ReservationAiV4CallbackRouter v4CallbackRouter; private final boolean allowLegacyInternalSourceMessageId; @@ -158,11 +161,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ObjectMapper objectMapper, SourceMessageInboxRepository sourceMessageInboxRepository, ReservationAiWorkflowRepository workflowRepository, + ReservationV4TaskIntakeService v4TaskIntakeService, @Value("${superagent.task-result.allow-legacy-internal-source-message-id:false}") boolean allowLegacyInternalSourceMessageId) { this.objectMapper = objectMapper; this.sourceMessageInboxRepository = sourceMessageInboxRepository; this.workflowRepository = workflowRepository; + this.v4TaskIntakeService = v4TaskIntakeService; this.v4CallbackValidator = new ReservationAiV4CallbackValidator(); this.v4CallbackRouter = new ReservationAiV4CallbackRouter(objectMapper); this.allowLegacyInternalSourceMessageId = allowLegacyInternalSourceMessageId; @@ -446,7 +451,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } /** - * 接收 V4 S10/S99 来源邮件通知。V4 不再要求旧 result_type 和通知对象,按包级 route_code 建只读任务。 + * 接收 V4 S10/S99 来源邮件通知。V4 不再要求旧 result_type 和通知对象,按包级 route_code 写来源通知。 */ private SuperAgentTaskResultResponse acceptV4SourceMessageNotification( JsonNode root, @@ -469,20 +474,68 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode) .filter(ReservationAiRouteDefinition::sourceMessageNotification) .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知 route_code 无效。")); - SourceMessageOnlyEntryResult entryResult = new SourceMessageOnlyEntryResult( - SourceMessageOnlyResultCode.fromCode(route.taskSubtype()) - .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "V4 通知结果码无效。")), - resolvedSourceMessage.responseSourceMessageId(), - AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code(), - route.routeCode() + SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); + String hotelId = sourceMessage.hotelId(); + Long sourceMessageId = sourceMessage.id(); + String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody); + String batchIdempotencyKey = sha256(BATCH_KEY_PREFIX + "|" + sourceMessageId + "|" + requestPayloadSha256); + + ReservationAiBatchSnapshot existingBatch = workflowRepository + .findBatchBySourceMessageId(hotelId, sourceMessageId) + .orElse(null); + if (existingBatch != null) { + return handleExistingBatch( + requestId, + resolvedSourceMessage.responseSourceMessageId(), + requestPayloadSha256, + existingBatch); + } + + LocalDateTime now = nowUtc(); + String safeRequestId = optionalText(requestId, "request_id", LENGTH_128); + ReservationAiBatchDraft batchDraft = new ReservationAiBatchDraft( + hotelId, + sourceMessageId, + requestPayloadSha256, + batchIdempotencyKey, + requireText(clientId, "clientId", LENGTH_128), + safeRequestId, + now, + 1, + null + ); + Long batchId = insertBatchOrReplay(batchDraft); + if (batchId == null) { + return handleExistingBatch( + requestId, + resolvedSourceMessage.responseSourceMessageId(), + requestPayloadSha256, + workflowRepository.findBatchBySourceMessageId(hotelId, sourceMessageId) + .orElseThrow(() -> error(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT", "AI 批次并发写入状态不确定。"))); + } + SuperAgentTaskResultItemResponse responseItem = createV4SourceNotificationTransition( + hotelId, + sourceMessageId, + batchId, + root, + route, + now); + v4TaskIntakeService.createSourceNotification( + sourceMessage, + batchId, + longValue(responseItem.aiTransitionId()), + route.routeCode(), + root, + now); + return new SuperAgentTaskResultResponse( + safeRequestId, + resolvedSourceMessage.responseSourceMessageId(), + batchId.toString(), + false, + 1, + List.of(responseItem), + List.of() ); - return acceptSourceMessageOnlyEntryResult( - entryResult, - clientId, - requestId, - defaultHotelId, - rawBody, - resolvedSourceMessage.snapshot()); } /** @@ -551,6 +604,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } List responseItems = new ArrayList<>(messageEvents.size()); + List acceptedEvents = new ArrayList<>(messageEvents.size()); Map targetOrderByOrderRef = new LinkedHashMap<>(); for (int index = 0; index < messageEvents.size(); index++) { JsonNode event = messageEvents.get(index); @@ -585,8 +639,11 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta continue; } ObjectNode item = v4CallbackRouter.normalizeEventToTaskResultItem(root, event, route, arrayIndex); - responseItems.add(createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now)); + SuperAgentTaskResultItemResponse responseItem = createItem(hotelId, sourceMessageId, batchId, item, arrayIndex, now); + responseItems.add(responseItem); + acceptedEvents.add(toV4AcceptedEvent(event, route, responseItem, arrayIndex)); } + v4TaskIntakeService.createBusinessPackageTasks(sourceMessage, batchId, root, acceptedEvents, now); return new SuperAgentTaskResultResponse( safeRequestId, @@ -1038,6 +1095,60 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return "linked_parent_release_after_child_split".equals(firstText(extractedFields, event, "relationship_type")); } + /** + * 将已创建 transition 的 V4 event 转成新模型写入草稿,避免 V4 多卡服务反向依赖旧任务响应细节。 + */ + private ReservationV4AcceptedEventDraft toV4AcceptedEvent( + JsonNode event, + ReservationAiRouteDefinition route, + SuperAgentTaskResultItemResponse responseItem, + int arrayIndex) { + JsonNode targetOrder = event.path("target_order"); + return new ReservationV4AcceptedEventDraft( + requireText(textAt(event, "order_ref"), "order_ref", LENGTH_128), + arrayIndex, + longValue(responseItem.aiTransitionId()), + longValue(responseItem.orderId()), + optionalText(textAt(targetOrder, "booking_type"), "target_order.booking_type", LENGTH_32), + optionalText(textAt(targetOrder, "locator_type"), "target_order.locator_type", LENGTH_64), + optionalText(textAt(targetOrder, "locator_value"), "target_order.locator_value", LENGTH_128), + requireText(textAt(event, "event_type"), "event_type", LENGTH_64), + isBooleanTrue(event.path("manual_review")), + nodeJson(v4BusinessCardAiPayload(event, route)), + nodeJson(v4BusinessCardDisplayPayload(event, route)) + ); + } + + /** + * 生成 V4 业务卡 AI 原始片段,保留 event 原文和系统派生路由,不复制整封邮件正文。 + */ + private ObjectNode v4BusinessCardAiPayload(JsonNode event, ReservationAiRouteDefinition route) { + ObjectNode payload = objectMapper.createObjectNode(); + payload.set("v4_message_event", event); + payload.put("route_code", route.routeCode()); + payload.put("result_type", route.resultType()); + payload.put("task_type", route.taskType()); + payload.put("task_subtype", route.taskSubtype()); + payload.put("system_process_category", route.systemProcessCategory().name()); + payload.put("field_contract_version", "20260718-v4"); + return payload; + } + + /** + * 生成 V4 业务卡展示快照,后续查询接口可在此基础上继续补目录和可操作性。 + */ + private ObjectNode v4BusinessCardDisplayPayload(JsonNode event, ReservationAiRouteDefinition route) { + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("event_type", textAt(event, "event_type")); + payload.put("route_code", route.routeCode()); + payload.put("result_type", route.resultType()); + payload.put("task_type", route.taskType()); + payload.put("task_subtype", route.taskSubtype()); + payload.set("target_order", event.path("target_order")); + payload.set("business_fields", event); + return payload; + } + /** * 归一 Parent split 的 Parent Group key;只补齐 adapter 派生用副本,不修改请求原文。 */ @@ -1969,6 +2080,16 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } } + /** + * 将本服务内部生成的字符串 ID 转回数据库 Long 外键;空值表示当前响应未创建对应对象。 + */ + private Long longValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + return Long.valueOf(value); + } + /** * 从对象节点中读取文本字段,缺失或 null 时返回 null。 */ @@ -2267,6 +2388,34 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta V4_SKILL_ID); } + /** + * 保存 V4 S10/S99 来源通知 transition。该记录只用于 AI 入站追踪,不创建旧任务或隐藏订单。 + */ + private SuperAgentTaskResultItemResponse createV4SourceNotificationTransition( + String hotelId, + Long sourceMessageId, + Long batchId, + JsonNode root, + ReservationAiRouteDefinition route, + LocalDateTime now) { + ObjectNode notificationPayload = objectMapper.createObjectNode(); + notificationPayload.set("v4_package", root == null ? objectMapper.createObjectNode() : root); + notificationPayload.set("v4_source_message", root == null ? objectMapper.createObjectNode() : root.path("source_message")); + return createAdapterContractErrorTransition( + hotelId, + sourceMessageId, + batchId, + notificationPayload, + 1, + 1, + route, + null, + null, + now, + V4_CATALOG_CODE, + V4_SKILL_ID); + } + /** * 保存 V4 包级 Adapter 契约错误 transition。该记录不关联具体任务,只用于排查回调包整体不合规。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java new file mode 100644 index 0000000..ebb1e3e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java @@ -0,0 +1,405 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4TaskIntakeService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +/** + * Reservation V4 入站写入实现。将 V4 回调结果写入订单任务、任务卡和来源通知表。 + */ +@Service +public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskIntakeService { + + private static final int SOURCE_MESSAGE_CARD_SORT_ORDER = 10; + private static final int BASIC_INFORMATION_CARD_SORT_ORDER = 20; + private static final int ROOM_INFORMATION_CARD_SORT_ORDER = 30; + private static final int TRACE_CARD_SORT_ORDER = 40; + private static final int ROOMING_LIST_CARD_SORT_ORDER = 50; + private static final int PAYMENT_CARD_SORT_ORDER = 60; + + private final ObjectMapper objectMapper; + private final ReservationV4WorkflowRepository workflowRepository; + private final ReservationV4SourceNotificationRepository sourceNotificationRepository; + + /** + * 注入 V4 持久化边界和 JSON 工具,Service 只编排入站写入,不直接访问 Mapper。 + */ + public ReservationV4TaskIntakeServiceImpl( + ObjectMapper objectMapper, + ReservationV4WorkflowRepository workflowRepository, + ReservationV4SourceNotificationRepository sourceNotificationRepository) { + this.objectMapper = objectMapper; + this.workflowRepository = workflowRepository; + this.sourceNotificationRepository = sourceNotificationRepository; + } + + /** + * 将普通 V4 业务包按 order_ref 写入订单任务、来源邮件展示卡、Basic Information 卡和业务卡。 + */ + @Override + public void createBusinessPackageTasks( + SourceMessageInboxSnapshot sourceMessage, + Long aiBatchId, + JsonNode root, + List acceptedEvents, + LocalDateTime now) { + if (acceptedEvents == null || acceptedEvents.isEmpty()) { + return; + } + Map> eventsByOrderRef = groupEventsByOrderRef(acceptedEvents); + JsonNode orderContexts = root.path("order_contexts"); + for (int index = 0; index < orderContexts.size(); index++) { + JsonNode orderContext = orderContexts.get(index); + String orderRef = textAt(orderContext, "order_ref"); + List orderEvents = eventsByOrderRef.get(orderRef); + if (orderEvents == null || orderEvents.isEmpty()) { + continue; + } + ReservationV4AcceptedEventDraft bindingEvent = selectOrderTaskBindingEvent(orderEvents); + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( + sourceMessage.hotelId(), + sourceMessage.id(), + aiBatchId, + orderRef, + index + 1, + localOrderId(bindingEvent), + bindingEvent.targetBookingType(), + bindingEvent.targetLocatorType(), + bindingEvent.targetLocatorValue(), + targetResolutionStatus(bindingEvent), + ReservationV4OrderTaskStatus.OPEN.name(), + sourceReceivedAt(sourceMessage, now), + now + )); + if (!workflowRepository.findTaskCardsByOrderTaskId(sourceMessage.hotelId(), orderTask.id()).isEmpty()) { + continue; + } + insertSourceMessageDisplayCard(sourceMessage, orderTask.id(), root.path("source_message"), now); + insertBasicInformationCard(sourceMessage, orderTask.id(), orderContext, now); + for (ReservationV4AcceptedEventDraft acceptedEvent : orderEvents) { + insertBusinessCard(sourceMessage, orderTask.id(), acceptedEvent, now); + } + } + } + + /** + * 将 V4 S10/S99 写入来源通知模型;该记录后续由工作台和通知详情接口读取。 + */ + @Override + public void createSourceNotification( + SourceMessageInboxSnapshot sourceMessage, + Long aiBatchId, + Long aiTransitionId, + String routeCode, + JsonNode root, + LocalDateTime now) { + sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft( + sourceMessage.hotelId(), + sourceMessage.id(), + aiBatchId, + aiTransitionId, + routeCode, + ReservationV4NotificationStatus.ACK_REQUIRED.name(), + nodeJson(root), + sourceReceivedAt(sourceMessage, now), + now + )); + } + + /** + * 新增普通业务包固定的来源邮件展示卡。 + */ + private void insertSourceMessageDisplayCard( + SourceMessageInboxSnapshot sourceMessage, + Long orderTaskId, + JsonNode sourceMessagePayload, + LocalDateTime now) { + ObjectNode displayPayload = objectMapper.createObjectNode(); + displayPayload.put("card_type", ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name()); + displayPayload.put("source_message_id", sourceMessage.externalMessageId()); + displayPayload.put("conversation_id", textAt(sourceMessagePayload, "conversation_id")); + displayPayload.put("subject", textAt(sourceMessagePayload, "subject")); + displayPayload.put("sender", textAt(sourceMessagePayload, "sender")); + displayPayload.put("body_content_type", textAt(sourceMessagePayload, "body_content_type")); + displayPayload.set("attachments", attachmentSummaries(sourceMessagePayload.path("attachments"))); + workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( + sourceMessage.hotelId(), + orderTaskId, + sourceMessage.id(), + null, + ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), + null, + 0, + SOURCE_MESSAGE_CARD_SORT_ORDER, + ReservationV4CardStatus.READONLY.name(), + null, + nodeJson(sourceMessagePayload), + nodeJson(displayPayload), + null, + now + )); + } + + /** + * 新增订单级 Basic Information 卡,account_code 缺失时进入人工复核。 + */ + private void insertBasicInformationCard( + SourceMessageInboxSnapshot sourceMessage, + Long orderTaskId, + JsonNode orderContext, + LocalDateTime now) { + JsonNode basicInformation = orderContext.path("basic_information"); + boolean manualReviewRequired = isBooleanTrue(basicInformation.path("manual_review")); + ObjectNode displayPayload = objectMapper.createObjectNode(); + displayPayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name()); + displayPayload.put("order_ref", textAt(orderContext, "order_ref")); + displayPayload.set("basic_information", basicInformation); + workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( + sourceMessage.hotelId(), + orderTaskId, + sourceMessage.id(), + null, + ReservationV4CardType.BASIC_INFORMATION.name(), + null, + 0, + BASIC_INFORMATION_CARD_SORT_ORDER, + cardStatus(manualReviewRequired), + reviewStatus(manualReviewRequired), + nodeJson(orderContext), + nodeJson(displayPayload), + null, + now + )); + } + + /** + * 新增 V4 event 对应的业务卡,人工复核 event 直接进入 REVIEW_REQUIRED。 + */ + private void insertBusinessCard( + SourceMessageInboxSnapshot sourceMessage, + Long orderTaskId, + ReservationV4AcceptedEventDraft acceptedEvent, + LocalDateTime now) { + workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( + sourceMessage.hotelId(), + orderTaskId, + sourceMessage.id(), + acceptedEvent.aiTransitionId(), + cardType(acceptedEvent.eventType()), + acceptedEvent.eventType(), + acceptedEvent.sourceEventIndex(), + cardSortOrder(acceptedEvent.eventType()), + cardStatus(acceptedEvent.manualReviewRequired()), + reviewStatus(acceptedEvent.manualReviewRequired()), + acceptedEvent.aiPayloadJson(), + acceptedEvent.displayPayloadJson(), + null, + now + )); + } + + /** + * 按 order_ref 保留 V4 event 原始数组顺序。 + */ + private Map> groupEventsByOrderRef( + List acceptedEvents) { + Map> grouped = new LinkedHashMap<>(); + for (ReservationV4AcceptedEventDraft event : acceptedEvents) { + grouped.computeIfAbsent(event.orderRef(), ignored -> new ArrayList<>()).add(event); + } + return grouped; + } + + /** + * 从同一 order_ref 下选择订单任务绑定来源:优先使用本系统当前能本地解析的 target,其次保留完整但待复核的 target。 + */ + private ReservationV4AcceptedEventDraft selectOrderTaskBindingEvent( + List orderEvents) { + for (ReservationV4AcceptedEventDraft event : orderEvents) { + if (isLocallyResolvableTarget(event)) { + return event; + } + } + for (ReservationV4AcceptedEventDraft event : orderEvents) { + if (hasCompleteTargetOrder(event)) { + return event; + } + } + return orderEvents.get(0); + } + + /** + * 第一版只把本系统当前可直接定位的 Group Code / Confirmation Number 视为已解析。 + */ + private String targetResolutionStatus(ReservationV4AcceptedEventDraft event) { + return isLocallyResolvableTarget(event) && event.orderId() != null + ? ReservationV4TargetResolutionStatus.RESOLVED.name() + : ReservationV4TargetResolutionStatus.UNRESOLVED.name(); + } + + /** + * V4 order_task 的 order_id 只保存真实可定位的本地订单,Booking Code 等待后续复核 / 绑定接口确认。 + */ + private Long localOrderId(ReservationV4AcceptedEventDraft event) { + return isLocallyResolvableTarget(event) ? event.orderId() : null; + } + + /** + * 判断 target_order 三元组是否完整。 + */ + private boolean hasCompleteTargetOrder(ReservationV4AcceptedEventDraft event) { + return hasText(event.targetBookingType()) + && hasText(event.targetLocatorType()) + && hasText(event.targetLocatorValue()); + } + + /** + * 判断当前后端是否能不经人工复核直接解析目标订单归属。 + */ + private boolean isLocallyResolvableTarget(ReservationV4AcceptedEventDraft event) { + if (!hasCompleteTargetOrder(event)) { + return false; + } + return "GROUP_CODE".equals(event.targetLocatorType()) + || "CONFIRMATION_NUMBER".equals(event.targetLocatorType()); + } + + /** + * 生成来源邮件展示卡的附件安全摘要,不在普通卡片展示 payload 中保存附件 URL。 + */ + private ArrayNode attachmentSummaries(JsonNode attachments) { + ArrayNode summaries = objectMapper.createArrayNode(); + if (attachments == null || !attachments.isArray()) { + return summaries; + } + for (JsonNode attachment : attachments) { + ObjectNode summary = objectMapper.createObjectNode(); + summary.put("id", textAt(attachment, "id")); + summary.put("name", textAt(attachment, "name")); + summary.put("content_type", textAt(attachment, "content_type")); + if (attachment.path("size").isNumber()) { + summary.put("size", attachment.path("size").asLong()); + } else { + summary.putNull("size"); + } + summaries.add(summary); + } + return summaries; + } + + /** + * 根据 V4 event_type 映射 V4 卡片类型。 + */ + private String cardType(String eventType) { + return switch (nullToEmpty(eventType)) { + case "NEW_BOOKING", "UPDATE_BOOKING", "CANCEL_BOOKING" -> ReservationV4CardType.ROOM_INFORMATION.name(); + case "TRACE_RESERVATION_NOTES" -> ReservationV4CardType.TRACE_RESERVATION_NOTES.name(); + case "ROOMING_LIST" -> ReservationV4CardType.ROOMING_LIST.name(); + case "PAYMENT" -> ReservationV4CardType.PAYMENT.name(); + default -> throw new IllegalArgumentException("V4 event_type 暂不支持创建任务卡:" + eventType); + }; + } + + /** + * 根据 V4 event_type 返回同一订单任务内的固定展示顺序。 + */ + private int cardSortOrder(String eventType) { + return switch (nullToEmpty(eventType)) { + case "NEW_BOOKING", "UPDATE_BOOKING", "CANCEL_BOOKING" -> ROOM_INFORMATION_CARD_SORT_ORDER; + case "TRACE_RESERVATION_NOTES" -> TRACE_CARD_SORT_ORDER; + case "ROOMING_LIST" -> ROOMING_LIST_CARD_SORT_ORDER; + case "PAYMENT" -> PAYMENT_CARD_SORT_ORDER; + default -> throw new IllegalArgumentException("V4 event_type 暂不支持排序:" + eventType); + }; + } + + /** + * 根据是否需要人工复核生成卡片处理状态。 + */ + private String cardStatus(boolean manualReviewRequired) { + return manualReviewRequired + ? ReservationV4CardStatus.REVIEW_REQUIRED.name() + : ReservationV4CardStatus.PENDING_CONFIRM.name(); + } + + /** + * 根据是否需要人工复核生成复核状态。 + */ + private String reviewStatus(boolean manualReviewRequired) { + return manualReviewRequired ? ReservationReviewStatus.PENDING.name() : null; + } + + /** + * 统一来源邮件接收时间;历史数据缺失时用当前 UTC 时间兜底。 + */ + private LocalDateTime sourceReceivedAt(SourceMessageInboxSnapshot sourceMessage, LocalDateTime now) { + return sourceMessage.receivedAt() == null ? now : sourceMessage.receivedAt(); + } + + /** + * 将 V4 卡片或通知 payload 安全序列化为 JSON 字符串。 + */ + private String nodeJson(JsonNode node) { + try { + return objectMapper.writeValueAsString(node == null || node.isMissingNode() + ? objectMapper.createObjectNode() + : node); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("V4 入站 JSON 序列化失败。", exception); + } + } + + /** + * 从对象节点读取文本字段,缺失或 null 时返回 null。 + */ + private String textAt(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode() || node.get(fieldName) == null || node.get(fieldName).isNull()) { + return null; + } + return node.get(fieldName).asText(); + } + + /** + * 判断 JSON 布尔节点是否显式为 true。 + */ + private boolean isBooleanTrue(JsonNode node) { + return node != null && node.isBoolean() && node.booleanValue(); + } + + /** + * 判断字符串是否有有效文本。 + */ + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + /** + * switch 前把空字符串统一为安全默认值。 + */ + private String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 2c64f7e..e01f64e 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -1030,6 +1030,172 @@ class SuperAgentTaskResultControllerTest { AND ai_payload_json LIKE '%"v4_message_event"%' """, Long.class, source.inboxId()); assertThat(transitionCount).isEqualTo(2L); + + Long v4OrderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + AND order_ref = 'order-1' + AND order_context_index = 1 + AND target_booking_type = 'GROUP' + AND target_locator_type = 'GROUP_CODE' + AND target_locator_value = 'GRP-V4-001' + AND target_resolution_status = 'RESOLVED' + AND order_task_status = 'OPEN' + """, Long.class, source.inboxId()); + assertThat(v4OrderTaskCount).isEqualTo(1L); + + List v4CardTypes = jdbcTemplate.queryForList(""" + SELECT card_type + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + ORDER BY card_sort_order, source_event_index + """, String.class, source.inboxId()); + assertThat(v4CardTypes).containsExactly( + "SOURCE_MESSAGE_DISPLAY", + "BASIC_INFORMATION", + "ROOM_INFORMATION", + "PAYMENT"); + + Long v4CardStatusCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND ( + (card_type = 'SOURCE_MESSAGE_DISPLAY' AND card_status = 'READONLY' AND source_event_index = 0) + OR (card_type = 'BASIC_INFORMATION' AND card_status = 'PENDING_CONFIRM' AND source_event_index = 0) + OR (card_type = 'ROOM_INFORMATION' AND card_status = 'PENDING_CONFIRM' AND source_event_index = 1) + OR (card_type = 'PAYMENT' AND card_status = 'PENDING_CONFIRM' AND source_event_index = 2) + ) + """, Long.class, source.inboxId()); + assertThat(v4CardStatusCount).isEqualTo(4L); + + String sourceDisplayPayload = jdbcTemplate.queryForObject(""" + SELECT display_payload_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'SOURCE_MESSAGE_DISPLAY' + LIMIT 1 + """, String.class, source.inboxId()); + assertThat(sourceDisplayPayload) + .contains("att-pay-1") + .contains("payment-slip.jpg") + .doesNotContain("https://oss.example.test"); + + mockMvc.perform(signedPost(body, "nonce-v4-business-root-replay-001")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.idempotent_replay").value(true)); + Long replayOrderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long replayCardCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(replayOrderTaskCount).isEqualTo(1L); + assertThat(replayCardCount).isEqualTo(4L); + } + + @Test + void shouldCreateV4OrderTasksForMultipleOrderRefs() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-two-order-refs-001"); + String body = v4TwoOrderRefsBody("mail-v4-two-order-refs-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-two-order-refs-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)) + .andExpect(jsonPath("$.items[0].execution_order").value(1)) + .andExpect(jsonPath("$.items[1].execution_order").value(1)); + + List orderRefs = jdbcTemplate.queryForList(""" + SELECT order_ref + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + ORDER BY order_context_index + """, String.class, source.inboxId()); + Long resolvedOrderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + AND target_resolution_status = 'RESOLVED' + """, Long.class, source.inboxId()); + Long cardCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(orderRefs).containsExactly("order-1", "order-2"); + assertThat(resolvedOrderTaskCount).isEqualTo(2L); + assertThat(cardCount).isEqualTo(6L); + } + + @Test + void shouldKeepV4BookingCodeOrderTaskUnresolvedUntilBindingCheckpoint() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-fit-booking-code-001"); + String body = v4FitBookingCodeBody("mail-v4-fit-booking-code-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-fit-booking-code-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].order_status").value("TEMPORARY")); + + Long unresolvedCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + AND target_locator_type = 'BOOKING_CODE' + AND target_locator_value = 'BK-V4-FIT-001' + AND target_resolution_status = 'UNRESOLVED' + AND order_id IS NULL + """, Long.class, source.inboxId()); + assertThat(unresolvedCount).isEqualTo(1L); + } + + @Test + void shouldChooseResolvedV4TargetWhenEarlierEventIsUnresolved() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unresolved-then-resolved-001"); + String body = v4UnresolvedThenResolvedBody("mail-v4-unresolved-then-resolved-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-unresolved-then-resolved-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)) + .andExpect(jsonPath("$.items[0].route_code").value("R04_UPDATE_BOOKING_AMENDMENT_REVIEW")) + .andExpect(jsonPath("$.items[1].route_code").value("R10_PAYMENT_EVIDENCE_NORMAL")); + + Long resolvedCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + AND order_ref = 'order-1' + AND target_locator_type = 'GROUP_CODE' + AND target_locator_value = 'GRP-V4-RESOLVED-LATER-001' + AND target_resolution_status = 'RESOLVED' + AND order_id IS NOT NULL + """, Long.class, source.inboxId()); + assertThat(resolvedCount).isEqualTo(1L); + } + + @Test + void shouldMarkV4BasicInformationCardReviewRequired() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-basic-review-001"); + String body = v4BasicInformationReviewBody("mail-v4-basic-review-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-basic-review-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)); + + Long basicReviewCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'BASIC_INFORMATION' + AND card_status = 'REVIEW_REQUIRED' + AND review_status = 'PENDING' + """, Long.class, source.inboxId()); + assertThat(basicReviewCount).isEqualTo(1L); } @Test @@ -1080,10 +1246,23 @@ class SuperAgentTaskResultControllerTest { ) """, Long.class, source.inboxId()); assertThat(transitionCount).isEqualTo(3L); + + List v4CardTypes = jdbcTemplate.queryForList(""" + SELECT card_type + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + ORDER BY card_sort_order, source_event_index + """, String.class, source.inboxId()); + assertThat(v4CardTypes).containsExactly( + "SOURCE_MESSAGE_DISPLAY", + "BASIC_INFORMATION", + "ROOM_INFORMATION", + "TRACE_RESERVATION_NOTES", + "ROOMING_LIST"); } @Test - void shouldCreateReadOnlyTaskForV4S10WithoutLegacyResultType() throws Exception { + void shouldCreateSourceNotificationForV4S10WithoutLegacyTask() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v4-s10-entry-result-001"); String body = v4S10Body("mail-v4-s10-entry-result-001"); @@ -1094,17 +1273,63 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.items[0].route_code").value("S10")) .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")) .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) - .andExpect(jsonPath("$.items[0].task_status").value("COMPLETED")); + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()) + .andExpect(jsonPath("$.items[0].task_status").doesNotExist()); Long transitionCount = jdbcTemplate.queryForObject(""" SELECT COUNT(*) FROM workflow_reservation_ai_transition WHERE source_message_id = ? AND result_type = 'source_message_review_notification' - AND ai_task_type = 'S10' + AND ai_task_type = 'Message Notification' + AND task_subtype = 'S10' AND route_code = 'S10' """, Long.class, source.inboxId()); + Long legacyTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long sourceNotificationCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_source_notification + WHERE source_message_id = ? + AND route_code = 'S10' + AND notification_status = 'ACK_REQUIRED' + AND raw_payload_json LIKE '%"route_code":"S10"%' + """, Long.class, source.inboxId()); assertThat(transitionCount).isEqualTo(1L); + assertThat(legacyTaskCount).isZero(); + assertThat(sourceNotificationCount).isEqualTo(1L); + } + + @Test + void shouldCreateSourceNotificationForV4S99WithoutLegacyTask() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-s99-entry-result-001"); + String body = v4S99Body("mail-v4-s99-entry-result-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-s99-entry-result-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.source_message_id").value("mail-v4-s99-entry-result-001")) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].route_code").value("S99")) + .andExpect(jsonPath("$.items[0].system_process_category").value("SOURCE_MESSAGE_NOTIFICATION")) + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()); + + Long legacyTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long sourceNotificationCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_source_notification + WHERE source_message_id = ? + AND route_code = 'S99' + AND notification_status = 'ACK_REQUIRED' + """, Long.class, source.inboxId()); + assertThat(legacyTaskCount).isZero(); + assertThat(sourceNotificationCount).isEqualTo(1L); } @Test @@ -1131,8 +1356,20 @@ class SuperAgentTaskResultControllerTest { AND system_process_category = 'ADAPTER_CONTRACT_ERROR' AND adapter_error_code = 'V4_ROOT_CONTRACT_INVALID' """, Long.class, source.inboxId()); + Long v4OrderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + Long v4NotificationCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_source_notification + WHERE source_message_id = ? + """, Long.class, source.inboxId()); assertThat(taskCount).isZero(); assertThat(adapterErrorCount).isEqualTo(1L); + assertThat(v4OrderTaskCount).isZero(); + assertThat(v4NotificationCount).isZero(); } @Test @@ -1186,8 +1423,14 @@ class SuperAgentTaskResultControllerTest { AND system_process_category = 'ADAPTER_CONTRACT_ERROR' AND adapter_error_code = 'PAYMENT_ATTACHMENT_ID_NOT_FOUND' """, Long.class, source.inboxId()); + Long v4OrderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); assertThat(taskCount).isZero(); assertThat(adapterErrorCount).isEqualTo(1L); + assertThat(v4OrderTaskCount).isZero(); } @Test @@ -1218,6 +1461,17 @@ class SuperAgentTaskResultControllerTest { """, Long.class, source.inboxId()); assertThat(taskCount).isEqualTo(1L); assertThat(adapterErrorCount).isEqualTo(1L); + + List v4CardTypes = jdbcTemplate.queryForList(""" + SELECT card_type + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + ORDER BY card_sort_order, source_event_index + """, String.class, source.inboxId()); + assertThat(v4CardTypes).containsExactly( + "SOURCE_MESSAGE_DISPLAY", + "BASIC_INFORMATION", + "ROOM_INFORMATION"); } @Test @@ -3509,6 +3763,243 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId); } + private String v4TwoOrderRefsBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-two-order-refs-001", + "subject": "Two order refs", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please create one group and one FIT booking.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + }, + { + "order_ref": "order-2", + "basic_information": { + "account_code": "LIAN_TAI", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-MULTI-001" + }, + "arrival_date": "2026-08-01", + "departure_date": "2026-08-03", + "rate_code": "BAR", + "booking_scenario": "STANDARD", + "room_items": [ + { + "room_type_code": "TWN", + "room_count": 2 + } + ], + "manual_review": null + }, + { + "order_ref": "order-2", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "FIT", + "locator_type": "CONFIRMATION_NUMBER", + "locator_value": "CNF-V4-MULTI-002" + }, + "arrival_date": "2026-08-05", + "departure_date": "2026-08-06", + "rate_code": "BAR", + "booking_scenario": "STANDARD", + "guest_name": "TEST GUEST", + "room_items": [ + { + "room_type_code": "KING", + "room_count": 1 + } + ], + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + + private String v4FitBookingCodeBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-fit-booking-code-001", + "subject": "FIT booking code", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please create or locate FIT booking by booking code.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "FIT", + "locator_type": "BOOKING_CODE", + "locator_value": "BK-V4-FIT-001" + }, + "arrival_date": "2026-08-05", + "departure_date": "2026-08-06", + "rate_code": "BAR", + "booking_scenario": "STANDARD", + "guest_name": "BOOKING CODE GUEST", + "room_items": [ + { + "room_type_code": "KING", + "room_count": 1 + } + ], + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + + private String v4UnresolvedThenResolvedBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-unresolved-then-resolved-001", + "subject": "Unresolved event followed by payment", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please update something; payment evidence attached.", + "body_content_type": "text/plain", + "attachments": [ + { + "id": "att-pay-1", + "name": "payment-slip.jpg", + "content_type": "image/jpeg", + "url": "https://oss.example.test/payment-slip.jpg", + "size": 251524 + } + ] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "UPDATE_BOOKING", + "target_order": { + "booking_type": null, + "locator_type": null, + "locator_value": null + }, + "after": { + "arrival_date": "2026-08-07" + }, + "manual_review": true + }, + { + "order_ref": "order-1", + "event_type": "PAYMENT", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-RESOLVED-LATER-001" + }, + "attachment_ids": ["att-pay-1"], + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + + private String v4BasicInformationReviewBody(String externalSourceMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-basic-review-001", + "subject": "Basic information needs review", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Account code is not clear.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": null, + "manual_review": true + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-V4-BASIC-REVIEW-001" + }, + "arrival_date": "2026-08-01", + "departure_date": "2026-08-03", + "rate_code": "BAR", + "booking_scenario": "STANDARD", + "room_items": [ + { + "room_type_code": "TWN", + "room_count": 2 + } + ], + "manual_review": null + } + ] + } + """.formatted(externalSourceMessageId); + } + private String v4CancelTraceRoomingListBody(String externalSourceMessageId) { return """ { @@ -3595,6 +4086,26 @@ class SuperAgentTaskResultControllerTest { """.formatted(externalSourceMessageId); } + private String v4S99Body(String externalSourceMessageId) { + return """ + { + "route_code": "S99", + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-v4-s99-001", + "subject": "Cannot form material package", + "sender": "guest@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "The input does not contain enough business material.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [], + "message_events": [] + } + """.formatted(externalSourceMessageId); + } + private String v4MalformedMissingOrderContextsBody(String externalSourceMessageId) { return """ {