From 1d593f05fd4be8ab2adb65221e5c8478b0bffcf7 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 21 Jul 2026 00:58:10 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DV4=E6=88=BF=E5=9E=8B=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=B1=95=E7=A4=BA=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 18 +- docs/project/README.md | 4 +- .../backend-to-frontend-notes.md | 16 +- .../frontend-to-backend-api-requests.md | 4 +- ...002-v4-order-task-card-domain-model-cp2.md | 16 +- .../ReservationFrontendQueryServiceImpl.java | 4 + .../impl/ReservationV4CommandServiceImpl.java | 750 +++++++++++++++++- .../impl/ReservationV4QueryServiceImpl.java | 523 +++++++++++- ...eservationFrontendQueryControllerTest.java | 19 +- .../ReservationV4CommandControllerTest.java | 318 ++++++++ .../ReservationV4QueryControllerTest.java | 512 +++++++++++- 11 files changed, 2122 insertions(+), 62 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 655b33a..7787576 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2,18 +2,18 @@ | 项 | 内容 | | --- | --- | -| 最近更新 | 2026-07-20 | +| 最近更新 | 2026-07-21 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径、Room Information 展示模型文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 | -| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 Room Information 卡下一阶段按 New / Update / Cancel 业务展示模型展示,后端派生 Nights、Breakfast 和最终值,Update 展示当前值到修改后值的差异,Cancel 展示本地订单投影只读,Group Booking Status 使用 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`,Rooming List 确认时 Group 自动变 `DEF`;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Room Information 展示模型、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型第一版、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 | +| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject,避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 后端展示模型第一版已实现:`GET /api/reservation/order-tasks/{orderTaskId}` 在 `display_payload.room_information` 返回 New / Update / Cancel 的 `current_values`、`proposed_values`、`final_values`、`change_summary[]`,后端派生 Nights、Breakfast 和 Group Booking Status;`fields[]` 统一返回 `/room_information/final_values/...` 可编辑白名单,确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`(GROUP / FIT)过滤和校验,不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;Rooming List 确认时 Group 自动变 `DEF` 仍未实现;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、前端 Room Information 业务化展示、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-card-requirements-doc-tightening` -- 状态:Docs Ready,已确认 V4 任务卡排序、复核态交互、可编辑字段白名单、Room Information 展示模型、Payment 附件预览、Rooming List 事项确认和 Rate Code 过滤口径,后续待后端和前端实现。 -- 目标:把 V4 任务详情页从通用 JSON 字段展示收口为可验收的业务卡交互:Basic Information 在顶部、业务卡居中、SourceMessage Display 在底部;`REVIEW_REQUIRED` 仍在原业务卡内编辑和确认;Room Information、Trace、Rooming List、Payment 分别按稳定业务字段渲染。 -- 边界:本 checkpoint 只落文档,不改业务代码;SuperAgent 不新增 Nights、Breakfast、Group Booking Status、Adult、Block ID 或 Confirmation Number 输出;Adult 第一版不在 Room Information 卡显示;Block ID / Confirmation Number 继续等待 PMS 或本地投影来源;`target_order.locator_value` 不直接编辑,但 New Booking 的最终订单投影字段 `group_block_name` / `fit_name` 允许在业务卡中编辑;Payment 第一版 `attachment_ids[]` 只读展示并确认,不支持前端增删或替换附件集合。 -- 联调备注:后端后续应在 V4 任务详情中补 Room Information 展示模型、复核态 `fields[]` 白名单、Payment 附件安全摘要和 Account 范围 Rate Code lookup / 校验;前端按后端 `fields[]` 渲染可编辑控件,复核态按钮文案统一为“确认卡片”,但内部调用 `review-resolution`。 +- 名称:`M002-V4-room-information-display-model-backend` +- 状态:Backend Done,已实现 Room Information 后端稳定展示模型、确认快照派生、复核 pointer 白名单和订单详情总览读取兼容。 +- 目标:让 V4 Room Information 卡不再依赖前端解析 Agent raw payload,而由后端在 `display_payload.room_information` 返回 New / Update / Cancel 的业务展示模型和 `fields[]` 可编辑白名单。 +- 边界:本 checkpoint 不做 Account + booking type Rate Code 适用关系、不做 Payment 附件预览、不做 Rooming List Excel、不做真实 PMS / OPERA / OHIP、不推进 M011 CP4;Rooming List 确认时 Group 自动置 `DEF` 仍后置。 +- 联调备注:前端 Room Information 页面应优先读取 `display_payload.room_information.final_values`,确认提交 `confirmed_payload.room_information.final_values`,复核提交 `field_overrides[].field_pointer=/room_information/final_values/...`;`target_order`、Nights、Adult、Block ID、Confirmation Number 等只读 / 派生字段不要提交。 ## 2. 当前优先级 @@ -36,7 +36,7 @@ - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 - M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel,按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSON;Debug EML 和 AgentBus dispatch 在各自 include 开关与总开关同时启用时,会在调用 SuperAgent 前追加 `attachment_extractions[]`。测试机 AgentBus 增强已开启;生产链路仍默认关闭,生产开启需单独确认。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`。Account + booking type 过滤 Rate Code、Payment 附件预览、复核态卡片字段白名单均已作为下一阶段文档口径确认,但后端 lookup / 校验、Payment 附件安全摘要、复核态整卡业务字段编辑和前端联动尚未实现;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;Room Information 后端展示模型第一版已完成。Account + booking type 过滤 Rate Code、Payment 附件预览均已作为下一阶段文档口径确认,但后端 lookup / 校验、Payment 附件安全摘要和前端联动尚未实现;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 - 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 必须先确认;Rooming List 卡第一版只做事项确认;Account / Market / Source 当前通过数据库目录读取和派生;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps diff --git a/docs/project/README.md b/docs/project/README.md index f3732f8..a6e4084 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -46,7 +46,7 @@ | `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/S99 和校验口径;后端已完成 V4 入站解析、持久化、查询、确认、复核和当前酒店数据库目录校验。 | -| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3-CP8 表结构、入站写入、查询、确认、复核和目录校验已落地状态;CP11 已完成 DB 目录与 lookup API,CP12 已完成前端 lookup 接入,CP13 已完成目录管理后台 CP1,CP14 已完成订单列表 V4 继续处理入口,CP15 已完成 V4 业务审计查询,CP15.1 已完成订单详情 V4 总览后端补齐,当前已停止 V4 普通业务双写旧 `workflow_reservation_task`;已补 Account 范围 Rate Code、Payment 附件预览、Rooming List 事项确认卡、Room Information 展示模型、V4 复核态卡片交互和可编辑字段白名单文档口径;开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后置。 | +| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3-CP8 表结构、入站写入、查询、确认、复核和目录校验已落地状态;CP11 已完成 DB 目录与 lookup API,CP12 已完成前端 lookup 接入,CP13 已完成目录管理后台 CP1,CP14 已完成订单列表 V4 继续处理入口,CP15 已完成 V4 业务审计查询,CP15.1 已完成订单详情 V4 总览后端补齐,当前已停止 V4 普通业务双写旧 `workflow_reservation_task`,并已完成 Room Information 后端展示模型第一版;已补 Account 范围 Rate Code、Payment 附件预览、Rooming List 事项确认卡、V4 复核态卡片交互和可编辑字段白名单文档口径;开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后置。 | | `requirements/M002-v4-real-catalog-lookup-api-design.md` | 当前有效 | M002 V4 真实目录与 Lookup API 设计及 CP11 / CP13 CP1 实现记录,记录 Account、Market、Source、Room Type、Rate Code 从固定种子导入数据库、前端 lookup API、目录管理后端接口、权限、缓存后置、PMS / OPERA / OHIP 同步后置和失败兜底;已新增 Account + booking type 过滤 Rate Code 的下一阶段契约,房型 / 日期 / 价格规则后置。 | | `requirements/M002-v4-test-machine-smoke-checklist.md` | 当前有效 | M002 V4 测试机冒烟清单,覆盖登录、酒店权限、V4 工作台、订单任务详情、lookup、确认、复核解阻、S10/S99 ack、订单详情 V4 时间线和目录管理 CP1 排查点。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | @@ -100,6 +100,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 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,CP5 已落地工作台、订单任务和来源通知查询接口,CP6/CP7 已落地卡片确认、S10/S99 ack 和复核解阻,CP11 已落地 DB 目录与 lookup API,CP12 已落地前端 lookup 接入,CP13 已落地目录管理后台 CP1,CP14 已落地订单列表 V4 继续处理入口,CP15 已落地 V4 业务审计查询,CP15.1 已落地订单详情 V4 总览后端补齐且前端已接入;已确认 Rooming List 卡第一版只做事项确认,Room Information 卡下一阶段按 New / Update / Cancel 展示模型实现,`REVIEW_REQUIRED` 保持原业务卡内编辑并统一显示“确认卡片”;后续仍需 Room Information 展示模型、Payment 附件预览、Account 范围 Rate Code lookup / 校验、复核态字段白名单实现、测试机联调和真实 PMS / OPERA / OHIP 同步。 +- 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 来源通知入站写入新模型,CP5 已落地工作台、订单任务和来源通知查询接口,CP6/CP7 已落地卡片确认、S10/S99 ack 和复核解阻,CP11 已落地 DB 目录与 lookup API,CP12 已落地前端 lookup 接入,CP13 已落地目录管理后台 CP1,CP14 已落地订单列表 V4 继续处理入口,CP15 已落地 V4 业务审计查询,CP15.1 已落地订单详情 V4 总览后端补齐且前端已接入,Room Information 后端展示模型第一版已落地;已确认 Rooming List 卡第一版只做事项确认,`REVIEW_REQUIRED` 保持原业务卡内编辑并统一显示“确认卡片”;后续仍需前端接入 Room Information 展示模型、Payment 附件预览、Account 范围 Rate Code lookup / 校验、测试机联调和真实 PMS / OPERA / OHIP 同步。 - V3 / 旧任务前端展示和编辑字段仍以 2026-07-11 P0 冻结基线中的前端字段表、0712 字段控件说明和 `requirements/M002-task-field-control-contract-v1.md` 为白名单和控件契约基线;V4 订单任务前端展示和编辑字段以 `requirements/M002-v4-order-task-card-domain-model-cp2.md`、后端返回的 `fields[]` 和 V4 前后端协作文档为准。 - 时间点语义以 `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 8ecca5f..b5ed64c 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -56,7 +56,7 @@ | `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/workbench-items` | 查询 V4 工作台统一列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;返回 V4 业务订单任务和 S10/S99 来源通知混排摘要;支持 `hotel_id`、`item_type`、`keyword`、`page_num`、`page_size`;默认按 `source_received_at` 倒序,同一来源时间下按 `updated_at`、`created_at`、数字 `target_id` 倒序;列表不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 | | `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id`、`order_id`、`order_task_status`、`card_status`、`keyword`、`page_num`、`page_size`;`order_task_status` 非 `OPEN` / `COMPLETED` 返回 400,`card_status` 非 V4 卡状态返回 400;`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 | -| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面最下方,正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡下一阶段可返回 `payment_attachments[]` 安全摘要用于展示凭证附件,但本接口不得返回附件 URL;图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。 | +| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面最下方,正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡下一阶段可返回 `payment_attachments[]` 安全摘要用于展示凭证附件,但本接口不得返回附件 URL;图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导业务展示。 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id` 和 `items[]`。`items[]` 用于展示 V4 卡片确认、复核解阻和订单归属确认轨迹,只包含脱敏后的审计摘要,不包含邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | | `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 S10/S99 来源通知审计流水 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`,后端按来源通知实际酒店校验访问权;返回 `notification_id` 和 `items[]`。`items[]` 第一版用于展示来源通知 ack 记录,只包含脱敏后的审计摘要。 | @@ -72,8 +72,8 @@ 前端已在系统设置下新增 `/system/reservation-catalogs` 消费上述目录管理接口。页面入口要求 `RESERVATION_CATALOG_MANAGE`,列表过滤直接传 `hotel_id`、`keyword`、`status`、`page_num`、`page_size`;Account 新增第一版固定提交 `market_code=LEISURE`、`source_code=TRAVEL_AGENT`;状态重复提交按成功提示处理,不额外弹失败。 -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code,下一阶段 `rate_code` 还必须属于已确认 Account + 当前业务 event `booking_type` 的适用范围;嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;`field_overrides[]` 只允许当前卡 `fields[]` 白名单内可编辑业务字段,问题字段可按 `fields[].validation_errors` 红字提示;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;Room Information 卡优先提交 `confirmed_payload.room_information.final_values`,后端写入稳定确认快照并重新派生 Nights / Breakfast / Group Booking Status 文案,不写 Agent `target_order`、Adult、邮件正文、附件 URL 或前端注入字段;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code,下一阶段 `rate_code` 还必须属于已确认 Account + 当前业务 event `booking_type` 的适用范围;Room Information 新结构错误路径形如 `room_information.final_values.room_items.0.room_type_code`,历史兼容结构可能返回如 `business_fields.after.room_items.0.room_type_code`,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;`field_overrides[]` 只允许当前卡 `fields[]` 白名单内可编辑业务字段,问题字段可按 `fields[].validation_errors` 红字提示;Room Information 复核优先使用 `/room_information/final_values/...` pointer,不允许指向 `nights`、`target_order`、Adult、Block ID、Confirmation Number 等只读 / 派生字段;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 | | `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` / `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 业务审计展示数据 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`;前端可在 V4 订单任务详情和 S10/S99 来源通知详情的“审计时间线”中调用。响应沿用旧审计行结构:`audit_id`、`actor_type`、`actor_id`、`action`、`reason`、`before_snapshot`、`after_snapshot`、`occurred_at`。快照已由后端脱敏,前端仍不要把未知 URL-like 字符串当附件或正文直渲。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取轻量摘要,此时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都为空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`;旧 `tasks[]` 按后端队列顺序返回,前端不要自行按创建时间重排;V4 `v4_order_tasks[]` 按同订单 V4 订单任务来源时间正序返回;隐藏技术订单详情不可作为普通订单页打开。 | @@ -526,7 +526,7 @@ 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 回调接口。 - M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口;M002 V4 CP7 已开放 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻接口;M002 V4 CP8 已开放 V4 卡片 `fields[]` 白名单和目录校验;M002 V4 CP11 已把固定种子迁移到数据库目录,并开放 Account / Room Type / Rate Code lookup API;目录管理后台 CP1 已开放 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口。 - V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染;前端对来源消息卡附件字段仍做 URL-like 文本兜底脱敏。 -- Room Information 卡下一阶段应由后端返回业务展示模型,前端不要自行从 Agent raw payload 计算。`NEW_BOOKING` 展示最终值,Group 的最终订单投影字段 `group_block_name` 可编辑,默认来自 `target_order.locator_value` 且 `locator_type=GROUP_CODE`;Fit 的最终订单投影字段 `fit_name` 可编辑,默认来自 `guest_name ?? target_order.locator_value`;Agent 原始 `target_order.locator_value` 始终只读,用户编辑只影响本系统最终订单投影和确认快照。`UPDATE_BOOKING` 展示本地当前值到 Agent 修改后值的 `change_summary[]`,日期变化时连带展示 Nights 差异,字段区展示合并后的最终值;`CANCEL_BOOKING` 从本地订单投影只读展示。Nights 由后端按酒店本地日期派生,Adult 不显示。 +- Room Information 卡已由后端返回业务展示模型,前端不要自行从 Agent raw payload 计算。读取路径是业务卡 `display_payload.room_information`:`NEW_BOOKING` 展示最终值,Group 的最终订单投影字段 `group_block_name` 可编辑,默认来自 `target_order.locator_value` 且 `locator_type=GROUP_CODE`;Fit 的最终订单投影字段 `fit_name` 可编辑,默认来自 `guest_name ?? target_order.locator_value`;Agent 原始 `target_order.locator_value` 始终只读,用户编辑只影响本系统最终订单投影和确认快照。`UPDATE_BOOKING` 展示本地当前值到 Agent 修改后值的 `change_summary[]`,日期变化时连带展示 Nights 差异,字段区展示合并后的最终值;`CANCEL_BOOKING` 从本地订单投影只读展示。Nights 由后端按酒店本地日期派生,Adult 不显示。 - Room Information 卡 Breakfast 前端显示为“含早”勾选框:Group 固定勾选且只读;Fit 由后端按最终 Rate Code 中 `RB` / `RO` 派生,无法派生时作为必填勾选项。Group Booking Status 仅 Group 显示,稳定 code 为 `TEN` / `DEF` / `INQ`,展示文案为 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`;New Group 默认 `TEN`,`NEW_BOOKING` / `UPDATE_BOOKING` 确认前可改选,`CANCEL_BOOKING` 只读。 - Rooming List 卡确认存在跨卡联动:同订单为 Group 时,确认 `ROOMING_LIST` 后后端应把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN` 或 `INQ`;Fit 不显示也不变更该状态。该自动变更需由后端写审计,前端只展示刷新后的状态。 - Rooming List 卡第一版是轻量事项确认卡:前端展示卡片标题、状态、目标订单信息和“确认卡片”按钮即可;不要做名单 rows、附件预览、Excel 生成或 PMS 导入入口。确认仅表示该 Rooming List 事项已人工处理。 @@ -539,10 +539,10 @@ RESERVATION_ROOMING_LIST_GENERATE - M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认;M002 V4 CP8 已开放目录校验和 V4 任务卡 `fields[]` 字段白名单;M002 V4 CP11 已新增目录表、数据库种子和 lookup API;目录管理后台 CP1 已新增 `RESERVATION_CATALOG_MANAGE` 后端接口;V4 审计查询已开放 `RESERVATION_AUDIT_READ` 下的订单任务审计和来源通知审计。 - V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false`、`editable=true`、`confirmable=false`、`reviewable=true`、`readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`。 - CP11 起 V4 入站阶段按当前酒店数据库目录做校验:Account 缺失或不存在时 Basic Information 卡直接 `REVIEW_REQUIRED`;业务卡已有 `room_items[].room_type_code` 或 `rate_code` 但不在当前酒店目录时,业务卡也会直接 `REVIEW_REQUIRED`,错误会回显在 `fields[].validation_errors`。 -- CP8 确认接口也按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order`、`order_ref`、`manual_review`、校验诊断字段以及前端额外注入字段不会写入 `confirmed_payload_json`。 -- 业务卡目录校验会递归检查 `business_fields` 下的嵌套结构。例如 `UPDATE_BOOKING` 的房型可能位于 `/business_fields/after/room_items/0/room_type_code`,错误详情会使用 `business_fields.after.room_items.0.room_type_code`;前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]`。 -- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/business_fields/room_items/0/pms_room_type_code","value":"RM2"}]}`。`confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID,不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段,也不能替换整个对象 / 数组。 -- V4 `fields[]` 第一版字段说明:Basic Information 固定返回 `/basic_information/account_code`、`/basic_information/market_code`、`/basic_information/source_code`;其中 Account `control_type=select`、`options_source=reservation_v4_account_catalog`,Market / Source 为只读派生字段。业务卡会按展示 payload 里的业务叶子字段返回字段白名单,例如 `/room_items/0/room_type_code` 或 `/business_fields/room_items/0/pms_room_type_code`;前端不要自行补未返回字段。 +- CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order`、`order_ref`、`manual_review`、校验诊断字段以及前端额外注入字段不会写入 `confirmed_payload_json`。 +- 业务卡目录校验会递归检查稳定模型或历史兼容结构。例如 Room Information 新结构的房型位于 `/room_information/final_values/room_items/0/room_type_code`,错误详情会使用 `room_information.final_values.room_items.0.room_type_code`;历史兼容 `UPDATE_BOOKING` 的房型可能仍使用 `business_fields.after.room_items.0.room_type_code`。前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]`。 +- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/room_information/final_values/room_items/0/room_type_code","value":"RM2"}]}`。`confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID,不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*`、`room_information.final_values.*` 或历史兼容 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段,也不能替换整个对象 / 数组。 +- V4 `fields[]` 第一版字段说明:Basic Information 固定返回 `/basic_information/account_code`、`/basic_information/market_code`、`/basic_information/source_code`;其中 Account `control_type=select`、`options_source=reservation_v4_account_catalog`,Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date`、`/room_information/final_values/room_items/0/room_type_code`;前端不要自行补未返回字段。 - V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts`、`GET /api/reservation/lookups/room-types`、`GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象;后端确认前仍会重新校验目录。Rate Code 下一阶段依赖 Account + `booking_type`:前端需在 Account 已选 / 已确认且能取得当前业务 event `booking_type` 后再请求 Rate Code,Account 改变后清空或重新校验已选 Rate Code;缺失条件时禁用或空态,不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source`、`catalog_version` 和 `warnings[]`。 - M002 V4 CP12 前端已接入上述三个 lookup API:V4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项,空 `items[]`、`stale=true` 和 `warnings[]` 作为非阻塞提示展示;Account 选择后只展示目录返回的 `market_code` / `source_code` 辅助确认,确认 / 复核请求仍只提交用户选择的 code。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 7df1137..fc2f9d2 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -33,7 +33,7 @@ | `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 | | `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段、V4 总览和 V4 `v4_order_tasks[]` 时间线,前端订单详情总览页已接入 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。 | | `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 当前 Controller 不接收 `hotel_id`;如后续多酒店隔离需要前端显式传酒店上下文,请后端补可选入参或确认按 taskId 全局唯一即可。 | -| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 展示模型、复核态字段白名单和 Payment 附件安全摘要待补齐 | 可以,但 Room Information 业务化展示、复核态整卡编辑和 Payment 预览需后端补模型 / 摘要后再完整联动 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡 `department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入。Room Information 下一阶段由后端返回业务展示模型:New 展示最终值,Update 展示 `change_summary[]` 和合并后的最终值,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,Group 默认来自 `target_order.locator_value` 且 `locator_type=GROUP_CODE`,Fit 默认来自 `guest_name ?? target_order.locator_value`,但 Agent 原始 `target_order.locator_value` 只读且不被用户编辑回写。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡下一阶段建议返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 | +| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 后端展示模型已补齐;复核态字段白名单第一版已随 `fields[]` 返回;Payment 附件安全摘要仍待补齐 | 可以;Room Information 可进入前端业务化展示,Payment 预览仍需后端补摘要后再完整联动 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡 `department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入。Room Information 已由后端返回 `display_payload.room_information`:New 展示最终值,Update 展示 `current_values`、`proposed_values`、`final_values` 和 `change_summary[]`,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,Group 默认来自 `target_order.locator_value` 且 `locator_type=GROUP_CODE`,Fit 默认来自 `guest_name ?? target_order.locator_value`,但 Agent 原始 `target_order.locator_value` 只读且不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡下一阶段建议返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 | | `GET /api/reservation/tasks/{taskId}/audits` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如审计查询需要酒店上下文隔离,请后端补可选入参。 | @@ -332,7 +332,7 @@ GET /api/reservation/orders/{orderId} | `result_type` / `ai_task_type` / `route_code` / `system_process_category` | V3 路由展示字段,和任务列表字段语义一致。 | | `order_overview` | V4 订单详情当前确认快照,只从已确认 V4 卡片派生;未确认 AI 建议不会进入这里。 | | `order_overview.account_code` / `account_name` / `market_code` / `source_code` | 来自已确认 Basic Information 卡;为空表示 Basic Information 尚未确认或无可靠确认值。 | -| `order_overview.arrival_date` / `departure_date` / `rate_code` / `room_items[]` | 来自已确认 Room Information 卡;后出现的已确认卡会覆盖前面同字段。下一阶段 Room Information 展示模型实现后,可继续从确认快照派生 `nights`、`breakfast_included` 和 Group Booking Status。 | +| `order_overview.arrival_date` / `departure_date` / `rate_code` / `room_items[]` | 来自已确认 Room Information 卡;后出现的已确认卡会覆盖前面同字段。后端已兼容读取新确认快照 `confirmed_payload.room_information.final_values` 和历史 `business_fields`。 | | `order_overview.trace_card_status` / `rooming_list_card_status` / `payment_card_status` | 当前订单下对应业务卡最新状态,方便订单详情页展示是否还有待处理事项。 | | `order_overview.latest_confirmed_at` | 当前订单 V4 卡片最近确认 UTC 时间。 | | `next_v4_action` | 订单详情页下一步处理入口,口径与订单列表 V4 入口一致;前端点击后跳 `/reservation/order-tasks/{order_task_id}`。 | 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 92eef9f..4ea3b2d 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,7 +6,7 @@ | --- | --- | | 文档版本 | 0.8 | | 日期 | 2026-07-20 | -| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14、V4 业务审计查询和停止 V4 普通业务双写旧任务已实现 | +| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14、V4 业务审计查询、停止 V4 普通业务双写旧任务和 Room Information 后端展示模型第一版已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | | 不适用范围 | 真实 PMS / OPERA / OHIP、前端页面视觉稿、生产历史数据迁移 | @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP14、V4 业务审计查询和停止旧任务双写补齐,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包只创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡,不再创建旧 `workflow_reservation_task`;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1,以及订单列表 V4 继续处理入口字段。下一阶段已确认 Rate Code 需要按订单级 Account + `booking_type`(GROUP / FIT)过滤和校验,当前后端 CP11 实现仍是酒店级 Rate Code 目录,是待补齐缺口;Room Information 卡需要补 New / Update / Cancel 业务展示模型、Nights / Breakfast 派生、Group Booking Status 和 Rooming List 确认联动;Payment 卡下一阶段需要展示付款凭证附件,图片为缩略图 + 点击大图预览,非图片为文件列表 + 下载,但附件 URL 仍必须走 SourceMessage 原文权限链路;真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 +截至 CP14、V4 业务审计查询、停止旧任务双写和 Room Information 后端展示模型补齐,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包只创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡,不再创建旧 `workflow_reservation_task`;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1、订单列表 V4 继续处理入口字段,以及 Room Information New / Update / Cancel 第一版业务展示模型。下一阶段已确认 Rate Code 需要按订单级 Account + `booking_type`(GROUP / FIT)过滤和校验,当前后端 CP11 实现仍是酒店级 Rate Code 目录,是待补齐缺口;Rooming List 确认联动 Group Booking Status 自动置 `DEF` 仍未实现;Payment 卡下一阶段需要展示付款凭证附件,图片为缩略图 + 点击大图预览,非图片为文件列表 + 下载,但附件 URL 仍必须走 SourceMessage 原文权限链路;真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -571,11 +571,12 @@ Room Information 卡展示模型: - `ROOM_INFORMATION` 卡只由 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING` 三类 event 触发;`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT` 不触发房型信息卡。 - SuperAgent 仍只输出字段契约中的业务字段。Nights、Breakfast、Group Booking Status、Block ID、Confirmation Number 和 Adult 不由 SuperAgent 输出;其中 Adult 第一版不在卡内展示。 -- 后端下一阶段应在 V4 任务详情中为 Room Information 卡补展示模型,建议拆为 `current_values`、`proposed_values`、`final_values`、`change_summary[]`、`derived_fields` 和 `field_controls`。前端按该展示模型渲染业务 UI,`fields[]` 继续作为确认 / 复核的可编辑字段白名单。 +- 后端已在 `GET /api/reservation/order-tasks/{orderTaskId}` 的 Room Information 业务卡 `display_payload.room_information` 中补稳定展示模型,结构为 `event_type`、`booking_type`、`current_values`、`proposed_values`、`final_values`、`change_summary[]`、`group_booking_status_options[]`。前端按该展示模型渲染业务 UI,不再从 Agent raw payload / `target_order` 自行推导;`fields[]` 继续作为确认 / 复核的可编辑字段白名单。 +- `fields[]` 的 Room Information 主路径统一为 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date`、`/room_information/final_values/room_items/0/room_type_code`。确认接口收到该结构时,后端会从展示模型派生 `confirmed_payload_json.room_information.final_values`,并重新计算 `nights`、`breakfast_included` 和 `group_booking_status_label`;只读字段、Agent `target_order`、Adult 和前端注入字段不会写入确认快照。 - `NEW_BOOKING`:卡片展示创建后的最终值。Agent 提供 `target_order`、`arrival_date`、`departure_date`、`rate_code`、`booking_scenario`、`room_items[]`,Fit 可提供 `guest_name`;后端派生 `nights`、`breakfast_included` 和 Group Booking Status。 - `UPDATE_BOOKING`:后端从本地订单投影读取当前值,用 Agent `after` 合并得到最终值;页面上方展示本次实际变化的 `change_summary[]`,例如 `入住日期:2026-07-12 -> 2026-07-20`。如果日期变化导致 `nights` 变化,`nights` 也必须出现在差异区;字段区展示合并后的最终值。 - `CANCEL_BOOKING`:不使用 Agent 输出当前订单快照;后端从本地订单投影读取当前值并只读展示,用户只确认整单取消。Cancel 卡不允许编辑 Group Booking Status、Breakfast、日期、Rate Code 或房型房量。 -- `nights` 由后端按酒店本地业务日期计算:`departure_date - arrival_date`,不涉及时区和 UTC;日期缺失、非法或差值小于等于 0 时,`nights` 为空并阻止确认。 +- `nights` 由后端按酒店本地业务日期计算:`departure_date - arrival_date`,不涉及时区和 UTC;日期缺失、非法或离店早于入住时,`nights` 为空。第一版确认校验要求日期必填,后续如需更严格营业日规则另开 checkpoint。 - `breakfast_included` 是卡片展示和确认使用的布尔字段。Group 固定含早,前端显示勾选且只读;Fit 按 Rate Code 派生,Rate Code 包含 `RB` 时含早,包含 `RO` 时不含早;如果 Rate Code 无法派生,前端显示必填勾选框,由用户确认是否含早。 - Group Booking Status 仅 Group 显示,稳定 code 为 `TEN`、`DEF`、`INQ`,前端显示 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`。New Group 默认 `TEN`;`booking_scenario=STANDARD | PROPOSAL` 仅保留为 Agent 场景参考,不映射 Group Booking Status。`NEW_BOOKING` / `UPDATE_BOOKING` 确认前可手动改选,`CANCEL_BOOKING` 只读。 - `ROOMING_LIST` 卡确认时,如果同订单为 Group,后端应把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN` 或 `INQ`;该自动变更应写入审计。Fit 不显示也不变更 Group Booking Status。 @@ -755,7 +756,8 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm - 请求 JSON 必须携带 `version`;`confirmed_payload` 可选,未传时后端使用当前展示 payload 作为确认快照。 - 前端只应提交当前卡 `fields[]` 中可编辑字段。后端确认时以当前卡展示快照为基准合并 `confirmed_payload`,未出现在展示快照 / 字段白名单中的字段会被忽略,不会写入 `confirmed_payload_json`。 - Basic Information 确认时 `basic_information.account_code` 必须是当前酒店数据库 Account 目录值;后端确认前会派生 `account_name`、`market_code` 和 `source_code` 写入 `confirmed_payload_json`。 -- 业务卡确认时,当前已递归校验已有 `rate_code`、`room_items[].room_type_code` 是否在当前酒店数据库目录中;下一阶段 `rate_code` 还必须属于当前订单 Basic Information 已确认 Account + 当前业务 event `booking_type` 的适用关系。`UPDATE_BOOKING` 等嵌套结构会返回类似 `business_fields.after.room_items.0.room_type_code` 的错误路径,失败返回 `V4_FIELD_VALIDATION_FAILED`。 +- Room Information 确认时,前端优先提交 `confirmed_payload.room_information.final_values` 中当前 `fields[]` 可编辑字段;后端会生成稳定确认快照 `confirmed_payload_json.room_information.final_values`。该快照不包含 Agent 原始 `target_order`,也不包含邮件正文、附件 URL、raw evidence、Adult 或前端注入字段。 +- 业务卡确认时,当前已递归校验已有 `rate_code`、`room_items[].room_type_code` 是否在当前酒店数据库目录中;下一阶段 `rate_code` 还必须属于当前订单 Basic Information 已确认 Account + 当前业务 event `booking_type` 的适用关系。Room Information 新结构的错误路径形如 `room_information.final_values.room_items.0.room_type_code`;旧兼容结构可能返回类似 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`。 - 不提交草稿。 - 必须带 `version` 做并发校验。 - 后端确认后卡片 `CONFIRMED` 并锁定。 @@ -778,7 +780,7 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution - 请求 JSON 必须携带卡片 `version`;可选 `reason` 写入业务审计摘要;`confirmed_order_id` 表示复核场景确认后的本地订单 ID。 - 如果订单任务当前 `order_id=null` 或 `target_resolution_status!=RESOLVED`,`confirmed_order_id` 必填;后端会按订单任务所属酒店查询并校验真实可见订单。 - `field_overrides[]` 每项包含 `field_pointer` 和 `value`;`field_pointer` 只允许指向当前卡 `fields[]` 中可编辑业务字段,不允许指向 `source_message`、`route_code`、`card_type`、`target_order`、`order_ref`、`missing_fields`、`manual_review`、`raw_evidence`、`validation_errors` 等只读诊断字段。 -- 第一版允许的写入容器是 `basic_information` 和 `business_fields`。复核态允许编辑当前卡业务字段白名单内的已有叶子字段;不允许替换整个对象 / 数组、新增未知字段或提交后端未返回为可编辑的字段。 +- 第一版允许的写入容器是 `basic_information`、Room Information 的 `room_information.final_values` 和历史兼容 `business_fields`。复核态允许编辑当前卡业务字段白名单内的已有叶子字段;不允许替换整个对象 / 数组、新增未知字段或提交后端未返回为可编辑的字段。 - 复核提交后同样执行目录校验;Basic Information 复核成功后会在 `confirmed_payload_json.basic_information` 中写入派生的 `account_name`、`market_code`、`source_code`。 - 如果订单任务已经有 `order_id` 且 `target_resolution_status=RESOLVED`,`confirmed_order_id` 只能为空或等于当前订单 ID;提交其它订单 ID 会返回 `V4_ORDER_REBIND_NOT_ALLOWED`。 - Basic Information 必须先确认;如果 Basic Information 自身是 `REVIEW_REQUIRED`,允许通过本接口先复核并确认 Basic。 @@ -873,7 +875,7 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | M002-V4-CP14.5 | Account 范围 Rate Code Lookup | 待实现:按 Account + `booking_type` 管理和查询 Rate Code 适用关系;业务卡确认 / 复核校验 Rate Code 适用性;前端在 Account 确认后加载对应 GROUP/FIT 候选 | | M002-V4-CP14.6 | Payment 附件预览 | 待实现:Payment 卡返回付款凭证附件安全摘要;前端图片缩略图 + 大图预览,非图片文件列表 + 下载;预览 / 下载走 SourceMessage 原文权限链路 | | M002-V4-CP14.7 | Rooming List 事项确认卡 | 待实现前端轻量展示:Rooming List 卡第一版只展示事项和确认按钮,不解析名单、不预览附件、不生成 Excel、不导入 PMS | -| M002-V4-CP14.8 | Room Information 展示模型 | 待实现:New / Update / Cancel 按业务模型展示最终值、差异、Nights、Breakfast、Group Booking Status 和本地订单投影;Adult 不显示;Rooming List 确认时 Group 状态自动置 `DEF` | +| M002-V4-CP14.8 | Room Information 展示模型 | 已完成后端第一版:New / Update / Cancel 按业务模型展示最终值、差异、Nights、Breakfast、Group Booking Status 和本地订单投影;Adult 不显示;Rooming List 确认时 Group 状态自动置 `DEF` 仍后置 | | M002-V4-CP14.9 | 复核态卡片字段白名单和统一确认交互 | 待实现:`REVIEW_REQUIRED` 保持原业务卡内编辑,问题字段红字提示,前端按钮显示“确认卡片”但调用 `review-resolution`;后端 `fields[]` 返回当前卡业务字段白名单,复核写入不再只限空值或目录错误字段 | | M002-V4-CP15 | V4 业务审计查询 | 已完成:`GET /api/reservation/order-tasks/{orderTaskId}/audits` 和 `GET /api/reservation/source-notifications/{notificationId}/audits` 返回卡片确认、复核解阻和来源通知 ack 的脱敏审计流水 | | M002-V4-CP15.1 | 订单详情 V4 化后端补齐 | 已完成:`GET /api/reservation/orders/{orderId}` 返回 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`,支撑订单总览页 | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java index 93a40fb..8864633 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java @@ -673,6 +673,10 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ * 读取业务字段主体;Update Booking 的 after 结构优先作为订单当前快照来源。 */ private JsonNode businessFields(JsonNode payload) { + JsonNode roomInformationFinalValues = payload.path("room_information").path("final_values"); + if (roomInformationFinalValues.isObject()) { + return roomInformationFinalValues; + } JsonNode businessFields = objectOrSelf(payload, "business_fields"); JsonNode after = businessFields.path("after"); return after.isObject() ? after : businessFields; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java index 910433e..2c76800 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java @@ -34,13 +34,16 @@ 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.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -58,6 +61,17 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private static final String ACTION_V4_CARD_CONFIRM = "V4_CARD_CONFIRM"; private static final String ACTION_V4_CARD_REVIEW_RESOLVE = "V4_CARD_REVIEW_RESOLVE"; private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK"; + private static final String EVENT_NEW_BOOKING = "NEW_BOOKING"; + private static final String EVENT_UPDATE_BOOKING = "UPDATE_BOOKING"; + private static final String EVENT_CANCEL_BOOKING = "CANCEL_BOOKING"; + private static final String BOOKING_TYPE_GROUP = "GROUP"; + private static final String BOOKING_TYPE_FIT = "FIT"; + private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE"; + private static final String GROUP_BOOKING_STATUS_TEN = "TEN"; + private static final Map GROUP_BOOKING_STATUS_LABELS = Map.of( + "TEN", "TEN-Tentative", + "DEF", "DEF-Definite", + "INQ", "INQ-Inquiry"); private static final Set REVIEW_READONLY_ROOT_FIELDS = Set.of( "ai_payload_json", "attachments", @@ -94,6 +108,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ "rate_code", "room_items", "trace_items"); + private static final Set ROOM_INFORMATION_READONLY_FINAL_FIELDS = Set.of( + "adult", + "adults", + "adult_count", + "block_id", + "confirmation_number", + "group_booking_status_label", + "nights", + "target_order"); private final ReservationV4WorkflowRepository workflowRepository; private final ReservationV4SourceNotificationRepository sourceNotificationRepository; @@ -144,6 +167,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ String confirmedPayloadJson = confirmedPayloadJson( orderTask.hotelId(), + orderTask, card, request == null ? null : request.confirmedPayload()); String actorId = actorIdentifier(actor); @@ -184,11 +208,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ ensureNoPriorOrderTaskBlocking(orderTask, targetOrderId); ensureBasicInformationConfirmed(orderTask, card); - ObjectNode confirmedPayload = mutableDisplayPayload(card); + ObjectNode confirmedPayload = mutableConfirmedPayloadBase( + orderTask, + card, + request == null ? null : request.fieldOverrides()); List> normalizedOverrides = applyReviewFieldOverrides( card, confirmedPayload, request == null ? null : request.fieldOverrides()); + normalizeStableRoomInformationPayload(confirmedPayload); validateAndEnrichConfirmedPayload(orderTask.hotelId(), card, confirmedPayload); String actorId = actorIdentifier(actor); String confirmedPayloadJson = toJson(confirmedPayload); @@ -472,19 +500,26 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。"); } - private String confirmedPayloadJson(String hotelId, ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { - ObjectNode payload = confirmedPayloadObject(card, confirmedPayload); + private String confirmedPayloadJson( + String hotelId, + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + JsonNode confirmedPayload) { + ObjectNode payload = confirmedPayloadObject(orderTask, card, confirmedPayload); validateAndEnrichConfirmedPayload(hotelId, card, payload); return toJson(payload); } - private ObjectNode confirmedPayloadObject(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { - ObjectNode payload = mutableDisplayPayload(card); + private ObjectNode confirmedPayloadObject( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + JsonNode confirmedPayload) { + ObjectNode payload = mutableConfirmedPayloadBase(orderTask, card, confirmedPayload); if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) { if (!confirmedPayload.isObject()) { throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_PAYLOAD_NOT_OBJECT", "确认 payload 必须是对象结构。"); } - overlayConfirmedPayload(card, payload, (ObjectNode) confirmedPayload); + overlayConfirmedPayload(orderTask, card, payload, (ObjectNode) confirmedPayload); } return payload; } @@ -492,11 +527,19 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ /** * 按当前卡片白名单合并前端确认值,避免未开放字段污染 confirmed_payload_json。 */ - private void overlayConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload, ObjectNode submittedPayload) { + private void overlayConfirmedPayload( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode payload, + ObjectNode submittedPayload) { if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { overlayBasicInformationPayload(payload, submittedPayload); return; } + if (isStableRoomInformationPayload(payload)) { + overlayRoomInformationPayload(orderTask, card, payload, submittedPayload); + return; + } overlayBusinessPayload(payload, submittedPayload); } @@ -528,6 +571,155 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ overlayEditableBusinessLeaves(payload, submittedRoot, List.of(), false); } + /** + * Room Information 新契约只允许写 final_values 中的业务投影字段,不接受 Agent target_order 或派生字段回写。 + */ + private void overlayRoomInformationPayload( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode payload, + ObjectNode submittedPayload) { + if (EVENT_CANCEL_BOOKING.equals(card.eventType())) { + return; + } + JsonNode finalValuesNode = payload.path("room_information").path("final_values"); + if (!finalValuesNode.isObject()) { + return; + } + ObjectNode finalValues = (ObjectNode) finalValuesNode; + JsonNode submittedFinalValues = submittedPayload.path("room_information").path("final_values"); + JsonNode submittedRoot = submittedFinalValues.isObject() ? submittedFinalValues : submittedPayload; + String bookingType = textAt(payload.path("room_information"), "booking_type"); + overlayEditableRoomInformationLeaves(card, bookingType, finalValues, submittedRoot, List.of()); + normalizeRoomInformationDerivedFields(bookingType, finalValues); + payload.withObject("/room_information").set("change_summary", + changeSummary(payload.path("room_information").path("current_values"), finalValues)); + } + + private void overlayEditableRoomInformationLeaves( + ReservationV4TaskCardSnapshot card, + String bookingType, + ObjectNode target, + JsonNode submitted, + List path) { + if (submitted == null || !submitted.isObject()) { + return; + } + Iterator> fields = target.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + List childPath = appendPath(path, field.getKey()); + if (!isRoomInformationPathWritable(card, bookingType, childPath)) { + continue; + } + JsonNode submittedValue = submitted.get(field.getKey()); + overlayEditableRoomInformationValue(card, bookingType, target, field.getKey(), field.getValue(), + submittedValue, childPath); + } + } + + private void overlayEditableRoomInformationArray( + ReservationV4TaskCardSnapshot card, + String bookingType, + ArrayNode target, + JsonNode submitted, + List path) { + if (submitted == null || !submitted.isArray()) { + return; + } + int size = Math.min(target.size(), submitted.size()); + for (int index = 0; index < size; index++) { + List childPath = appendPath(path, String.valueOf(index)); + overlayEditableRoomInformationValue(card, bookingType, target, index, target.get(index), submitted.get(index), + childPath); + } + } + + private void overlayEditableRoomInformationValue( + ReservationV4TaskCardSnapshot card, + String bookingType, + ObjectNode parent, + String fieldName, + JsonNode currentValue, + JsonNode submittedValue, + List path) { + if (currentValue != null && currentValue.isObject()) { + overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path); + return; + } + if (currentValue != null && currentValue.isArray()) { + overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path); + return; + } + if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode() + && isRoomInformationPathWritable(card, bookingType, path)) { + parent.set(fieldName, submittedValue); + } + } + + private void overlayEditableRoomInformationValue( + ReservationV4TaskCardSnapshot card, + String bookingType, + ArrayNode parent, + int index, + JsonNode currentValue, + JsonNode submittedValue, + List path) { + if (currentValue != null && currentValue.isObject()) { + overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path); + return; + } + if (currentValue != null && currentValue.isArray()) { + overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path); + return; + } + if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode() + && isRoomInformationPathWritable(card, bookingType, path)) { + parent.set(index, submittedValue); + } + } + + private boolean isRoomInformationPathWritable( + ReservationV4TaskCardSnapshot card, + String bookingType, + List path) { + if (path == null || path.isEmpty() || EVENT_CANCEL_BOOKING.equals(card.eventType())) { + return false; + } + if (path.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) { + return false; + } + if (path.size() == 1) { + String fieldName = path.get(0); + if ("group_block_name".equals(fieldName)) { + return BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(card.eventType()); + } + if ("fit_name".equals(fieldName)) { + return BOOKING_TYPE_FIT.equals(bookingType); + } + if ("arrival_date".equals(fieldName) || "departure_date".equals(fieldName)) { + return true; + } + if ("rate_code".equals(fieldName)) { + return EVENT_NEW_BOOKING.equals(card.eventType()); + } + if ("breakfast_included".equals(fieldName)) { + return BOOKING_TYPE_FIT.equals(bookingType); + } + if ("group_booking_status".equals(fieldName)) { + return BOOKING_TYPE_GROUP.equals(bookingType); + } + if ("room_items".equals(fieldName)) { + return true; + } + return false; + } + if (path.size() == 3 && "room_items".equals(path.get(0)) && parseArrayIndex(path.get(1)) != null) { + return "room_type_code".equals(path.get(2)) || "room_count".equals(path.get(2)); + } + return false; + } + private void overlayEditableBusinessLeaves( ObjectNode target, JsonNode submitted, @@ -618,7 +810,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { validateAndEnrichBasicInformation(hotelId, payload, details); } else { - validateBusinessCardPayload(hotelId, payload, details); + validateBusinessCardPayload(hotelId, card, payload, details); } if (!details.isEmpty()) { throw new ReservationTaskWorkflowException( @@ -666,12 +858,103 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } - private void validateBusinessCardPayload(String hotelId, ObjectNode payload, List details) { + private void validateBusinessCardPayload( + String hotelId, + ReservationV4TaskCardSnapshot card, + ObjectNode payload, + List details) { + JsonNode roomInformationFinalValues = payload.path("room_information").path("final_values"); + if (isRoomInformationEventCard(card) && roomInformationFinalValues.isObject()) { + validateRoomInformationPayload(hotelId, card, payload.path("room_information"), roomInformationFinalValues, details); + return; + } JsonNode businessFields = payload.path("business_fields"); JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload; validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details); } + private void validateRoomInformationPayload( + String hotelId, + ReservationV4TaskCardSnapshot card, + JsonNode roomInformation, + JsonNode finalValues, + List details) { + String eventType = firstText(card.eventType(), textAt(roomInformation, "event_type")); + String bookingType = textAt(roomInformation, "booking_type"); + if (!EVENT_CANCEL_BOOKING.equals(eventType)) { + validateRequiredText(finalValues, "arrival_date", "room_information.final_values.arrival_date", "入住日期", details); + validateRequiredText(finalValues, "departure_date", "room_information.final_values.departure_date", "离店日期", details); + validateRoomInformationDates(finalValues, details); + validateRequiredRoomItems(finalValues.path("room_items"), details); + if (EVENT_NEW_BOOKING.equals(eventType)) { + validateRequiredText(finalValues, "rate_code", "room_information.final_values.rate_code", "Rate Code", details); + } + if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) { + validateRequiredText(finalValues, "group_block_name", "room_information.final_values.group_block_name", + "Group Block Name", details); + } + if (BOOKING_TYPE_FIT.equals(bookingType)) { + validateRequiredText(finalValues, "fit_name", "room_information.final_values.fit_name", "Fit Name", details); + if (breakfastIncludedFromRateCode(textAt(finalValues, "rate_code")) == null + && isMissingOrNull(finalValues.path("breakfast_included"))) { + details.add("room_information.final_values.breakfast_included: 含早不能为空。"); + } + } + } + validateBusinessCatalogFields(hotelId, finalValues, "room_information.final_values", details); + } + + private void validateRoomInformationDates(JsonNode finalValues, List details) { + String arrival = textAt(finalValues, "arrival_date"); + String departure = textAt(finalValues, "departure_date"); + LocalDate arrivalDate = parseDate(arrival); + LocalDate departureDate = parseDate(departure); + if (hasText(arrival) && arrivalDate == null) { + details.add("room_information.final_values.arrival_date: 入住日期格式必须为 yyyy-MM-dd。"); + } + if (hasText(departure) && departureDate == null) { + details.add("room_information.final_values.departure_date: 离店日期格式必须为 yyyy-MM-dd。"); + } + if (arrivalDate != null && departureDate != null && departureDate.isBefore(arrivalDate)) { + details.add("room_information.final_values.departure_date: 离店日期不能早于入住日期。"); + } + } + + private void validateRequiredText( + JsonNode node, + String fieldName, + String fieldPath, + String displayName, + List details) { + if (!hasText(textAt(node, fieldName))) { + details.add(fieldPath + ": " + displayName + " 不能为空。"); + } + } + + private boolean isMissingOrNull(JsonNode node) { + return node == null || node.isMissingNode() || node.isNull(); + } + + private void validateRequiredRoomItems(JsonNode roomItems, List details) { + if (roomItems == null || !roomItems.isArray() || roomItems.isEmpty()) { + details.add("room_information.final_values.room_items: 房型明细不能为空。"); + return; + } + for (int index = 0; index < roomItems.size(); index++) { + JsonNode item = roomItems.get(index); + if (item == null || !item.isObject()) { + details.add("room_information.final_values.room_items." + index + ": 房型明细必须是对象。"); + continue; + } + validateRequiredText(item, "room_type_code", + "room_information.final_values.room_items." + index + ".room_type_code", "房型代码", details); + JsonNode roomCount = item.get("room_count"); + if (roomCount == null || roomCount.isNull() || !roomCount.canConvertToInt() || roomCount.asInt() <= 0) { + details.add("room_information.final_values.room_items." + index + ".room_count: 房间数必须大于 0。"); + } + } + } + private void validateBusinessCatalogFields(String hotelId, JsonNode node, String fieldPath, List details) { if (node == null || node.isMissingNode() || node.isNull()) { return; @@ -739,6 +1022,19 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return textValue(node.get(fieldName)); } + private String firstText(String first, String second) { + return hasText(first) ? first : second; + } + + private boolean isRoomInformationEventCard(ReservationV4TaskCardSnapshot card) { + if (card == null || !ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())) { + return false; + } + return EVENT_NEW_BOOKING.equals(card.eventType()) + || EVENT_UPDATE_BOOKING.equals(card.eventType()) + || EVENT_CANCEL_BOOKING.equals(card.eventType()); + } + private String textValue(JsonNode node) { if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) { return null; @@ -766,6 +1062,421 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + /** + * 根据当前卡契约选择确认基础快照;V4 Room Information 新 payload 使用稳定 room_information 模型。 + */ + private ObjectNode mutableConfirmedPayloadBase( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + JsonNode submittedPayload) { + ObjectNode displayPayload = mutableDisplayPayload(card); + if (shouldUseStableRoomInformationPayload(card, displayPayload, submittedPayload, null)) { + return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload); + } + return displayPayload; + } + + /** + * 复核场景根据 pointer 判断是否使用 Room Information 新模型,避免旧兼容测试夹具被强制升级。 + */ + private ObjectNode mutableConfirmedPayloadBase( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + List fieldOverrides) { + ObjectNode displayPayload = mutableDisplayPayload(card); + if (shouldUseStableRoomInformationPayload(card, displayPayload, null, fieldOverrides)) { + return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload); + } + return displayPayload; + } + + private boolean shouldUseStableRoomInformationPayload( + ReservationV4TaskCardSnapshot card, + JsonNode displayPayload, + JsonNode submittedPayload, + List fieldOverrides) { + if (!isRoomInformationEventCard(card)) { + return false; + } + if (submittedPayload != null && submittedPayload.path("room_information").isObject()) { + return true; + } + if (fieldOverrides != null && fieldOverrides.stream() + .map(override -> trimToNull(override == null ? null : override.fieldPointer())) + .filter(Objects::nonNull) + .anyMatch(pointer -> pointer.startsWith("/room_information/"))) { + return true; + } + return displayPayload != null && displayPayload.path("target_order").isObject(); + } + + private boolean isStableRoomInformationPayload(JsonNode payload) { + return payload != null + && payload.path("room_information").path("final_values").isObject(); + } + + private ObjectNode roomInformationConfirmedPayloadBase( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode displayPayload) { + ObjectNode model = roomInformationDisplayModel(orderTask, card, displayPayload); + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("card_type", ReservationV4CardType.ROOM_INFORMATION.name()); + payload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type"))); + payload.set("room_information", model); + return payload; + } + + /** + * 构造确认和复核共用的 Room Information 业务投影快照。 + */ + private ObjectNode roomInformationDisplayModel( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode displayPayload) { + String eventType = firstText(card.eventType(), textAt(displayPayload, "event_type")); + JsonNode targetOrder = displayPayload.path("target_order"); + String bookingType = firstText(textAt(targetOrder, "booking_type"), orderTask.targetBookingType()); + String locatorType = firstText(textAt(targetOrder, "locator_type"), orderTask.targetLocatorType()); + String locatorValue = firstText(textAt(targetOrder, "locator_value"), orderTask.targetLocatorValue()); + ObjectNode currentValues = currentRoomInformationProjection(orderTask); + ObjectNode proposedValues = proposedRoomInformationValues(eventType, bookingType, locatorType, locatorValue, displayPayload); + ObjectNode finalValues = finalRoomInformationValues(eventType, bookingType, locatorType, locatorValue, currentValues, proposedValues); + ensureRoomInformationEditablePlaceholders(card, bookingType, finalValues); + + ObjectNode model = objectMapper.createObjectNode(); + model.put("event_type", eventType); + model.put("booking_type", bookingType); + model.set("current_values", EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues); + model.set("proposed_values", EVENT_CANCEL_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : proposedValues); + model.set("final_values", finalValues); + model.set("change_summary", changeSummary(EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues, finalValues)); + return model; + } + + private ObjectNode currentRoomInformationProjection(ReservationV4OrderTaskSnapshot orderTask) { + ObjectNode current = objectMapper.createObjectNode(); + if (orderTask.orderId() == null) { + return current; + } + List orderTasks = workflowRepository.findOrderTasksByOrderIds( + orderTask.hotelId(), + List.of(orderTask.orderId())); + List priorOrderTaskIds = new ArrayList<>(); + for (ReservationV4OrderTaskSnapshot candidate : orderTasks) { + if (Objects.equals(candidate.id(), orderTask.id())) { + break; + } + priorOrderTaskIds.add(candidate.id()); + } + if (priorOrderTaskIds.isEmpty()) { + return current; + } + List cards = workflowRepository.findTaskCardsByOrderTaskIds( + orderTask.hotelId(), + priorOrderTaskIds); + for (ReservationV4TaskCardSnapshot candidate : cards) { + if (!ReservationV4CardType.ROOM_INFORMATION.name().equals(candidate.cardType()) + || !ReservationV4CardStatus.CONFIRMED.name().equals(candidate.cardStatus())) { + continue; + } + mergeObject(current, confirmedRoomInformationFinalValues(candidate.confirmedPayloadJson())); + } + normalizeRoomInformationDerivedFields(orderTask.targetBookingType(), current); + return roomInformationDisplayValues(orderTask.targetBookingType(), current, true); + } + + private ObjectNode confirmedRoomInformationFinalValues(String confirmedPayloadJson) { + JsonNode payload = parseJson(confirmedPayloadJson); + JsonNode finalValues = payload.path("room_information").path("final_values"); + if (finalValues.isObject()) { + return ((ObjectNode) finalValues).deepCopy(); + } + JsonNode businessFields = payload.path("business_fields"); + if (businessFields.isObject()) { + return ((ObjectNode) businessFields).deepCopy(); + } + if (payload.isObject()) { + return ((ObjectNode) payload).deepCopy(); + } + return objectMapper.createObjectNode(); + } + + private ObjectNode proposedRoomInformationValues( + String eventType, + String bookingType, + String locatorType, + String locatorValue, + JsonNode displayPayload) { + if (EVENT_CANCEL_BOOKING.equals(eventType)) { + return objectMapper.createObjectNode(); + } + JsonNode businessFields = displayPayload.path("business_fields"); + JsonNode source = businessFields.isObject() ? businessFields : displayPayload; + if (EVENT_UPDATE_BOOKING.equals(eventType) && businessFields.path("after").isObject()) { + source = businessFields.path("after"); + } + ObjectNode proposed = roomInformationDisplayValues(bookingType, source, EVENT_NEW_BOOKING.equals(eventType)); + if (EVENT_NEW_BOOKING.equals(eventType)) { + applyNewBookingNameDefaults(proposed, bookingType, locatorType, locatorValue, source); + } + normalizeRoomInformationDerivedFields(bookingType, proposed); + return proposed; + } + + private ObjectNode finalRoomInformationValues( + String eventType, + String bookingType, + String locatorType, + String locatorValue, + ObjectNode currentValues, + ObjectNode proposedValues) { + ObjectNode finalValues = EVENT_NEW_BOOKING.equals(eventType) + ? objectMapper.createObjectNode() + : currentValues.deepCopy(); + if (!EVENT_CANCEL_BOOKING.equals(eventType)) { + mergeObject(finalValues, proposedValues); + } + if (EVENT_NEW_BOOKING.equals(eventType)) { + applyNewBookingNameDefaults(finalValues, bookingType, locatorType, locatorValue, proposedValues); + } + normalizeRoomInformationDerivedFields(bookingType, finalValues); + return roomInformationDisplayValues(bookingType, finalValues, true); + } + + private void applyNewBookingNameDefaults( + ObjectNode values, + String bookingType, + String locatorType, + String locatorValue, + JsonNode source) { + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + if (!hasText(textAt(values, "group_block_name")) && LOCATOR_TYPE_GROUP_CODE.equals(locatorType)) { + values.put("group_block_name", locatorValue); + } + } else if (BOOKING_TYPE_FIT.equals(bookingType)) { + String fitName = firstText(textAt(values, "fit_name"), textAt(source, "guest_name")); + if (!hasText(fitName)) { + fitName = locatorValue; + } + if (hasText(fitName)) { + values.put("fit_name", fitName); + } + } + } + + private void normalizeRoomInformationDerivedFields(String bookingType, ObjectNode values) { + putNights(values); + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + values.put("breakfast_included", true); + if (!hasText(textAt(values, "group_booking_status"))) { + values.put("group_booking_status", GROUP_BOOKING_STATUS_TEN); + } + putGroupBookingStatusLabel(values); + } else if (BOOKING_TYPE_FIT.equals(bookingType)) { + Boolean breakfast = breakfastIncludedFromRateCode(textAt(values, "rate_code")); + if (breakfast != null) { + values.put("breakfast_included", breakfast); + } + } + } + + private void removeRoomInformationNonDisplayFields(String bookingType, ObjectNode values) { + ObjectNode filtered = roomInformationDisplayValues(bookingType, values, true); + values.removeAll(); + values.setAll(filtered); + } + + private void ensureRoomInformationEditablePlaceholders( + ReservationV4TaskCardSnapshot card, + String bookingType, + ObjectNode finalValues) { + if (EVENT_CANCEL_BOOKING.equals(card.eventType())) { + return; + } + putNullIfMissing(finalValues, "arrival_date"); + putNullIfMissing(finalValues, "departure_date"); + if (EVENT_NEW_BOOKING.equals(card.eventType())) { + putNullIfMissing(finalValues, "rate_code"); + } + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + if (EVENT_NEW_BOOKING.equals(card.eventType())) { + putNullIfMissing(finalValues, "group_block_name"); + } + putNullIfMissing(finalValues, "group_booking_status"); + } else if (BOOKING_TYPE_FIT.equals(bookingType)) { + putNullIfMissing(finalValues, "fit_name"); + if (breakfastIncludedFromRateCode(textAt(finalValues, "rate_code")) == null) { + putNullIfMissing(finalValues, "breakfast_included"); + } + } + } + + private void putNullIfMissing(ObjectNode values, String fieldName) { + if (!values.has(fieldName)) { + values.set(fieldName, objectMapper.nullNode()); + } + } + + private void putNights(ObjectNode values) { + values.remove("nights"); + LocalDate arrivalDate = parseDate(textAt(values, "arrival_date")); + LocalDate departureDate = parseDate(textAt(values, "departure_date")); + if (arrivalDate != null && departureDate != null && !departureDate.isBefore(arrivalDate)) { + values.put("nights", ChronoUnit.DAYS.between(arrivalDate, departureDate)); + } + } + + private ObjectNode roomInformationDisplayValues(String bookingType, JsonNode source, boolean includeRateCode) { + ObjectNode values = objectMapper.createObjectNode(); + if (source == null || !source.isObject()) { + return values; + } + copyScalarField(source, values, "group_block_name"); + copyScalarField(source, values, "fit_name"); + copyScalarField(source, values, "arrival_date"); + copyScalarField(source, values, "departure_date"); + copyScalarField(source, values, "nights"); + if (includeRateCode) { + copyScalarField(source, values, "rate_code"); + } + copyScalarField(source, values, "breakfast_included"); + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + copyScalarField(source, values, "block_id"); + copyScalarField(source, values, "group_booking_status"); + copyScalarField(source, values, "group_booking_status_label"); + } else if (BOOKING_TYPE_FIT.equals(bookingType)) { + copyScalarField(source, values, "confirmation_number"); + } + copyRoomItems(source.path("room_items"), values); + return values; + } + + private void copyScalarField(JsonNode source, ObjectNode target, String fieldName) { + JsonNode value = source.get(fieldName); + if (value == null || value.isMissingNode() || value.isNull() || value.isContainerNode()) { + return; + } + target.set(fieldName, value); + } + + private void copyRoomItems(JsonNode roomItems, ObjectNode target) { + if (roomItems == null || !roomItems.isArray()) { + return; + } + ArrayNode sanitizedItems = objectMapper.createArrayNode(); + for (JsonNode item : roomItems) { + if (item == null || !item.isObject()) { + continue; + } + ObjectNode sanitizedItem = objectMapper.createObjectNode(); + copyScalarField(item, sanitizedItem, "room_type_code"); + copyScalarField(item, sanitizedItem, "room_count"); + sanitizedItems.add(sanitizedItem); + } + target.set("room_items", sanitizedItems); + } + + private void putGroupBookingStatusLabel(ObjectNode values) { + String status = textAt(values, "group_booking_status"); + if (hasText(status)) { + values.put("group_booking_status_label", GROUP_BOOKING_STATUS_LABELS.getOrDefault(status, status)); + } + } + + private Boolean breakfastIncludedFromRateCode(String rateCode) { + if (!hasText(rateCode)) { + return null; + } + String normalized = rateCode.toUpperCase(Locale.ROOT); + if (normalized.contains("RB")) { + return true; + } + if (normalized.contains("RO")) { + return false; + } + return null; + } + + private ArrayNode changeSummary(JsonNode currentValues, JsonNode finalValues) { + ArrayNode changes = objectMapper.createArrayNode(); + if (!currentValues.isObject() || !finalValues.isObject()) { + return changes; + } + addChangeIfDifferent(changes, "group_block_name", currentValues, finalValues); + addChangeIfDifferent(changes, "fit_name", currentValues, finalValues); + addChangeIfDifferent(changes, "arrival_date", currentValues, finalValues); + addChangeIfDifferent(changes, "departure_date", currentValues, finalValues); + addChangeIfDifferent(changes, "nights", currentValues, finalValues); + addChangeIfDifferent(changes, "rate_code", currentValues, finalValues); + addChangeIfDifferent(changes, "breakfast_included", currentValues, finalValues); + addChangeIfDifferent(changes, "group_booking_status", currentValues, finalValues); + addChangeIfDifferent(changes, "room_items", currentValues, finalValues); + return changes; + } + + private void addChangeIfDifferent(ArrayNode changes, String fieldName, JsonNode currentValues, JsonNode finalValues) { + JsonNode before = currentValues.get(fieldName); + JsonNode after = finalValues.get(fieldName); + if (Objects.equals(before, after)) { + return; + } + if ((before == null || before.isMissingNode()) && (after == null || after.isMissingNode())) { + return; + } + ObjectNode change = changes.addObject(); + change.put("field", fieldName); + change.set("before", before == null ? objectMapper.nullNode() : before); + change.set("after", after == null ? objectMapper.nullNode() : after); + } + + private void mergeObject(ObjectNode target, JsonNode source) { + if (source == null || !source.isObject()) { + return; + } + Iterator> fields = source.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + target.set(field.getKey(), field.getValue()); + } + } + + private LocalDate parseDate(String value) { + if (!hasText(value)) { + return null; + } + try { + return LocalDate.parse(value); + } catch (Exception exception) { + return null; + } + } + + private JsonNode parseJson(String json) { + if (!hasText(json)) { + return objectMapper.createObjectNode(); + } + try { + JsonNode node = objectMapper.readTree(json); + return node == null || node.isNull() ? objectMapper.createObjectNode() : node; + } catch (Exception exception) { + return objectMapper.createObjectNode(); + } + } + + private void normalizeStableRoomInformationPayload(ObjectNode payload) { + if (!isStableRoomInformationPayload(payload)) { + return; + } + JsonNode roomInformationNode = payload.path("room_information"); + ObjectNode roomInformation = (ObjectNode) roomInformationNode; + ObjectNode finalValues = (ObjectNode) roomInformation.path("final_values"); + String bookingType = textAt(roomInformation, "booking_type"); + normalizeRoomInformationDerivedFields(bookingType, finalValues); + removeRoomInformationNonDisplayFields(bookingType, finalValues); + roomInformation.set("change_summary", changeSummary(roomInformation.path("current_values"), finalValues)); + } + private List> applyReviewFieldOverrides( ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload, @@ -869,6 +1580,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } return; } + if ("room_information".equals(root) && isStableRoomInformationPayload(confirmedPayload)) { + ensureRoomInformationReviewPointerWritable(card, confirmedPayload, segments); + return; + } if ("business_fields".equals(root)) { return; } @@ -880,6 +1595,23 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + private void ensureRoomInformationReviewPointerWritable( + ReservationV4TaskCardSnapshot card, + ObjectNode confirmedPayload, + List segments) { + if (segments.size() < 3 || !"final_values".equals(segments.get(1))) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。"); + } + List finalValuePath = segments.subList(2, segments.size()); + if (finalValuePath.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。"); + } + String bookingType = textAt(confirmedPayload.path("room_information"), "booking_type"); + if (!isRoomInformationPathWritable(card, bookingType, finalValuePath)) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。"); + } + } + private JsonNode findPointerValue(JsonNode root, List segments) { JsonNode current = root; for (String segment : segments) { diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java index 834ab14..a61f226 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java @@ -51,7 +51,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; @@ -80,6 +82,17 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService private static final int MAX_MIXED_WORKBENCH_FETCH_SIZE = 10_000; private static final String DISPLAY_STATUS_BLOCKED = "BLOCKED"; private static final String DISPLAY_STATUS_OPEN = "OPEN"; + private static final String EVENT_NEW_BOOKING = "NEW_BOOKING"; + private static final String EVENT_UPDATE_BOOKING = "UPDATE_BOOKING"; + private static final String EVENT_CANCEL_BOOKING = "CANCEL_BOOKING"; + private static final String BOOKING_TYPE_GROUP = "GROUP"; + private static final String BOOKING_TYPE_FIT = "FIT"; + private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE"; + private static final String GROUP_BOOKING_STATUS_TEN = "TEN"; + private static final Map GROUP_BOOKING_STATUS_LABELS = Map.of( + "TEN", "TEN-Tentative", + "DEF", "DEF-Definite", + "INQ", "INQ-Inquiry"); private static final Set V4_BUSINESS_FIELD_ROOTS = Set.of( "arrival_date", "departure_date", @@ -232,15 +245,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext, cards); ReservationV4TaskCardSnapshot basicCard = findFirstCard(cards, ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null); ReservationV4TaskCardResult sourceMessageCard = findFirstCard(cards, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name()) - .map(card -> toCardResult(card, orderAvailability, basicCard)) + .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard)) .orElse(null); ReservationV4TaskCardResult basicInformationCard = Optional.ofNullable(basicCard) - .map(card -> toCardResult(card, orderAvailability, basicCard)) + .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard)) .orElse(null); List businessCards = cards.stream() .filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) .filter(card -> !ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) - .map(card -> toCardResult(card, orderAvailability, basicCard)) + .map(card -> toCardResult(orderTask, card, orderAvailability, basicCard)) .toList(); ReservationV4SourceMessageSummaryResult sourceSummary = sourceSummaries( orderTask.hotelId(), @@ -379,10 +392,11 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService } private ReservationV4TaskCardResult toCardResult( + ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot card, ReservationV4ActionAvailabilityResult orderAvailability, ReservationV4TaskCardSnapshot basicCard) { - JsonNode displayPayload = parseJson(card.displayPayloadJson()); + JsonNode displayPayload = displayPayloadForCard(orderTask, card); JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson()); JsonNode reviewResolution = parseJson(card.reviewResolutionJson()); JsonNode validationErrors = parseJson(card.validationErrorsJson()); @@ -408,6 +422,27 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService availability); } + /** + * 生成卡片安全展示 payload;Room Information 卡在这里补稳定业务展示模型。 + */ + private JsonNode displayPayloadForCard( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card) { + JsonNode displayPayload = parseJson(card.displayPayloadJson()); + if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) { + return displayPayload; + } + ObjectNode source = (ObjectNode) displayPayload; + ObjectNode safePayload = objectMapper.createObjectNode(); + safePayload.put("card_type", firstText(textAt(source, "card_type"), card.cardType())); + safePayload.put("event_type", firstText(card.eventType(), textAt(source, "event_type"))); + if (card.sourceEventIndex() != null) { + safePayload.put("source_event_index", card.sourceEventIndex()); + } + safePayload.set("room_information", roomInformationDisplayModel(orderTask, card, source)); + return safePayload; + } + private ReservationV4TaskCardResult toSourceNotificationCard( ReservationV4SourceNotificationSnapshot notification, ReservationV4ActionAvailabilityResult availability) { @@ -963,9 +998,322 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService || ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) { return List.of(); } + if (isRoomInformationEventCard(card) && displayPayload.path("room_information").isObject()) { + return roomInformationFields(card, displayPayload.path("room_information"), validationErrors, availability); + } return businessFields(card, displayPayload, validationErrors, availability); } + /** + * 构造 Room Information 稳定展示模型,避免前端直接解析 Agent target_order 或 raw event。 + */ + private ObjectNode roomInformationDisplayModel( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + ObjectNode displayPayload) { + String eventType = firstText(card.eventType(), textAt(displayPayload, "event_type")); + JsonNode targetOrder = displayPayload.path("target_order"); + String bookingType = firstText(textAt(targetOrder, "booking_type"), orderTask.targetBookingType()); + String locatorType = firstText(textAt(targetOrder, "locator_type"), orderTask.targetLocatorType()); + String locatorValue = firstText(textAt(targetOrder, "locator_value"), orderTask.targetLocatorValue()); + ObjectNode currentValues = currentRoomInformationProjection(orderTask, card); + ObjectNode proposedValues = proposedRoomInformationValues(eventType, bookingType, locatorType, locatorValue, displayPayload); + ObjectNode finalValues = finalRoomInformationValues(eventType, bookingType, locatorType, locatorValue, currentValues, proposedValues); + + ObjectNode model = objectMapper.createObjectNode(); + model.put("event_type", eventType); + model.put("booking_type", bookingType); + model.set("current_values", EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues); + model.set("proposed_values", EVENT_CANCEL_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : proposedValues); + model.set("final_values", finalValues); + model.set("change_summary", changeSummary(EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues, finalValues)); + model.set("group_booking_status_options", groupBookingStatusOptions()); + return model; + } + + /** + * 从同一本地订单更早已确认 Room Information 卡中提取当前订单投影。 + */ + private ObjectNode currentRoomInformationProjection( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot currentCard) { + ObjectNode current = objectMapper.createObjectNode(); + if (orderTask.orderId() == null) { + return current; + } + List orderTasks = workflowRepository.findOrderTasksByOrderIds( + orderTask.hotelId(), + List.of(orderTask.orderId())); + List orderTaskIds = orderTasks.stream().map(ReservationV4OrderTaskSnapshot::id).toList(); + List cards = workflowRepository.findTaskCardsByOrderTaskIds( + orderTask.hotelId(), + orderTaskIds); + Set priorTaskIds = new HashSet<>(); + for (ReservationV4OrderTaskSnapshot candidate : orderTasks) { + if (Objects.equals(candidate.id(), orderTask.id())) { + break; + } + priorTaskIds.add(candidate.id()); + } + for (ReservationV4TaskCardSnapshot card : cards) { + if (!priorTaskIds.contains(card.v4OrderTaskId()) + || !ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType()) + || !ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) { + continue; + } + ObjectNode finalValues = confirmedRoomInformationFinalValues(card.confirmedPayloadJson()); + mergeObject(current, finalValues); + } + normalizeRoomInformationDerivedFields(orderTask.targetBookingType(), current); + return roomInformationDisplayValues(orderTask.targetBookingType(), current, true); + } + + private ObjectNode confirmedRoomInformationFinalValues(String confirmedPayloadJson) { + JsonNode payload = parseJson(confirmedPayloadJson); + JsonNode finalValues = payload.path("room_information").path("final_values"); + if (finalValues.isObject()) { + return ((ObjectNode) finalValues).deepCopy(); + } + JsonNode businessFields = payload.path("business_fields"); + if (businessFields.isObject()) { + return ((ObjectNode) businessFields).deepCopy(); + } + if (payload.isObject()) { + return ((ObjectNode) payload).deepCopy(); + } + return objectMapper.createObjectNode(); + } + + private ObjectNode proposedRoomInformationValues( + String eventType, + String bookingType, + String locatorType, + String locatorValue, + JsonNode displayPayload) { + if (EVENT_CANCEL_BOOKING.equals(eventType)) { + return objectMapper.createObjectNode(); + } + JsonNode businessFields = displayPayload.path("business_fields"); + JsonNode source = businessFields.isObject() ? businessFields : displayPayload; + if (EVENT_UPDATE_BOOKING.equals(eventType) && businessFields.path("after").isObject()) { + source = businessFields.path("after"); + } + ObjectNode proposed = roomInformationDisplayValues(bookingType, source, EVENT_NEW_BOOKING.equals(eventType)); + if (EVENT_NEW_BOOKING.equals(eventType)) { + applyNewBookingNameDefaults(proposed, bookingType, locatorType, locatorValue, source); + } + normalizeRoomInformationDerivedFields(bookingType, proposed); + return proposed; + } + + private ObjectNode finalRoomInformationValues( + String eventType, + String bookingType, + String locatorType, + String locatorValue, + ObjectNode currentValues, + ObjectNode proposedValues) { + ObjectNode finalValues = EVENT_NEW_BOOKING.equals(eventType) + ? objectMapper.createObjectNode() + : currentValues.deepCopy(); + if (!EVENT_CANCEL_BOOKING.equals(eventType)) { + mergeObject(finalValues, proposedValues); + } + if (EVENT_NEW_BOOKING.equals(eventType)) { + applyNewBookingNameDefaults(finalValues, bookingType, locatorType, locatorValue, proposedValues); + } + normalizeRoomInformationDerivedFields(bookingType, finalValues); + return roomInformationDisplayValues(bookingType, finalValues, true); + } + + private void applyNewBookingNameDefaults( + ObjectNode values, + String bookingType, + String locatorType, + String locatorValue, + JsonNode source) { + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + if (!hasText(textAt(values, "group_block_name")) && LOCATOR_TYPE_GROUP_CODE.equals(locatorType)) { + values.put("group_block_name", locatorValue); + } + } else if (BOOKING_TYPE_FIT.equals(bookingType)) { + String fitName = firstText(textAt(values, "fit_name"), textAt(source, "guest_name")); + if (!hasText(fitName)) { + fitName = locatorValue; + } + if (hasText(fitName)) { + values.put("fit_name", fitName); + } + } + } + + private void normalizeRoomInformationDerivedFields(String bookingType, ObjectNode values) { + putNights(values); + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + values.put("breakfast_included", true); + if (!hasText(textAt(values, "group_booking_status"))) { + values.put("group_booking_status", GROUP_BOOKING_STATUS_TEN); + } + putGroupBookingStatusLabel(values); + } else if (BOOKING_TYPE_FIT.equals(bookingType)) { + Boolean breakfast = breakfastIncludedFromRateCode(textAt(values, "rate_code")); + if (breakfast != null) { + values.put("breakfast_included", breakfast); + } + } + } + + private void removeRoomInformationNonDisplayFields(String bookingType, ObjectNode values) { + ObjectNode filtered = roomInformationDisplayValues(bookingType, values, true); + values.removeAll(); + values.setAll(filtered); + } + + private void putNights(ObjectNode values) { + values.remove("nights"); + LocalDate arrivalDate = parseDate(textAt(values, "arrival_date")); + LocalDate departureDate = parseDate(textAt(values, "departure_date")); + if (arrivalDate != null && departureDate != null && !departureDate.isBefore(arrivalDate)) { + values.put("nights", ChronoUnit.DAYS.between(arrivalDate, departureDate)); + } + } + + private ObjectNode roomInformationDisplayValues(String bookingType, JsonNode source, boolean includeRateCode) { + ObjectNode values = objectMapper.createObjectNode(); + if (source == null || !source.isObject()) { + return values; + } + copyScalarField(source, values, "group_block_name"); + copyScalarField(source, values, "fit_name"); + copyScalarField(source, values, "arrival_date"); + copyScalarField(source, values, "departure_date"); + copyScalarField(source, values, "nights"); + if (includeRateCode) { + copyScalarField(source, values, "rate_code"); + } + copyScalarField(source, values, "breakfast_included"); + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + copyScalarField(source, values, "block_id"); + copyScalarField(source, values, "group_booking_status"); + copyScalarField(source, values, "group_booking_status_label"); + } else if (BOOKING_TYPE_FIT.equals(bookingType)) { + copyScalarField(source, values, "confirmation_number"); + } + copyRoomItems(source.path("room_items"), values); + return values; + } + + private void copyScalarField(JsonNode source, ObjectNode target, String fieldName) { + JsonNode value = source.get(fieldName); + if (value == null || value.isMissingNode() || value.isNull() || value.isContainerNode()) { + return; + } + target.set(fieldName, value); + } + + private void copyRoomItems(JsonNode roomItems, ObjectNode target) { + if (roomItems == null || !roomItems.isArray()) { + return; + } + ArrayNode sanitizedItems = objectMapper.createArrayNode(); + for (JsonNode item : roomItems) { + if (item == null || !item.isObject()) { + continue; + } + ObjectNode sanitizedItem = objectMapper.createObjectNode(); + copyScalarField(item, sanitizedItem, "room_type_code"); + copyScalarField(item, sanitizedItem, "room_count"); + sanitizedItems.add(sanitizedItem); + } + target.set("room_items", sanitizedItems); + } + + private void putGroupBookingStatusLabel(ObjectNode values) { + String status = textAt(values, "group_booking_status"); + if (hasText(status)) { + values.put("group_booking_status_label", GROUP_BOOKING_STATUS_LABELS.getOrDefault(status, status)); + } + } + + private Boolean breakfastIncludedFromRateCode(String rateCode) { + if (!hasText(rateCode)) { + return null; + } + String normalized = rateCode.toUpperCase(Locale.ROOT); + if (normalized.contains("RB")) { + return true; + } + if (normalized.contains("RO")) { + return false; + } + return null; + } + + private ArrayNode changeSummary(ObjectNode currentValues, ObjectNode finalValues) { + ArrayNode changes = objectMapper.createArrayNode(); + addChangeIfDifferent(changes, "group_block_name", currentValues, finalValues); + addChangeIfDifferent(changes, "fit_name", currentValues, finalValues); + addChangeIfDifferent(changes, "arrival_date", currentValues, finalValues); + addChangeIfDifferent(changes, "departure_date", currentValues, finalValues); + addChangeIfDifferent(changes, "nights", currentValues, finalValues); + addChangeIfDifferent(changes, "rate_code", currentValues, finalValues); + addChangeIfDifferent(changes, "breakfast_included", currentValues, finalValues); + addChangeIfDifferent(changes, "group_booking_status", currentValues, finalValues); + addChangeIfDifferent(changes, "room_items", currentValues, finalValues); + return changes; + } + + private void addChangeIfDifferent(ArrayNode changes, String fieldName, ObjectNode currentValues, ObjectNode finalValues) { + JsonNode before = currentValues.get(fieldName); + JsonNode after = finalValues.get(fieldName); + if (Objects.equals(before, after)) { + return; + } + if ((before == null || before.isMissingNode()) && (after == null || after.isMissingNode())) { + return; + } + ObjectNode change = changes.addObject(); + change.put("field", fieldName); + change.set("before", before == null ? NullNode.getInstance() : before); + change.set("after", after == null ? NullNode.getInstance() : after); + } + + private ArrayNode groupBookingStatusOptions() { + ArrayNode options = objectMapper.createArrayNode(); + addGroupBookingStatusOption(options, "TEN"); + addGroupBookingStatusOption(options, "DEF"); + addGroupBookingStatusOption(options, "INQ"); + return options; + } + + private void addGroupBookingStatusOption(ArrayNode options, String code) { + ObjectNode option = options.addObject(); + option.put("code", code); + option.put("label", GROUP_BOOKING_STATUS_LABELS.get(code)); + } + + private void mergeObject(ObjectNode target, JsonNode source) { + if (source == null || !source.isObject()) { + return; + } + Iterator> fields = source.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + target.set(field.getKey(), field.getValue()); + } + } + + private LocalDate parseDate(String value) { + if (!hasText(value)) { + return null; + } + try { + return LocalDate.parse(value); + } catch (Exception exception) { + return null; + } + } + private List basicInformationFields( String hotelId, JsonNode displayPayload, @@ -1030,6 +1378,164 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return displayPayload; } + private List roomInformationFields( + ReservationV4TaskCardSnapshot card, + JsonNode roomInformation, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability) { + String eventType = firstText(card.eventType(), textAt(roomInformation, "event_type")); + if (EVENT_CANCEL_BOOKING.equals(eventType)) { + return List.of(); + } + String bookingType = textAt(roomInformation, "booking_type"); + JsonNode finalValues = roomInformation.path("final_values"); + if (!finalValues.isObject()) { + return List.of(); + } + List fields = new ArrayList<>(); + if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) { + addRoomInformationField(fields, "group_block_name", "Group Block Name", finalValues, validationErrors, availability, + true, "text", null); + } + if (BOOKING_TYPE_FIT.equals(bookingType) && (EVENT_NEW_BOOKING.equals(eventType) || EVENT_UPDATE_BOOKING.equals(eventType))) { + addRoomInformationField(fields, "fit_name", "Fit Name", finalValues, validationErrors, availability, + true, "text", null); + } + addRoomInformationField(fields, "arrival_date", "入住日期", finalValues, validationErrors, availability, + true, "date", null); + addRoomInformationField(fields, "departure_date", "离店日期", finalValues, validationErrors, availability, + true, "date", null); + if (EVENT_NEW_BOOKING.equals(eventType)) { + addRoomInformationField(fields, "rate_code", "Rate Code", finalValues, validationErrors, availability, + true, "select", "reservation_v4_rate_code_catalog"); + } + addRoomItemsFields(fields, finalValues.path("room_items"), validationErrors, availability); + if (BOOKING_TYPE_FIT.equals(bookingType) && !finalValues.hasNonNull("breakfast_included")) { + addRoomInformationField(fields, "breakfast_included", "含早", finalValues, validationErrors, availability, + true, "checkbox", null); + } + if (BOOKING_TYPE_GROUP.equals(bookingType)) { + addRoomInformationField(fields, "group_booking_status", "Group Booking Status", finalValues, validationErrors, availability, + true, "select", "reservation_v4_group_booking_status_fixed"); + } + return fields; + } + + private void addRoomItemsFields( + List fields, + JsonNode roomItems, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability) { + if (roomItems == null || !roomItems.isArray()) { + return; + } + for (int index = 0; index < roomItems.size(); index++) { + JsonNode item = roomItems.get(index); + if (item == null || !item.isObject()) { + continue; + } + addRoomItemField(fields, index, "room_type_code", "房型代码", item, validationErrors, availability, + true, "select", "reservation_v4_room_type_catalog"); + addRoomItemField(fields, index, "room_count", "房间数", item, validationErrors, availability, + true, "number", null); + } + } + + private void addRoomInformationField( + List fields, + String fieldName, + String displayName, + JsonNode finalValues, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability, + boolean required, + String controlType, + String optionsSource) { + String pointer = "/room_information/final_values/" + escapeJsonPointer(fieldName); + String fieldPath = "room_information.final_values." + fieldName; + fields.add(field( + fieldPath, + pointer, + displayName, + finalValues.path(fieldName), + roomInformationFieldEditable(availability, validationErrors, pointer, finalValues.path(fieldName)), + required, + controlType, + availability.reviewable() ? "manual_review_only" : "confirm", + availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json", + optionsSource, + false, + validationMessages(validationErrors, pointer, fieldPath), + null)); + } + + private void addRoomItemField( + List fields, + int index, + String fieldName, + String displayName, + JsonNode roomItem, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability, + boolean required, + String controlType, + String optionsSource) { + String pointer = "/room_information/final_values/room_items/" + index + "/" + escapeJsonPointer(fieldName); + String fieldPath = "room_information.final_values.room_items." + index + "." + fieldName; + fields.add(field( + fieldPath, + pointer, + displayName, + roomItem.path(fieldName), + roomInformationFieldEditable(availability, validationErrors, pointer, roomItem.path(fieldName)), + required, + controlType, + availability.reviewable() ? "manual_review_only" : "confirm", + availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json", + optionsSource, + false, + validationMessages(validationErrors, pointer, fieldPath), + null)); + } + + private boolean roomInformationFieldEditable( + ReservationV4ActionAvailabilityResult availability, + JsonNode validationErrors, + String pointer, + JsonNode currentValue) { + if (!availability.editable()) { + return false; + } + if (!availability.reviewable()) { + return true; + } + if (validationErrorPointers(validationErrors).contains(pointer)) { + return true; + } + return isUnresolvedReviewLeaf(currentValue); + } + + private Set validationErrorPointers(JsonNode validationErrors) { + if (validationErrors == null || !validationErrors.isArray()) { + return Set.of(); + } + Set pointers = new HashSet<>(); + for (JsonNode item : validationErrors) { + String pointer = textAt(item, "field_pointer"); + if (pointer != null && pointer.startsWith("/")) { + pointers.add(pointer); + } + } + return pointers; + } + + private boolean isUnresolvedReviewLeaf(JsonNode currentValue) { + if (currentValue == null || currentValue.isMissingNode() || currentValue.isNull()) { + return true; + } + return currentValue.isTextual() && !hasText(currentValue.asText()); + } + private List businessFields( ReservationV4TaskCardSnapshot card, JsonNode displayPayload, @@ -1073,6 +1579,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return V4_BUSINESS_FIELD_ROOTS.contains(fieldName); } + private boolean isRoomInformationEventCard(ReservationV4TaskCardSnapshot card) { + if (card == null || !ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())) { + return false; + } + return EVENT_NEW_BOOKING.equals(card.eventType()) + || EVENT_UPDATE_BOOKING.equals(card.eventType()) + || EVENT_CANCEL_BOOKING.equals(card.eventType()); + } + private void collectBusinessLeafFields( List fields, ReservationV4TaskCardSnapshot card, diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java index a63a44b..c944c35 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -550,14 +550,17 @@ class ReservationFrontendQueryControllerTest { ReservationV4CardStatus.CONFIRMED.name(), null, "{}"); confirmV4TaskCard(roomCard.id(), """ { - "business_fields": { - "arrival_date": "2026-07-26", - "departure_date": "2026-07-29", - "rate_code": "BAR", - "room_items": [ - {"room_type_code": "RM1", "room_count": 2}, - {"room_type_code": "RM2", "room_count": 1} - ] + "room_information": { + "final_values": { + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "nights": 3, + "rate_code": "BAR", + "room_items": [ + {"room_type_code": "RM1", "room_count": 2}, + {"room_type_code": "RM2", "room_count": 1} + ] + } } } """, "frontend-query-admin", Instant.parse("2026-07-08T11:20:00Z")); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java index 1b87da7..6d51810 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.control; import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.not; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -324,6 +325,209 @@ class ReservationV4CommandControllerTest { .doesNotExist()); } + @Test + void shouldConfirmRoomInformationStableFinalValuesAndDropReadonlySubmittedFields() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-room-info-stable-001", + Instant.parse("2026-07-19T01:13:20Z"), + null, + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"NEW_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-COMMAND-RI-001"}, + "business_fields":{ + "event_type":"NEW_BOOKING", + "raw_evidence":"nested raw evidence should not persist", + "attachment_url":"https://oss.example.test/nested-command-private.pdf", + "pms_raw_response":{"debug":"NESTED-COMMAND-SHOULD-NOT-PERSIST"}, + "arrival_date":"2026-07-26", + "departure_date":"2026-07-29", + "rate_code":"GROUP", + "room_items":[{"room_type_code":"TWN","room_count":2,"raw_evidence":"NESTED-ROOM-RAW","pms_room_type_code":"NESTED-PMS-SHOULD-NOT-PERSIST"}] + } + } + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "room_information": { + "final_values": { + "group_block_name": "前端修正团队名", + "arrival_date": "2026-07-27", + "departure_date": "2026-07-30", + "nights": 99, + "rate_code": "GROUP", + "breakfast_included": false, + "group_booking_status": "DEF", + "room_items": [ + { + "room_type_code": "DBL", + "room_count": 3, + "pms_room_type_code": "SHOULD-NOT-PERSIST" + } + ], + "adult": 2, + "target_order": {"locator_value": "SHOULD-NOT-PERSIST"} + } + } + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.event_type") + .value("NEW_BOOKING")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.booking_type") + .value("GROUP")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.group_block_name") + .value("前端修正团队名")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.arrival_date") + .value("2026-07-27")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.nights") + .value(3)) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.breakfast_included") + .value(true)) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.group_booking_status") + .value("DEF")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code") + .value("DBL")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_count") + .value(3)) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].pms_room_type_code") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.adult") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.target_order") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.raw_evidence") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.attachment_url") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.pms_raw_response") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].raw_evidence") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].pms_room_type_code") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist()) + .andExpect(content().string(not(containsString("nested raw evidence")))) + .andExpect(content().string(not(containsString("https://oss.example.test/nested-command-private.pdf")))) + .andExpect(content().string(not(containsString("NESTED-COMMAND-SHOULD-NOT-PERSIST")))) + .andExpect(content().string(not(containsString("NESTED-ROOM-RAW")))) + .andExpect(content().string(not(containsString("NESTED-PMS-SHOULD-NOT-PERSIST")))); + } + + @Test + void shouldRejectRoomInformationConfirmWhenDatesAreInvalid() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-room-info-invalid-date-001", + Instant.parse("2026-07-19T01:13:25Z"), + null, + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"NEW_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-COMMAND-RI-DATE-001"}, + "business_fields":{ + "event_type":"NEW_BOOKING", + "arrival_date":"2026-07-26", + "departure_date":"2026-07-29", + "rate_code":"GROUP", + "room_items":[{"room_type_code":"TWN","room_count":2}] + } + } + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "room_information": { + "final_values": { + "arrival_date": "2026-07-30", + "departure_date": "2026-07-29", + "rate_code": "GROUP", + "group_block_name": "GRP-V4-COMMAND-RI-DATE-001", + "room_items": [ + {"room_type_code": "TWN", "room_count": 2} + ] + } + } + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[?(@=='room_information.final_values.departure_date: 离店日期不能早于入住日期。')]") + .exists()); + } + + @Test + void shouldRequireBreakfastWhenFitRateCodeCannotDeriveBreakfast() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-room-info-fit-breakfast-001", + Instant.parse("2026-07-19T01:13:30Z"), + null, + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"NEW_BOOKING", + "target_order":{"booking_type":"FIT","locator_type":"BOOKING_CODE","locator_value":"FIT-V4-COMMAND-RI-001"}, + "business_fields":{ + "event_type":"NEW_BOOKING", + "guest_name":"LI CHUNHONG", + "arrival_date":"2026-07-26", + "departure_date":"2026-07-29", + "rate_code":"FIT", + "room_items":[{"room_type_code":"KING","room_count":1}] + } + } + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "room_information": { + "final_values": { + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "FIT", + "room_items": [ + {"room_type_code": "KING", "room_count": 1} + ] + } + } + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[?(@=='room_information.final_values.breakfast_included: 含早不能为空。')]") + .exists()); + } + @Test void shouldRejectRepeatedCardConfirm() throws Exception { SeededOrderTask seeded = seedOrderTask( @@ -538,6 +742,120 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("修正房型目录")); } + @Test + void shouldResolveRoomInformationReviewWithStablePointer() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-room-info-stable-001", + Instant.parse("2026-07-19T01:22:06Z"), + 990000000000070104L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"NEW_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-RI-001"}, + "business_fields":{ + "event_type":"NEW_BOOKING", + "arrival_date":"2026-08-01", + "departure_date":"2026-08-03", + "rate_code":"GROUP", + "room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}] + } + } + """, + """ + [ + { + "field_path": "room_information.final_values.room_items.0.room_type_code", + "field_pointer": "/room_information/final_values/room_items/0/room_type_code", + "message": "房型代码不在第一版目录中。", + "detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不在第一版目录中。" + } + ] + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "reason": "按稳定 Room Information 模型修正房型", + "field_overrides": [ + { + "field_pointer": "/room_information/final_values/room_items/0/room_type_code", + "value": "RM2" + } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code") + .value("RM2")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.nights") + .value(2)) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].review_resolution.field_overrides[0].field_pointer") + .value("/room_information/final_values/room_items/0/room_type_code")); + } + + @Test + void shouldRejectRoomInformationReviewWhenPointerTargetsDerivedReadonlyField() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-room-info-readonly-001", + Instant.parse("2026-07-19T01:22:07Z"), + 990000000000070105L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"NEW_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-RI-002"}, + "business_fields":{ + "event_type":"NEW_BOOKING", + "arrival_date":"2026-08-01", + "departure_date":"2026-08-03", + "rate_code":"GROUP", + "room_items":[{"room_type_code":"TWN","room_count":2}] + } + } + """, + """ + [ + { + "field_path": "room_information.final_values.nights", + "field_pointer": "/room_information/final_values/nights", + "message": "Nights 由日期派生,不能人工写入。", + "detail": "room_information.final_values.nights: Nights 由日期派生,不能人工写入。" + } + ] + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "field_overrides": [ + {"field_pointer": "/room_information/final_values/nights", "value": 7} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY")); + } + @Test void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception { Long currentOrderId = 990000000000070111L; diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java index e2e9338..f1617f8 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java @@ -28,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTask import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; @@ -252,9 +253,9 @@ class ReservationV4QueryControllerTest { .value(contains(true))) .andExpect(jsonPath("$.business_cards[0].card_type").value("ROOM_INFORMATION")) .andExpect(jsonPath("$.business_cards[0].display_payload.event_type").value("NEW_BOOKING")) - .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_type_code')].control_type") + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].control_type") .value(contains("select"))) - .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_count')].control_type") + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_count')].control_type") .value(contains("number"))) .andExpect(jsonPath("$.business_cards[0].ai_payload_json").doesNotExist()) .andExpect(jsonPath("$.availability.read_only").value(false)) @@ -264,6 +265,273 @@ class ReservationV4QueryControllerTest { .andExpect(content().string(not(containsString("https://private.example.test")))); } + @Test + void shouldReturnRoomInformationDisplayModelForNewGroupBooking() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload( + "mail-v4-room-info-new-group-001", + Instant.parse("2026-07-18T03:01:00Z"), + "GROUP", + "GROUP_CODE", + "GRP-V4-RI-GROUP-001", + "NEW_BOOKING", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "event_type":"NEW_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-GROUP-001"}, + "raw_evidence":"raw evidence should not leak", + "attachment_url":"https://oss.example.test/private-room-info.pdf", + "pms_raw_response":{"confirmation_number":"SHOULD-NOT-LEAK"}, + "business_fields":{ + "event_type":"NEW_BOOKING", + "raw_evidence":"nested raw evidence should not leak", + "attachment_url":"https://oss.example.test/nested-private-room-info.pdf", + "pms_raw_response":{"debug":"NESTED-SHOULD-NOT-LEAK"}, + "arrival_date":"2026-07-26", + "departure_date":"2026-07-29", + "rate_code":"GROUP", + "room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":"NESTED-PMS-SHOULD-NOT-LEAK","raw_evidence":"NESTED-ROOM-RAW"}] + } + } + """, + null); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.event_type") + .value("NEW_BOOKING")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.booking_type") + .value("GROUP")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_block_name") + .value("GRP-V4-RI-GROUP-001")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.arrival_date") + .value("2026-07-26")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.departure_date") + .value("2026-07-29")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.nights") + .value(3)) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.rate_code") + .value("GROUP")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.breakfast_included") + .value(true)) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_booking_status") + .value("TEN")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_booking_status_label") + .value("TEN-Tentative")) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/group_block_name')].editable") + .value(contains(true))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/arrival_date')].control_type") + .value(contains("date"))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].options_source") + .value(contains("reservation_v4_rate_code_catalog"))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].options_source") + .value(contains("reservation_v4_room_type_catalog"))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/target_order/locator_value')]") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.target_order").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.business_fields").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.raw_evidence").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.attachment_url").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.pms_raw_response").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.raw_evidence").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.attachment_url").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.pms_raw_response").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.room_items[0].pms_room_type_code").doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.room_items[0].raw_evidence").doesNotExist()) + .andExpect(content().string(not(containsString("ai_payload_json")))) + .andExpect(content().string(not(containsString("raw evidence")))) + .andExpect(content().string(not(containsString("nested raw evidence")))) + .andExpect(content().string(not(containsString("https://oss.example.test/private-room-info.pdf")))) + .andExpect(content().string(not(containsString("https://oss.example.test/nested-private-room-info.pdf")))) + .andExpect(content().string(not(containsString("SHOULD-NOT-LEAK")))) + .andExpect(content().string(not(containsString("NESTED-SHOULD-NOT-LEAK")))) + .andExpect(content().string(not(containsString("NESTED-PMS-SHOULD-NOT-LEAK")))) + .andExpect(content().string(not(containsString("NESTED-ROOM-RAW")))); + } + + @Test + void shouldReturnRoomInformationUpdateCurrentProposedFinalAndNightsChangeSummary() throws Exception { + Long orderId = 990000000000777001L; + seedConfirmedRoomInformationProjection( + orderId, + "mail-v4-room-info-current-projection-001", + Instant.parse("2026-07-18T02:50:00Z"), + """ + { + "room_information":{ + "final_values":{ + "group_block_name":"GRP-V4-RI-UPDATE-001", + "arrival_date":"2026-07-26", + "departure_date":"2026-07-29", + "nights":3, + "rate_code":"GROUP", + "breakfast_included":true, + "group_booking_status":"TEN", + "room_items":[{"room_type_code":"TWN","room_count":2}] + } + } + } + """); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload( + "mail-v4-room-info-update-001", + Instant.parse("2026-07-18T03:02:00Z"), + orderId, + "GROUP", + "GROUP_CODE", + "GRP-V4-RI-UPDATE-001", + "UPDATE_BOOKING", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "event_type":"UPDATE_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-UPDATE-001"}, + "business_fields":{ + "event_type":"UPDATE_BOOKING", + "after":{ + "arrival_date":"2026-07-27", + "departure_date":"2026-07-31", + "rate_code":"BAR", + "room_items":[{"room_type_code":"DBL","room_count":3}] + } + } + } + """, + null); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.current_values.arrival_date") + .value("2026-07-26")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.proposed_values.arrival_date") + .value("2026-07-27")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.arrival_date") + .value("2026-07-27")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.departure_date") + .value("2026-07-31")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.nights") + .value(4)) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.rate_code") + .value("GROUP")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.change_summary[?(@.field=='rate_code')]") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.change_summary[?(@.field=='nights')].before") + .value(contains(3))) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.change_summary[?(@.field=='nights')].after") + .value(contains(4))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')]") + .doesNotExist()) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_count')].editable") + .value(contains(true))); + } + + @Test + void shouldOnlyMarkRoomInformationReviewProblemFieldsEditable() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload( + "mail-v4-room-info-review-fields-001", + Instant.parse("2026-07-18T03:02:30Z"), + "GROUP", + "GROUP_CODE", + "GRP-V4-RI-REVIEW-FIELDS-001", + "NEW_BOOKING", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + """ + { + "event_type":"NEW_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-REVIEW-FIELDS-001"}, + "business_fields":{ + "event_type":"NEW_BOOKING", + "arrival_date":"2026-08-01", + "departure_date":"2026-08-03", + "rate_code":"GROUP", + "room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}] + } + } + """, + null, + """ + [ + { + "field_path": "room_information.final_values.room_items.0.room_type_code", + "field_pointer": "/room_information/final_values/room_items/0/room_type_code", + "message": "房型代码不在第一版目录中。", + "detail": "room_information.final_values.room_items.0.room_type_code: 房型代码不在第一版目录中。" + } + ] + """); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].editable") + .value(contains(true))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/arrival_date')].editable") + .value(contains(false))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/departure_date')].editable") + .value(contains(false))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].editable") + .value(contains(false))); + } + + @Test + void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception { + Long orderId = 990000000000777002L; + seedConfirmedRoomInformationProjection( + orderId, + "mail-v4-room-info-current-projection-002", + Instant.parse("2026-07-18T02:55:00Z"), + """ + { + "room_information":{ + "final_values":{ + "group_block_name":"GRP-V4-RI-CANCEL-001", + "arrival_date":"2026-08-01", + "departure_date":"2026-08-05", + "nights":4, + "rate_code":"GROUP", + "breakfast_included":true, + "group_booking_status":"DEF", + "room_items":[{"room_type_code":"KING","room_count":1}] + } + } + } + """); + ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload( + "mail-v4-room-info-cancel-001", + Instant.parse("2026-07-18T03:03:00Z"), + orderId, + "GROUP", + "GROUP_CODE", + "GRP-V4-RI-CANCEL-001", + "CANCEL_BOOKING", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + { + "event_type":"CANCEL_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-RI-CANCEL-001"}, + "business_fields":{"event_type":"CANCEL_BOOKING"} + } + """, + null); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.event_type") + .value("CANCEL_BOOKING")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.current_values.arrival_date") + .value("2026-08-01")) + .andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.room_items[0].room_type_code") + .value("KING")) + .andExpect(jsonPath("$.business_cards[0].fields.length()").value(0)) + .andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true)); + } + @Test void shouldExposeReviewCardsAsReviewableButNotConfirmable() throws Exception { ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask( @@ -584,6 +852,157 @@ class ReservationV4QueryControllerTest { Instant receivedAt, String orderRef, String targetLocatorValue) { + return seedOrderTaskWithBusinessPayload( + source, + aiBatchId, + receivedAt, + orderRef, + null, + "GROUP", + "GROUP_CODE", + targetLocatorValue, + "NEW_BOOKING", + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]} + """, + null); + } + + private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload( + String externalMessageId, + Instant receivedAt, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String eventType, + String basicCardStatus, + String businessCardStatus, + String businessDisplayPayloadJson, + String confirmedPayloadJson) { + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID); + return seedOrderTaskWithBusinessPayload( + source, + 990000000000001001L + Math.abs(externalMessageId.hashCode()), + receivedAt, + "order-room-info-" + externalMessageId, + null, + targetBookingType, + targetLocatorType, + targetLocatorValue, + eventType, + basicCardStatus, + businessCardStatus, + businessDisplayPayloadJson, + confirmedPayloadJson); + } + + private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload( + String externalMessageId, + Instant receivedAt, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String eventType, + String basicCardStatus, + String businessCardStatus, + String businessDisplayPayloadJson, + String confirmedPayloadJson, + String businessValidationErrorsJson) { + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID); + return seedOrderTaskWithBusinessPayload( + source, + 990000000000001001L + Math.abs(externalMessageId.hashCode()), + receivedAt, + "order-room-info-" + externalMessageId, + null, + targetBookingType, + targetLocatorType, + targetLocatorValue, + eventType, + basicCardStatus, + businessCardStatus, + businessDisplayPayloadJson, + confirmedPayloadJson, + businessValidationErrorsJson); + } + + private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload( + String externalMessageId, + Instant receivedAt, + Long orderId, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String eventType, + String basicCardStatus, + String businessCardStatus, + String businessDisplayPayloadJson, + String confirmedPayloadJson) { + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt, HOTEL_ID); + return seedOrderTaskWithBusinessPayload( + source, + 990000000000002001L + Math.abs(externalMessageId.hashCode()), + receivedAt, + "order-room-info-" + externalMessageId, + orderId, + targetBookingType, + targetLocatorType, + targetLocatorValue, + eventType, + basicCardStatus, + businessCardStatus, + businessDisplayPayloadJson, + confirmedPayloadJson); + } + + private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload( + SourceMessageCaptureResult source, + Long aiBatchId, + Instant receivedAt, + String orderRef, + Long orderId, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String eventType, + String basicCardStatus, + String businessCardStatus, + String businessDisplayPayloadJson, + String confirmedPayloadJson) { + return seedOrderTaskWithBusinessPayload( + source, + aiBatchId, + receivedAt, + orderRef, + orderId, + targetBookingType, + targetLocatorType, + targetLocatorValue, + eventType, + basicCardStatus, + businessCardStatus, + businessDisplayPayloadJson, + confirmedPayloadJson, + null); + } + + private ReservationV4OrderTaskSnapshot seedOrderTaskWithBusinessPayload( + SourceMessageCaptureResult source, + Long aiBatchId, + Instant receivedAt, + String orderRef, + Long orderId, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String eventType, + String basicCardStatus, + String businessCardStatus, + String businessDisplayPayloadJson, + String confirmedPayloadJson, + String businessValidationErrorsJson) { LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( HOTEL_ID, @@ -591,9 +1010,9 @@ class ReservationV4QueryControllerTest { aiBatchId, orderRef, 1, - null, - "GROUP", - "GROUP_CODE", + orderId, + targetBookingType, + targetLocatorType, targetLocatorValue, ReservationV4TargetResolutionStatus.RESOLVED.name(), ReservationV4OrderTaskStatus.OPEN.name(), @@ -604,16 +1023,61 @@ class ReservationV4QueryControllerTest { {"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Business"}} """); insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, - ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ + basicCardStatus, reviewStatusFor(basicCardStatus), """ {"card_type":"BASIC_INFORMATION","order_ref":"order-1","basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}} """); - insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, - ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ - {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]} - """); + ReservationV4TaskCardSnapshot businessCard = insertCard( + orderTask, + ReservationV4CardType.ROOM_INFORMATION.name(), + eventType, + 1, + 30, + businessCardStatus, + reviewStatusFor(businessCardStatus), + businessDisplayPayloadJson, + businessValidationErrorsJson); + if (confirmedPayloadJson != null) { + workflowRepository.confirmTaskCardWithVersion( + HOTEL_ID, + businessCard.id(), + businessCard.version(), + confirmedPayloadJson, + "v4-query-admin", + now); + workflowRepository.updateOrderTaskStatus( + HOTEL_ID, + orderTask.id(), + ReservationV4OrderTaskStatus.COMPLETED.name(), + now); + } return orderTask; } + private void seedConfirmedRoomInformationProjection( + Long orderId, + String externalMessageId, + Instant receivedAt, + String confirmedPayloadJson) { + seedOrderTaskWithBusinessPayload( + externalMessageId, + receivedAt, + orderId, + "GROUP", + "GROUP_CODE", + "GRP-V4-RI-PROJECTION", + "NEW_BOOKING", + ReservationV4CardStatus.CONFIRMED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + """ + {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","business_fields":{"event_type":"NEW_BOOKING"}} + """, + confirmedPayloadJson); + } + + private String reviewStatusFor(String cardStatus) { + return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(cardStatus) ? "PENDING" : null; + } + private ReservationV4OrderTaskSnapshot seedReviewRequiredOrderTask(String externalMessageId, Instant receivedAt) { SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Review", receivedAt, HOTEL_ID); LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); @@ -669,7 +1133,7 @@ class ReservationV4QueryControllerTest { LocalDateTime.ofInstant(receivedAt.plusSeconds(20), ZoneOffset.UTC))); } - private void insertCard( + private ReservationV4TaskCardSnapshot insertCard( ReservationV4OrderTaskSnapshot orderTask, String cardType, String eventType, @@ -678,7 +1142,29 @@ class ReservationV4QueryControllerTest { String cardStatus, String reviewStatus, String displayPayloadJson) { - workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( + return insertCard( + orderTask, + cardType, + eventType, + sourceEventIndex, + sortOrder, + cardStatus, + reviewStatus, + displayPayloadJson, + null); + } + + private ReservationV4TaskCardSnapshot insertCard( + ReservationV4OrderTaskSnapshot orderTask, + String cardType, + String eventType, + Integer sourceEventIndex, + Integer sortOrder, + String cardStatus, + String reviewStatus, + String displayPayloadJson, + String validationErrorsJson) { + return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( HOTEL_ID, orderTask.id(), orderTask.sourceMessageId(), @@ -693,7 +1179,7 @@ class ReservationV4QueryControllerTest { {"private_url":"https://private.example.test/raw","raw":"must stay internal"} """, displayPayloadJson, - null, + validationErrorsJson, orderTask.createdAt())); }