From 39518b3cf28bcb4d83336bb5d7e7f09e15f82a70 Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 12:25:06 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20V4=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=92=8C=E5=AD=97=E6=AE=B5=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 16 +- .../backend-to-frontend-notes.md | 15 +- .../M002-order-task-workflow-v3.md | 7 +- .../M002-v4-agent-callback-field-contract.md | 11 +- ...002-v4-order-task-card-domain-model-cp2.md | 27 +- .../security-access-control-boundary.md | 4 +- .../dto/ReservationV4AccountCatalogItem.java | 17 + .../ReservationV4TaskCardFieldResult.java | 49 +++ .../result/ReservationV4TaskCardResult.java | 3 + .../ReservationV4DirectoryService.java | 25 ++ ...ixedReservationV4DirectoryServiceImpl.java | 70 ++++ .../impl/ReservationV4CommandServiceImpl.java | 154 +++++++- .../impl/ReservationV4QueryServiceImpl.java | 364 +++++++++++++++++- .../ReservationV4TaskIntakeServiceImpl.java | 156 +++++++- .../ReservationV4CommandControllerTest.java | 194 +++++++++- .../ReservationV4QueryControllerTest.java | 15 +- .../SuperAgentTaskResultControllerTest.java | 50 +++ 17 files changed, 1124 insertions(+), 53 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 66d72d4..7e1ebe0 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认和复核解阻并行 | -| 当前重点 | M002 V4 CP7 已开放 V4 复核解阻与复核场景订单归属确认;后续准备做 V4 前端页面和目录校验 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻和目录校验并行 | +| 当前重点 | M002 V4 CP8 已开放固定种子目录校验和 V4 卡片 fields[] 字段白名单;后续准备做 V4 前端页面或真实目录 / PMS lookup | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP7-v4-review-resolution-and-order-ownership-confirmation` -- 状态:Done,已实现 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`。 -- 目标:让 V4 `REVIEW_REQUIRED` 卡可以在原卡片上提交字段修正、复核说明和复核场景订单归属确认,通过后直接 `CONFIRMED` 并写当前登录用户审计。 -- 边界:CP7 不实现普通任务任意切换订单、真实 OPERA / OHIP、前端页面和历史数据迁移;已 `RESOLVED` 到具体订单的 V4 订单任务不能通过复核接口换绑到其它订单,复核字段覆盖按显式 `missing_fields[]` 或未解决叶子值收口。 +- 名称:`M002-V4-CP8-controlled-directory-and-card-fields-whitelist-v1` +- 状态:Done,已实现 V4 Account 固定目录校验、Market / Source 派生、Room Type / Rate Code 第一版固定种子校验和 V4 卡片 `fields[]` 字段白名单。 +- 目标:让 V4 入站、确认和复核写操作具备第一版目录边界,并让前端从 `GET /api/reservation/order-tasks/{orderTaskId}` 的卡片 `fields[]` 获取可展示 / 可编辑字段。 +- 边界:CP8 不实现真实 PMS 目录、Rate Code 配置中心、通用 lookup API、前端页面、普通任务任意切换订单、真实 OPERA / OHIP 和历史数据迁移。 ## 2. 当前优先级 @@ -34,12 +34,12 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认。V4 前端页面仍未完成代码实现。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成固定种子目录校验和 V4 卡片 `fields[]` 字段白名单。V4 前端页面仍未完成代码实现。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,应优先进入 V4 前端页面接入 checkpoint,或进入受控目录第一版校验 checkpoint。 +- 后续如继续做 M002 V4,应优先进入 V4 前端页面接入 checkpoint,或进入真实目录 / PMS lookup / OPERA 映射前置设计 checkpoint。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 53eaeb0..352d410 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -56,10 +56,10 @@ | `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 会话接口。 | +| `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 会话接口。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;确认后卡片 `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` 必填,且必须是当前酒店下真实可见订单;成功后卡片 `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 必须先确认,业务卡第一版不强制逐张顺序确认;确认前会做 Account / Room Type / Rate 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` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `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/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | | `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;旧源邮件只读通知卡字段列表和 OPERA 操作列表为空;V3 结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 | @@ -464,15 +464,18 @@ 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 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[]` 白名单和第一版固定目录校验。 - V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染。 - V4 可映射 event 现阶段仍保留现有任务详情结构作为过渡兼容;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。 - V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。该字段只返回白名单诊断字段,不返回完整 AI payload、邮件正文、附件 URL 或 raw evidence。 - V4 包级契约错误只会保存在 AI transition 中,不会出现在普通任务列表;V4 event 级契约错误如果同批次存在其它业务任务,前端仍按任务详情里的 `adapter_contract_errors[]` 只读展示诊断信息。 - M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表、业务订单任务列表 / 详情和 S10/S99 来源通知详情已实现。 -- 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 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[]` 字段白名单。 - V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false`、`editable=true`、`confirmable=false`、`reviewable=true`、`readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`。 -- `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` 必须来自当前卡允许编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段;展示 payload 有 `missing_fields[]` 时只提交清单里的 pointer,没有显式清单时只提交当前值为 `null` / 空字符串的未解决叶子字段。前端不要提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段,也不能替换整个对象 / 数组。 +- CP8 起 V4 入站阶段也会做目录校验:Account 缺失或不存在时 Basic Information 卡直接 `REVIEW_REQUIRED`;业务卡已有 `room_items[].room_type_code` 或 `rate_code` 但不在固定目录时,业务卡也会直接 `REVIEW_REQUIRED`,错误会回显在 `fields[].validation_errors`。 +- `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` 必须来自当前卡允许编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段;展示 payload 有 `missing_fields[]` 时只提交清单里的 pointer,没有显式清单时只提交当前值为 `null` / 空字符串的未解决叶子字段;如果是目录校验错误,也可以提交后端 `fields[].validation_errors` 对应的字段 pointer。前端不要提交来源邮件、路由、`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`;前端不要自行补未返回字段。 +- V4 CP8 仍没有独立目录 lookup API。前端第一版可先按后端文档固定种子渲染 Account / Room Type / Rate Code 选项;后续真实目录或管理后台目录接入时再切换为 lookup。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 - V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 0dfb18a..9b13d70 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -444,6 +444,7 @@ V3 P0.1 不做以下事项: - M002 V4 CP4 已补充新模型写入:普通 V4 业务包会额外创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;只有契约错误、没有合法业务 event 的包不会创建 V4 订单任务。 - M002 V4 CP4 后,V4 `route_code=S10/S99` 写入 `workflow_reservation_v4_source_notification`,不再创建隐藏技术订单或旧任务;V3 S10/S99 和旧 S000/S999 仍保留历史兼容链路。 - M002 V4 CP5 已开放 V4 查询接口:`GET /api/reservation/workbench-items`、`GET /api/reservation/order-tasks`、`GET /api/reservation/order-tasks/{orderTaskId}`、`GET /api/reservation/source-notifications/{notificationId}`。 +- M002 V4 CP6/CP7/CP8 已开放 V4 普通卡片确认、S10/S99 来源通知 ack、`REVIEW_REQUIRED` 卡复核解阻、复核场景订单归属确认、固定种子目录校验和 V4 卡片 `fields[]` 字段白名单。 - V4 包级契约错误在 `source_message.source_message_id` 可定位时只写 `adapter_contract_error` transition,不创建订单、任务或用户可处理卡;`source_message_id` 缺失或 SourceMessage 不存在时仍返回明确错误。 - V4 `PAYMENT.attachment_ids[]` 必须匹配 `source_message.attachments[].id`;V4 `UPDATE_BOOKING` 不接受 `rate_code` 或 `after.rate_code`;这类契约错误只落 `adapter_contract_error` transition,不创建用户可处理业务任务。 - 40 条 P0.1 路由枚举 / 稳定配置。 @@ -477,12 +478,12 @@ M002 V4 CP2 设计文档已落地: - 文档路径:`docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`。 - 设计内容:SourceMessage 邮件展示卡、S10/S99 来源通知、`source_message_id + order_ref` 订单任务、Basic Information 独立卡、每个 V4 event 的业务卡、卡片确认 / 复核 / 锁定、同订单阻塞、表结构草案和后续接口草案。 - 已确认:V4 工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知使用 `/api/reservation/source-notifications/**`;S10/S99 采用来源通知模型;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认,其它业务卡第一版不强制逐张确认;Account / Market / Source 目录第一版使用后端固定种子数据。 -- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型、CP5 查询接口和 CP6 普通卡片确认 / S10/S99 ack 已完成;V4 复核接口和前端页面仍未实现。 +- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型、CP5 查询接口、CP6 普通卡片确认 / S10/S99 ack、CP7 复核解阻和 CP8 固定目录 / 字段白名单已完成;V4 前端页面仍未实现。 仍需后续 checkpoint 实现: - V4 表结构和 Repository 落地已完成第一版:新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,并提供 Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 version 乐观锁更新基础方法。 - V4 入站写入新模型已完成第一版:真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知。 -- V4 复核解阻、复核场景订单归属确认和前端页面模型切换。 -- V4 前端页面模型、任务详情字段矩阵和目录校验完全切换。 +- V4 前端页面模型切换。 +- 真实 PMS 目录、Rate Code 配置中心或通用 lookup API 接入;当前 CP8 只是后端固定种子目录第一版。 - 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。 diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index 6906eb0..0de1ae4 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -193,6 +193,14 @@ Agent 给出非空 `account_code`,但信息系统运行时目录不存在该 不同 `order_ref` 可以对应不同 Account。 +后端 CP8 第一版已落地固定种子目录: + +- Account:`QBD_TRAVEL`、`LIAN_TAI`、`HANATOUR_TD`。 +- Market / Source:由 Account 派生,当前分别为 `LEISURE` / `TRAVEL_AGENT`。 +- SuperAgent 不需要输出 `market_code`、`source_code`、`account_name`,也不要输出显示名称替代 `account_code`。 +- 如果 SuperAgent 输出的 `account_code` 不在上述目录,后端会创建 `REVIEW_REQUIRED` Basic Information 卡,并在 `validation_errors_json` / 查询 `fields[].validation_errors` 中返回目录错误。 +- 如果 SuperAgent 输出的业务卡 `room_items[].room_type_code` 或 `rate_code` 不在上述固定目录,后端会创建 `REVIEW_REQUIRED` 业务卡,并在 `validation_errors_json` / 查询 `fields[].validation_errors` 中返回目录错误;用户可通过 V4 复核解阻接口提交对应字段 pointer 修正。 + ## 8. message_events 公共字段 ### 8.1 结构 @@ -732,11 +740,12 @@ AI 回调包 - CP5 查询接口强制 Bearer 登录、`RESERVATION_TASK_READ` 和酒店访问权;不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 - `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` 确认 V4 卡片,强制 Bearer 登录、`RESERVATION_TASK_CONFIRM`、酒店访问权和 version 并发校验。 - `POST /api/reservation/source-notifications/{notificationId}/ack` 确认 V4 S10/S99 来源通知已读 / 已处理,强制 Bearer 登录、`RESERVATION_TASK_CONFIRM`、酒店访问权和 version 并发校验。 +- CP7 已完成 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻和复核场景订单归属确认。 +- CP8 已完成 Account 固定目录校验、Market / Source 派生、Room Type / Rate Code 第一版固定种子校验和 V4 任务卡 `fields[]` 白名单。 当前仍未完成: - 普通 V4 业务包暂时仍保留旧 V3 任务状态、草稿和 OPERA 模拟骨架兼容,便于前端过渡;V4 写接口和前端页面完成后再逐步废弃旧链路。 -- V4 复核解阻写接口。 - 尚未接入真实 PMS / OPERA / OHIP。 - 尚未改造前端 V4 页面模型。 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 352d6d7..5febd3b 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 @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP7,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、V4 卡片确认接口、S10/S99 来源通知 ack 接口,以及 V4 `REVIEW_REQUIRED` 卡复核解阻接口。 +截至 CP8,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口,以及 Account / Room Type / Rate Code 固定种子目录第一版校验和卡片 `fields[]` 白名单。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -26,7 +26,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 | --- | --- | --- | | 入站识别 | 已识别 `route_code`、`source_message`、`order_contexts[]`、`message_events[]` | CP4 已把有合法 event 的 `order_ref` 建成订单任务聚合;CP5 已开放 V4 安全查询接口 | | SourceMessage | 已按 `source_message.source_message_id` 反查 SourceMessage Inbox | CP4 已固定生成普通业务包内邮件展示卡;邮件正文完整读取仍走 SourceMessage 会话接口 | -| Basic Information | 已写入 V4 Basic Information 独立卡 | CP6 已支持确认并锁定;目录校验和复核写接口仍待后续 checkpoint | +| Basic Information | 已写入 V4 Basic Information 独立卡 | CP6 已支持确认并锁定;CP7 已支持复核解阻;CP8 已支持 Account 固定目录校验、Market / Source 派生和 `fields[]` 白名单 | | 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task`,并已额外创建 V4 业务卡 | 旧任务链路仍作前端过渡兼容,后续 V4 查询和写接口完成后再逐步废弃 | | 技术错误 | 已落 `adapter_contract_error` transition | 已符合目标方向:不创建用户可处理卡 | | 草稿 / READY / OPERA | 仍复用 V3 草稿、READY 和 OPERA 模拟骨架 | V4 新数据确认口径是不保存草稿、确认后锁定、当前不生成 OPERA | @@ -171,9 +171,9 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 - 不改写 AI 原始 payload。 - 用户修正写入 `review_resolution_json` 和 `confirmed_payload_json`。 -- CP7 第一版只校验 JSON Pointer 是否指向当前卡允许编辑字段;目录值必须来自信息系统受控目录的校验放到受控目录 checkpoint。 +- CP8 起确认和复核都会校验目录字段;Basic Information 的 `account_code` 必须来自第一版 Account 固定目录,通过后后端派生 `market_code` / `source_code`。 - 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。 -- `field_overrides[].field_pointer` 必须是当前卡 `display_payload_json` 中允许编辑的 RFC 6901 JSON Pointer;如果当前卡展示 payload 中存在显式 `missing_fields[]`,只允许提交该清单内的 pointer;如果没有显式清单,第一版只允许 `basic_information.*` 或 `business_fields.*` 下已经存在且值为 `null` / 空字符串的未解决叶子字段,不允许替换对象或数组。 +- `field_overrides[].field_pointer` 必须是当前卡 `display_payload_json` 中允许编辑的 RFC 6901 JSON Pointer;如果当前卡展示 payload 中存在显式 `missing_fields[]`,只允许提交该清单内的 pointer;如果没有显式清单,第一版只允许 `basic_information.*` 或 `business_fields.*` 下已经存在且值为 `null` / 空字符串的未解决叶子字段,或后端 `validation_errors_json` 指向的目录错误字段,不允许替换对象或数组。 - 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。 - 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。 - 如果订单任务已经有 `order_id` 且 `target_resolution_status=RESOLVED`,复核请求不能提交不同的 `confirmed_order_id`,否则返回 `V4_ORDER_REBIND_NOT_ALLOWED`;普通任务任意切换订单继续后置。 @@ -195,6 +195,18 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 第一版 Account / Market / Source 目录使用后端固定种子数据,不依赖 SuperAgent 动态提供目录文件。后续如目录由管理后台维护或从外部系统同步,应以专项 checkpoint 设计目录版本、变更审计和回放影响。 +CP8 第一版固定种子: + +| 目录 | 第一版代码 | +| --- | --- | +| Account | `QBD_TRAVEL`、`LIAN_TAI`、`HANATOUR_TD` | +| Market | 由 Account 派生,当前固定为 `LEISURE` | +| Source | 由 Account 派生,当前固定为 `TRAVEL_AGENT` | +| Room Type | `TWN`、`KING`、`DBL`、`SGL`、`TRP`、`RM1`、`RM2`、`RM3` | +| Rate Code | `BAR`、`RACK`、`PACKAGE`、`GROUP`、`FIT` | + +说明:Room Type / Rate Code 当前只作为确认和字段控件的第一版校验 / 选项来源代码;尚未接真实 PMS 房型目录、Rate Code 配置中心或通用 lookup API。 + ## 8. 订单归属和 target_order ### 8.1 order_ref 和 target_order 分工 @@ -599,6 +611,8 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm - 请求 JSON 必须携带 `version`;`confirmed_payload` 可选,未传时后端使用当前展示 payload 作为确认快照。 - 只提交当前卡允许编辑的字段或当前卡确认快照。 +- Basic Information 确认时 `basic_information.account_code` 必须是第一版 Account 目录值;后端确认前会派生 `account_name`、`market_code` 和 `source_code` 写入 `confirmed_payload_json`。 +- 业务卡确认时,第一版会校验已有 `rate_code`、`room_items[].room_type_code` 是否在固定目录中;失败返回 `V4_FIELD_VALIDATION_FAILED`。 - 不提交草稿。 - 必须带 `version` 做并发校验。 - 后端确认后卡片 `CONFIRMED` 并锁定。 @@ -621,7 +635,8 @@ 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` 只允许指向当前卡展示 payload 中的可编辑业务字段,不允许指向 `source_message`、`route_code`、`card_type`、`target_order`、`order_ref`、`missing_fields`、`manual_review`、`raw_evidence`、`validation_errors` 等只读诊断字段。 -- 第一版允许的写入容器是 `basic_information` 和 `business_fields`;如果展示 payload 中存在显式 `missing_fields[]`,只允许提交清单中的 pointer;否则只能改已存在且值为 `null` / 空字符串的叶子字段,不能修改已有有效值、替换整个对象 / 数组或新增未知字段。 +- 第一版允许的写入容器是 `basic_information` 和 `business_fields`;如果展示 payload 中存在显式 `missing_fields[]`,只允许提交清单中的 pointer;否则只能改已存在且值为 `null` / 空字符串的叶子字段,或后端 `validation_errors_json` 指向的目录错误字段,不能修改其它已有有效值、替换整个对象 / 数组或新增未知字段。 +- 复核提交后同样执行目录校验;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。 - 解阻过程不改写 `ai_payload_json`;用户修正写入 `review_resolution_json`,最终确认快照写入 `confirmed_payload_json`。 @@ -704,7 +719,7 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | M002-V4-CP5 | V4 查询接口 | 已完成:工作台统一列表、订单任务列表、订单任务详情和来源通知详情查询接口;订单详情 V4 专属时间线后续再做 | | M002-V4-CP6 | V4 卡片确认和 S10/S99 ack | 已完成:不保存草稿,支持普通卡片确认、确认后锁定、Basic Information 前置约束、同订单前置任务写侧阻塞、业务审计、version 并发校验和 S10/S99 来源通知确认 | | M002-V4-CP7 | V4 复核解阻与订单归属确认 | 已完成:支持 `REVIEW_REQUIRED` 卡字段修正、复核说明、复核场景订单归属确认、version 并发校验、直接 `CONFIRMED`、审计和 availability.reviewable | -| M002-V4-CP8 | 受控目录第一版 | Account、RoomType、RateCode、Department 固定目录或版本化快照校验 | +| M002-V4-CP8 | 受控目录第一版 | 已完成第一版:Account 固定目录校验、Market / Source 派生、RoomType / RateCode 固定种子校验、V4 任务卡 `fields[]` 字段白名单 | | M002-V4-CP9 | V4 前端契约收口 | 字段、控件、availability、错误展示和旧任务入口切换 | | M002-V4-CP10 | 旧 V3 / V2 能力收口评估 | 明确哪些兼容入口可以关闭,哪些仍保留只读历史 | diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index fc6a706..201825e 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -53,8 +53,8 @@ | `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload | | `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 | | `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload | 必须写业务审计,actor 使用当前登录用户 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡可编辑业务字段,优先按显式 `missing_fields[]` 或未解决叶子值收口;订单归属未解决时必须提交当前酒店下真实可见订单 ID,已 `RESOLVED` 的订单任务不得换绑不同订单 | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 AI 原始 payload | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6/CP8;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 + 第一版目录校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload;Basic Account、Room Type、Rate Code 目录错误返回 `V4_FIELD_VALIDATION_FAILED` | 必须写业务审计,actor 使用当前登录用户 | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7/CP8;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 + 第一版目录校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡可编辑业务字段,优先按显式 `missing_fields[]`、未解决叶子值或 `validation_errors_json` 指向字段收口;订单归属未解决时必须提交当前酒店下真实可见订单 ID,已 `RESOLVED` 的订单任务不得换绑不同订单 | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 AI 原始 payload | | `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 `route_code=S10/S99` 的 V4 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞;重复 ack 幂等返回当前状态且不新增审计 | 首次确认必须写业务审计,记录已读 / 已处理确认,actor 使用当前登录用户 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计和原因 | | `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计 | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java new file mode 100644 index 0000000..3653184 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +/** + * Reservation V4 第一版 Account 目录项。当前为后端固定种子,后续可替换为酒店目录表。 + * + * @param accountCode Account 稳定代码,SuperAgent 与前端提交均使用该值 + * @param accountName Account 显示名称 + * @param marketCode Account 派生的 Market 代码 + * @param sourceCode Account 派生的 Source 代码 + */ +public record ReservationV4AccountCatalogItem( + String accountCode, + String accountName, + String marketCode, + String sourceCode +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java new file mode 100644 index 0000000..360e415 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardFieldResult.java @@ -0,0 +1,49 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; + +/** + * Reservation V4 任务卡字段白名单结果。前端只应基于 fields[] 渲染可编辑字段。 + * + * @param fieldPath 点分字段路径,便于前端日志和表单绑定 + * @param fieldPointer RFC 6901 JSON Pointer,复核和确认写入优先使用 + * @param displayName 字段中文展示名 + * @param value 当前回显值 + * @param editable 当前卡片状态下是否允许编辑 + * @param required 是否第一版必填 + * @param controlType 前端控件类型 + * @param editScope 编辑范围,区分普通确认、人工复核和只读 + * @param writeTarget 用户修改值写入目标 + * @param optionsSource 选项来源,目录未接口化前使用稳定代码 + * @param rawReadonly 是否来源、派生或诊断字段,只读展示 + * @param validationErrors 当前字段对应的后端校验错误 + * @param controlHint 给前端的控件补充提示 + */ +public record ReservationV4TaskCardFieldResult( + @JsonProperty("field_path") + String fieldPath, + @JsonProperty("field_pointer") + String fieldPointer, + @JsonProperty("display_name") + String displayName, + JsonNode value, + Boolean editable, + Boolean required, + @JsonProperty("control_type") + String controlType, + @JsonProperty("edit_scope") + String editScope, + @JsonProperty("write_target") + String writeTarget, + @JsonProperty("options_source") + String optionsSource, + @JsonProperty("raw_readonly") + Boolean rawReadonly, + @JsonProperty("validation_errors") + List validationErrors, + @JsonProperty("control_hint") + String controlHint +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardResult.java index 48aadb2..cf3121d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardResult.java @@ -3,6 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import java.time.OffsetDateTime; +import java.util.List; /** * Reservation V4 任务卡查询结果。只返回展示、确认和复核结果 JSON,不返回 ai_payload_json。 @@ -18,6 +19,7 @@ import java.time.OffsetDateTime; * @param confirmedPayload 用户确认后的 JSON * @param reviewResolution 复核解阻结果 JSON * @param validationErrors 后端校验错误 JSON + * @param fields 当前卡片字段展示 / 编辑白名单 * @param confirmedBy 确认人 * @param confirmedAt 确认 UTC 时间 * @param version 卡片乐观锁版本 @@ -48,6 +50,7 @@ public record ReservationV4TaskCardResult( JsonNode reviewResolution, @JsonProperty("validation_errors") JsonNode validationErrors, + List fields, @JsonProperty("confirmed_by") String confirmedBy, @JsonProperty("confirmed_at") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java new file mode 100644 index 0000000..9b39193 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; +import java.util.Optional; + +/** + * Reservation V4 第一版目录服务。封装 Account、房型和 Rate Code 的目录边界。 + */ +public interface ReservationV4DirectoryService { + + /** + * 按 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。 + */ + Optional findAccount(String accountCode); + + /** + * 判断房型代码是否在第一版固定目录中;空值由业务字段必填规则处理。 + */ + boolean isKnownRoomTypeCode(String roomTypeCode); + + /** + * 判断 Rate Code 是否在第一版固定目录中;空值由业务字段必填规则处理。 + */ + boolean isKnownRateCode(String rateCode); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java new file mode 100644 index 0000000..87ff27e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java @@ -0,0 +1,70 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** + * Reservation V4 固定种子目录实现。开发阶段先用代码种子,后续可替换为数据库目录。 + */ +@Service +public class FixedReservationV4DirectoryServiceImpl implements ReservationV4DirectoryService { + + private static final Map ACCOUNT_CATALOG = Map.of( + "QBD_TRAVEL", new ReservationV4AccountCatalogItem( + "QBD_TRAVEL", + "Q.B.D. TRAVEL GROUP CO., LTD", + "LEISURE", + "TRAVEL_AGENT"), + "LIAN_TAI", new ReservationV4AccountCatalogItem( + "LIAN_TAI", + "LIAN TAI TRAVEL (THAILAND) CO., LTD.", + "LEISURE", + "TRAVEL_AGENT"), + "HANATOUR_TD", new ReservationV4AccountCatalogItem( + "HANATOUR_TD", + "HANATOUR TD CO., LTD.", + "LEISURE", + "TRAVEL_AGENT") + ); + private static final Set ROOM_TYPE_CODES = Set.of("TWN", "KING", "DBL", "SGL", "TRP", "RM1", "RM2", "RM3"); + private static final Set RATE_CODES = Set.of("BAR", "RACK", "PACKAGE", "GROUP", "FIT"); + + /** + * 按 Account Code 查询固定种子目录,比较保持大小写敏感。 + */ + @Override + public Optional findAccount(String accountCode) { + String normalized = trimToNull(accountCode); + return normalized == null ? Optional.empty() : Optional.ofNullable(ACCOUNT_CATALOG.get(normalized)); + } + + /** + * 判断房型代码是否存在于第一版固定目录。 + */ + @Override + public boolean isKnownRoomTypeCode(String roomTypeCode) { + String normalized = trimToNull(roomTypeCode); + return normalized != null && ROOM_TYPE_CODES.contains(normalized); + } + + /** + * 判断 Rate Code 是否存在于第一版固定目录。 + */ + @Override + public boolean isKnownRateCode(String rateCode) { + String normalized = trimToNull(rateCode); + return normalized != null && RATE_CODES.contains(normalized); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} 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 360f004..074d764 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 @@ -5,6 +5,7 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; @@ -27,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowR import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService; import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -87,6 +89,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private final ReservationV4SourceNotificationRepository sourceNotificationRepository; private final ReservationAiWorkflowRepository auditRepository; private final ReservationV4QueryService queryService; + private final ReservationV4DirectoryService directoryService; private final HotelContextService hotelContextService; private final ObjectMapper objectMapper; @@ -98,12 +101,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ ReservationV4SourceNotificationRepository sourceNotificationRepository, ReservationAiWorkflowRepository auditRepository, ReservationV4QueryService queryService, + ReservationV4DirectoryService directoryService, HotelContextService hotelContextService, ObjectMapper objectMapper) { this.workflowRepository = workflowRepository; this.sourceNotificationRepository = sourceNotificationRepository; this.auditRepository = auditRepository; this.queryService = queryService; + this.directoryService = directoryService; this.hotelContextService = hotelContextService; this.objectMapper = objectMapper; } @@ -171,6 +176,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ card, confirmedPayload, request == null ? null : request.fieldOverrides()); + validateAndEnrichConfirmedPayload(card, confirmedPayload); String actorId = actorIdentifier(actor); String confirmedPayloadJson = toJson(confirmedPayload); String reviewResolutionJson = reviewResolutionJson( @@ -454,13 +460,121 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { + ObjectNode payload = confirmedPayloadObject(card, confirmedPayload); + validateAndEnrichConfirmedPayload(card, payload); + return toJson(payload); + } + + private ObjectNode confirmedPayloadObject(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) { - return toJson(confirmedPayload); + if (!confirmedPayload.isObject()) { + throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_PAYLOAD_NOT_OBJECT", "确认 payload 必须是对象结构。"); + } + return ((ObjectNode) confirmedPayload).deepCopy(); } - if (hasText(card.displayPayloadJson())) { - return card.displayPayloadJson(); + return mutableDisplayPayload(card); + } + + private void validateAndEnrichConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload) { + List details = new ArrayList<>(); + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + validateAndEnrichBasicInformation(payload, details); + } else { + validateBusinessCardPayload(payload, details); } - return "{}"; + if (!details.isEmpty()) { + throw new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "V4_FIELD_VALIDATION_FAILED", + "V4 任务字段校验失败。", + details); + } + } + + private void validateAndEnrichBasicInformation(ObjectNode payload, List details) { + ObjectNode basicInformation = ensureBasicInformationObject(payload); + String accountCode = textAt(basicInformation, "account_code"); + if (accountCode == null) { + details.add("basic_information.account_code: Account Code 不能为空。"); + return; + } + ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null); + if (account == null) { + details.add("basic_information.account_code: Account Code 不在信息系统目录中。"); + return; + } + basicInformation.put("account_name", account.accountName()); + basicInformation.put("market_code", account.marketCode()); + basicInformation.put("source_code", account.sourceCode()); + } + + private ObjectNode ensureBasicInformationObject(ObjectNode payload) { + JsonNode existing = payload.get("basic_information"); + if (existing != null && existing.isObject()) { + return (ObjectNode) existing; + } + ObjectNode basicInformation = objectMapper.createObjectNode(); + copyTextField(payload, basicInformation, "account_code"); + copyTextField(payload, basicInformation, "market_code"); + copyTextField(payload, basicInformation, "source_code"); + payload.set("basic_information", basicInformation); + return basicInformation; + } + + private void copyTextField(ObjectNode source, ObjectNode target, String fieldName) { + JsonNode value = source.get(fieldName); + if (value != null && value.isTextual()) { + target.put(fieldName, value.asText()); + } + } + + private void validateBusinessCardPayload(ObjectNode payload, List details) { + JsonNode businessFields = payload.path("business_fields"); + JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload; + validateRateCode(fieldRoot.path("rate_code"), details); + validateRoomItems(fieldRoot.path("room_items"), details); + } + + private void validateRateCode(JsonNode rateCode, List details) { + String code = textValue(rateCode); + if (code != null && !directoryService.isKnownRateCode(code)) { + details.add("business_fields.rate_code: Rate Code 不在第一版目录中。"); + } + } + + private void validateRoomItems(JsonNode roomItems, List details) { + if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) { + return; + } + if (!roomItems.isArray()) { + details.add("business_fields.room_items: 房型明细必须是数组。"); + return; + } + for (int index = 0; index < roomItems.size(); index++) { + JsonNode item = roomItems.get(index); + if (item == null || !item.isObject()) { + details.add("business_fields.room_items." + index + ": 房型明细必须是对象。"); + continue; + } + String roomTypeCode = textAt(item, "room_type_code"); + if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) { + details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。"); + } + } + } + + private String textAt(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode()) { + return null; + } + return textValue(node.get(fieldName)); + } + + private String textValue(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) { + return null; + } + return trimToNull(node.asText()); } private ObjectNode mutableDisplayPayload(ReservationV4TaskCardSnapshot card) { @@ -567,7 +681,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ if (current.isContainerNode()) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。"); } - if (!isReviewPointerAllowedForResolution(confirmedPayload, pointer, current)) { + if (!isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); } } @@ -664,9 +778,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ * 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。 */ private boolean isReviewPointerAllowedForResolution( + ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload, String pointer, JsonNode current) { + Set validationPointers = collectValidationErrorPointers(card.validationErrorsJson()); + if (validationPointers.contains(pointer)) { + return true; + } Set explicitPointers = collectExplicitReviewFieldPointers(confirmedPayload); if (!explicitPointers.isEmpty()) { return explicitPointers.contains(pointer); @@ -674,6 +793,31 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return isUnresolvedReviewLeaf(current); } + /** + * 从后端字段校验错误中提取可修正字段指针,支持目录错误这类非空值复核。 + */ + private Set collectValidationErrorPointers(String validationErrorsJson) { + if (!hasText(validationErrorsJson)) { + return Set.of(); + } + try { + JsonNode root = objectMapper.readTree(validationErrorsJson); + if (root == null || !root.isArray()) { + return Set.of(); + } + Set pointers = new HashSet<>(); + for (JsonNode item : root) { + String pointer = textAt(item, "field_pointer"); + if (pointer != null && pointer.startsWith("/")) { + pointers.add(pointer); + } + } + return pointers; + } catch (Exception exception) { + return Set.of(); + } + } + /** * 从当前卡展示 payload 中收集兼容用的 missing_fields 指针清单。 */ 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 cf00c66..bb628d4 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 @@ -7,6 +7,7 @@ import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryRespons import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; @@ -32,12 +33,14 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderT import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceMessageSummaryResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService; import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -46,11 +49,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,11 +74,28 @@ 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 Set V4_BUSINESS_FIELD_ROOTS = Set.of( + "arrival_date", + "departure_date", + "rate_code", + "booking_scenario", + "guest_name", + "room_items", + "trace_items", + "attachment_ids", + "evidence_url"); + private static final Set V4_BUSINESS_READONLY_FIELDS = Set.of( + "event_type", + "manual_review", + "order_ref", + "route_code", + "target_order"); private final ReservationV4WorkflowRepository workflowRepository; private final ReservationAiWorkflowRepository aiWorkflowRepository; private final ReservationV4SourceNotificationRepository sourceNotificationRepository; private final SourceMessageQueryService sourceMessageQueryService; + private final ReservationV4DirectoryService directoryService; private final HotelContextService hotelContextService; private final ObjectMapper objectMapper; @@ -84,12 +107,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService ReservationAiWorkflowRepository aiWorkflowRepository, ReservationV4SourceNotificationRepository sourceNotificationRepository, SourceMessageQueryService sourceMessageQueryService, + ReservationV4DirectoryService directoryService, HotelContextService hotelContextService, ObjectMapper objectMapper) { this.workflowRepository = workflowRepository; this.aiWorkflowRepository = aiWorkflowRepository; this.sourceNotificationRepository = sourceNotificationRepository; this.sourceMessageQueryService = sourceMessageQueryService; + this.directoryService = directoryService; this.hotelContextService = hotelContextService; this.objectMapper = objectMapper; } @@ -319,6 +344,11 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService ReservationV4TaskCardSnapshot card, ReservationV4ActionAvailabilityResult orderAvailability, ReservationV4TaskCardSnapshot basicCard) { + JsonNode displayPayload = parseJson(card.displayPayloadJson()); + JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson()); + JsonNode reviewResolution = parseJson(card.reviewResolutionJson()); + JsonNode validationErrors = parseJson(card.validationErrorsJson()); + ReservationV4ActionAvailabilityResult availability = cardAvailability(card, orderAvailability, basicCard); return new ReservationV4TaskCardResult( card.id().toString(), card.cardType(), @@ -327,16 +357,17 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService card.cardSortOrder(), card.cardStatus(), card.reviewStatus(), - parseJson(card.displayPayloadJson()), - parseJson(card.confirmedPayloadJson()), - parseJson(card.reviewResolutionJson()), - parseJson(card.validationErrorsJson()), + displayPayload, + confirmedPayload, + reviewResolution, + validationErrors, + cardFields(card, displayPayload, validationErrors, availability), card.confirmedBy(), UtcTimeFormatter.toUtcOffsetDateTime(card.confirmedAt()), card.version(), UtcTimeFormatter.toUtcOffsetDateTime(card.createdAt()), UtcTimeFormatter.toUtcOffsetDateTime(card.updatedAt()), - cardAvailability(card, orderAvailability, basicCard)); + availability); } private ReservationV4TaskCardResult toSourceNotificationCard( @@ -359,6 +390,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService NullNode.getInstance(), NullNode.getInstance(), NullNode.getInstance(), + List.of(), notification.ackBy(), UtcTimeFormatter.toUtcOffsetDateTime(notification.ackAt()), notification.version(), @@ -761,6 +793,328 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService fragment.put(fieldName, objectMapper.convertValue(value, Object.class)); } + private List cardFields( + ReservationV4TaskCardSnapshot card, + JsonNode displayPayload, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability) { + if (displayPayload == null || displayPayload.isNull() || displayPayload.isMissingNode()) { + return List.of(); + } + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + return basicInformationFields(displayPayload, validationErrors, availability); + } + if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()) + || ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) { + return List.of(); + } + return businessFields(card, displayPayload, validationErrors, availability); + } + + private List basicInformationFields( + JsonNode displayPayload, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability) { + JsonNode basicInformation = basicInformationNode(displayPayload); + String accountCode = textAt(basicInformation, "account_code"); + Optional account = directoryService.findAccount(accountCode); + String marketCode = firstText(textAt(basicInformation, "market_code"), account.map(ReservationV4AccountCatalogItem::marketCode).orElse(null)); + String sourceCode = firstText(textAt(basicInformation, "source_code"), account.map(ReservationV4AccountCatalogItem::sourceCode).orElse(null)); + List fields = new ArrayList<>(); + fields.add(field( + "basic_information.account_code", + "/basic_information/account_code", + "Account Code", + basicInformation.path("account_code"), + availability.editable(), + true, + "select", + availability.reviewable() ? "manual_review_only" : "confirm", + availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json", + "reservation_v4_account_catalog", + false, + validationMessages(validationErrors, "/basic_information/account_code", "basic_information.account_code"), + "第一版 Account 使用后端固定目录,不能自由输入。")); + fields.add(field( + "basic_information.market_code", + "/basic_information/market_code", + "Market Code", + valueNode(marketCode), + false, + false, + "readonly", + "readonly", + null, + "reservation_v4_account_catalog", + true, + validationMessages(validationErrors, "/basic_information/market_code", "basic_information.market_code"), + "Market 由 Account Code 派生,前端只读展示。")); + fields.add(field( + "basic_information.source_code", + "/basic_information/source_code", + "Source Code", + valueNode(sourceCode), + false, + false, + "readonly", + "readonly", + null, + "reservation_v4_account_catalog", + true, + validationMessages(validationErrors, "/basic_information/source_code", "basic_information.source_code"), + "Source 由 Account Code 派生,前端只读展示。")); + return fields; + } + + private JsonNode basicInformationNode(JsonNode displayPayload) { + JsonNode basicInformation = displayPayload.path("basic_information"); + if (basicInformation.isObject()) { + return basicInformation; + } + return displayPayload; + } + + private List businessFields( + ReservationV4TaskCardSnapshot card, + JsonNode displayPayload, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability) { + JsonNode fieldRoot = displayPayload.path("business_fields"); + String pointerPrefix = "/business_fields"; + String pathPrefix = "business_fields"; + if (!fieldRoot.isObject()) { + fieldRoot = displayPayload; + pointerPrefix = ""; + pathPrefix = ""; + } + List fields = new ArrayList<>(); + Iterator> iterator = fieldRoot.fields(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (!shouldExposeBusinessField(entry.getKey(), pointerPrefix)) { + continue; + } + collectBusinessLeafFields( + fields, + card, + entry.getKey(), + entry.getValue(), + pointerPrefix + "/" + escapeJsonPointer(entry.getKey()), + pathPrefix.isEmpty() ? entry.getKey() : pathPrefix + "." + entry.getKey(), + validationErrors, + availability); + } + return fields; + } + + private boolean shouldExposeBusinessField(String fieldName, String pointerPrefix) { + if (V4_BUSINESS_READONLY_FIELDS.contains(fieldName)) { + return false; + } + if ("/business_fields".equals(pointerPrefix)) { + return true; + } + return V4_BUSINESS_FIELD_ROOTS.contains(fieldName); + } + + private void collectBusinessLeafFields( + List fields, + ReservationV4TaskCardSnapshot card, + String fieldName, + JsonNode value, + String pointer, + String fieldPath, + JsonNode validationErrors, + ReservationV4ActionAvailabilityResult availability) { + if (value == null || value.isMissingNode()) { + return; + } + if (value.isObject()) { + Iterator> iterator = value.fields(); + while (iterator.hasNext()) { + Map.Entry child = iterator.next(); + collectBusinessLeafFields( + fields, + card, + child.getKey(), + child.getValue(), + pointer + "/" + escapeJsonPointer(child.getKey()), + fieldPath + "." + child.getKey(), + validationErrors, + availability); + } + return; + } + if (value.isArray()) { + for (int index = 0; index < value.size(); index++) { + collectBusinessLeafFields( + fields, + card, + fieldName, + value.get(index), + pointer + "/" + index, + fieldPath + "." + index, + validationErrors, + availability); + } + return; + } + List fieldValidationErrors = validationMessages(validationErrors, pointer, fieldPath); + boolean editable = businessFieldEditable(card, value, availability, fieldValidationErrors); + fields.add(field( + fieldPath, + pointer, + displayName(fieldName), + value, + editable, + false, + controlType(fieldPath), + availability.reviewable() ? "manual_review_only" : "confirm", + availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json", + optionsSource(fieldPath), + false, + fieldValidationErrors, + null)); + } + + private boolean businessFieldEditable( + ReservationV4TaskCardSnapshot card, + JsonNode value, + ReservationV4ActionAvailabilityResult availability, + List validationErrors) { + if (!availability.editable()) { + return false; + } + if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) { + return true; + } + return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus()) + && (!validationErrors.isEmpty() || isUnresolvedLeaf(value)); + } + + private ReservationV4TaskCardFieldResult field( + String fieldPath, + String fieldPointer, + String displayName, + JsonNode value, + Boolean editable, + Boolean required, + String controlType, + String editScope, + String writeTarget, + String optionsSource, + Boolean rawReadonly, + List validationErrors, + String controlHint) { + return new ReservationV4TaskCardFieldResult( + fieldPath, + fieldPointer, + displayName, + value == null || value.isMissingNode() ? NullNode.getInstance() : value, + editable, + required, + controlType, + editScope, + writeTarget, + optionsSource, + rawReadonly, + validationErrors == null ? List.of() : validationErrors, + controlHint); + } + + private List validationMessages(JsonNode validationErrors, String pointer, String fieldPath) { + if (validationErrors == null || !validationErrors.isArray()) { + return List.of(); + } + List messages = new ArrayList<>(); + for (JsonNode item : validationErrors) { + String itemPointer = textAt(item, "field_pointer"); + String itemPath = textAt(item, "field_path"); + String detail = textAt(item, "detail"); + if (Objects.equals(pointer, itemPointer) || Objects.equals(fieldPath, itemPath) + || (detail != null && detail.startsWith(fieldPath + ":"))) { + messages.add(firstText(textAt(item, "message"), detail)); + } + } + return messages; + } + + private String controlType(String fieldPath) { + if (fieldPath.endsWith("_date") || fieldPath.contains(".date")) { + return "date"; + } + if (fieldPath.endsWith("_count") || fieldPath.endsWith("_quantity") || fieldPath.endsWith("_number")) { + return "number"; + } + if (fieldPath.endsWith("room_type_code") || fieldPath.endsWith("rate_code")) { + return "select"; + } + return "text"; + } + + private String optionsSource(String fieldPath) { + if (fieldPath.endsWith("room_type_code")) { + return "reservation_v4_room_type_catalog"; + } + if (fieldPath.endsWith("rate_code")) { + return "reservation_v4_rate_code_catalog"; + } + return null; + } + + private String displayName(String fieldName) { + if ("room_type_code".equals(fieldName)) { + return "房型代码"; + } + if ("room_count".equals(fieldName)) { + return "房间数"; + } + if ("rate_code".equals(fieldName)) { + return "Rate Code"; + } + if ("arrival_date".equals(fieldName)) { + return "入住日期"; + } + if ("departure_date".equals(fieldName)) { + return "离店日期"; + } + return fieldName; + } + + private JsonNode valueNode(String value) { + return value == null ? NullNode.getInstance() : objectMapper.valueToTree(value); + } + + private String firstText(String first, String second) { + return hasText(first) ? first : second; + } + + private String textAt(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode()) { + return null; + } + JsonNode value = node.get(fieldName); + if (value == null || value.isNull() || value.isMissingNode()) { + return null; + } + if (!value.isTextual()) { + return null; + } + String text = value.asText().trim(); + return text.isEmpty() ? null : text; + } + + private boolean isUnresolvedLeaf(JsonNode value) { + if (value == null || value.isNull()) { + return true; + } + return value.isTextual() && !hasText(value.asText()); + } + + private String escapeJsonPointer(String segment) { + return segment.replace("~", "~0").replace("/", "~1"); + } + private List findSourceMessageIdsByKeyword(String hotelId, String keyword) { if (!hasText(keyword)) { return List.of(); diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java index ebb1e3e..fc1e16a 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; @@ -14,6 +15,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTa import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService; import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4TaskIntakeService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -43,6 +45,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta private final ObjectMapper objectMapper; private final ReservationV4WorkflowRepository workflowRepository; private final ReservationV4SourceNotificationRepository sourceNotificationRepository; + private final ReservationV4DirectoryService directoryService; /** * 注入 V4 持久化边界和 JSON 工具,Service 只编排入站写入,不直接访问 Mapper。 @@ -50,10 +53,12 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta public ReservationV4TaskIntakeServiceImpl( ObjectMapper objectMapper, ReservationV4WorkflowRepository workflowRepository, - ReservationV4SourceNotificationRepository sourceNotificationRepository) { + ReservationV4SourceNotificationRepository sourceNotificationRepository, + ReservationV4DirectoryService directoryService) { this.objectMapper = objectMapper; this.workflowRepository = workflowRepository; this.sourceNotificationRepository = sourceNotificationRepository; + this.directoryService = directoryService; } /** @@ -173,10 +178,15 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta LocalDateTime now) { JsonNode basicInformation = orderContext.path("basic_information"); boolean manualReviewRequired = isBooleanTrue(basicInformation.path("manual_review")); + ObjectNode safeBasicInformation = basicInformation.isObject() + ? ((ObjectNode) basicInformation).deepCopy() + : objectMapper.createObjectNode(); + List validationDetails = validateAndEnrichBasicInformation(safeBasicInformation); + boolean reviewRequired = manualReviewRequired || !validationDetails.isEmpty(); ObjectNode displayPayload = objectMapper.createObjectNode(); displayPayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name()); displayPayload.put("order_ref", textAt(orderContext, "order_ref")); - displayPayload.set("basic_information", basicInformation); + displayPayload.set("basic_information", safeBasicInformation); workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( sourceMessage.hotelId(), orderTaskId, @@ -186,11 +196,11 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta null, 0, BASIC_INFORMATION_CARD_SORT_ORDER, - cardStatus(manualReviewRequired), - reviewStatus(manualReviewRequired), + cardStatus(reviewRequired), + reviewStatus(reviewRequired), nodeJson(orderContext), nodeJson(displayPayload), - null, + validationErrorsJson(validationDetails), now )); } @@ -203,6 +213,9 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta Long orderTaskId, ReservationV4AcceptedEventDraft acceptedEvent, LocalDateTime now) { + ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson()); + List validationDetails = validateBusinessCardDisplayPayload(displayPayload); + boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty(); workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( sourceMessage.hotelId(), orderTaskId, @@ -212,11 +225,11 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta acceptedEvent.eventType(), acceptedEvent.sourceEventIndex(), cardSortOrder(acceptedEvent.eventType()), - cardStatus(acceptedEvent.manualReviewRequired()), - reviewStatus(acceptedEvent.manualReviewRequired()), + cardStatus(reviewRequired), + reviewStatus(reviewRequired), acceptedEvent.aiPayloadJson(), - acceptedEvent.displayPayloadJson(), - null, + nodeJson(displayPayload), + validationErrorsJson(validationDetails), now )); } @@ -359,6 +372,124 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta return sourceMessage.receivedAt() == null ? now : sourceMessage.receivedAt(); } + /** + * 校验并派生 Basic Information 的 Account 目录字段,错误只写展示卡,不改 AI 原始 payload。 + */ + private List validateAndEnrichBasicInformation(ObjectNode basicInformation) { + List details = new ArrayList<>(); + String accountCode = trimToNull(textAt(basicInformation, "account_code")); + if (accountCode == null) { + details.add("basic_information.account_code: Account Code 不能为空。"); + return details; + } + ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null); + if (account == null) { + details.add("basic_information.account_code: Account Code 不在信息系统目录中。"); + return details; + } + basicInformation.put("account_name", account.accountName()); + basicInformation.put("market_code", account.marketCode()); + basicInformation.put("source_code", account.sourceCode()); + return details; + } + + /** + * 校验 V4 业务卡展示字段中的第一版目录值,失败时卡片进入人工复核。 + */ + private List validateBusinessCardDisplayPayload(ObjectNode displayPayload) { + List details = new ArrayList<>(); + JsonNode businessFields = displayPayload.path("business_fields"); + JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload; + validateRateCode(fieldRoot.path("rate_code"), details); + validateRoomItems(fieldRoot.path("room_items"), details); + return details; + } + + private void validateRateCode(JsonNode rateCode, List details) { + String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText()); + if (code != null && !directoryService.isKnownRateCode(code)) { + details.add("business_fields.rate_code: Rate Code 不在第一版目录中。"); + } + } + + private void validateRoomItems(JsonNode roomItems, List details) { + if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) { + return; + } + if (!roomItems.isArray()) { + details.add("business_fields.room_items: 房型明细必须是数组。"); + return; + } + for (int index = 0; index < roomItems.size(); index++) { + JsonNode item = roomItems.get(index); + if (item == null || !item.isObject()) { + details.add("business_fields.room_items." + index + ": 房型明细必须是对象。"); + continue; + } + String roomTypeCode = trimToNull(textAt(item, "room_type_code")); + if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) { + details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。"); + } + } + } + + /** + * 将字段校验错误转换为前端和复核接口都能识别的 JSON 数组。 + */ + private String validationErrorsJson(List details) { + if (details == null || details.isEmpty()) { + return null; + } + ArrayNode errors = objectMapper.createArrayNode(); + for (String detail : details) { + String fieldPath = fieldPathFromDetail(detail); + ObjectNode error = errors.addObject(); + error.put("field_path", fieldPath); + error.put("field_pointer", jsonPointerFromFieldPath(fieldPath)); + error.put("message", messageFromDetail(detail)); + error.put("detail", detail); + } + return nodeJson(errors); + } + + private String fieldPathFromDetail(String detail) { + int index = detail == null ? -1 : detail.indexOf(':'); + if (index <= 0) { + return "unknown"; + } + return detail.substring(0, index).trim(); + } + + private String messageFromDetail(String detail) { + int index = detail == null ? -1 : detail.indexOf(':'); + if (index < 0 || index + 1 >= detail.length()) { + return detail; + } + return detail.substring(index + 1).trim(); + } + + private String jsonPointerFromFieldPath(String fieldPath) { + if (!hasText(fieldPath)) { + return "/"; + } + return "/" + fieldPath.replace(".", "/"); + } + + /** + * 解析 V4 展示 payload;该 JSON 来自本系统适配器,异常时必须显式失败,避免落入空白业务卡。 + */ + private ObjectNode objectJson(String json) { + if (!hasText(json)) { + return objectMapper.createObjectNode(); + } + try { + JsonNode node = objectMapper.readTree(json); + return node != null && node.isObject() ? ((ObjectNode) node).deepCopy() : objectMapper.createObjectNode(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("V4 业务卡展示 payload 解析失败。", exception); + } + } + /** * 将 V4 卡片或通知 payload 安全序列化为 JSON 字符串。 */ @@ -396,6 +527,13 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta return value != null && !value.isBlank(); } + private String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + /** * switch 前把空字符串统一为安全默认值。 */ 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 adf18a2..46965d0 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 @@ -149,7 +149,10 @@ class ReservationV4CommandControllerTest { { "version": 0, "confirmed_payload": { - "company": "Q.B.D. TRAVEL GROUP CO., LTD", + "card_type": "BASIC_INFORMATION", + "basic_information": { + "account_code": "QBD_TRAVEL" + }, "group_code": "GRP-V4-COMMAND-001" } } @@ -159,6 +162,10 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin")) .andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code") .value("GRP-V4-COMMAND-001")) + .andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.market_code") + .value("LEISURE")) + .andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code") + .value("TRAVEL_AGENT")) .andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true)) .andExpect(jsonPath("$.business_cards[0].availability.read_only").value(false)) .andExpect(content().string(not(org.hamcrest.Matchers.containsString("private.example.test")))); @@ -185,6 +192,33 @@ class ReservationV4CommandControllerTest { assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2); } + @Test + void shouldRejectBasicInformationConfirmWhenAccountCodeUnknown() throws Exception { + SeededOrderTask seeded = seedOrderTask( + HOTEL_ID, + "mail-v4-command-confirm-unknown-account-001", + Instant.parse("2026-07-19T01:12:00Z")); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.basicCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "card_type": "BASIC_INFORMATION", + "basic_information": { + "account_code": "UNKNOWN_ACCOUNT" + } + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value("basic_information.account_code: Account Code 不在信息系统目录中。")); + } + @Test void shouldRejectRepeatedCardConfirm() throws Exception { SeededOrderTask seeded = seedOrderTask( @@ -244,12 +278,44 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.basic_information_card.review_status").value("RESOLVED")) .andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin")) .andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.account_code").value("QBD_TRAVEL")) + .andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.market_code").value("LEISURE")) + .andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code").value("TRAVEL_AGENT")) .andExpect(jsonPath("$.basic_information_card.review_resolution.field_overrides[0].field_pointer") .value("/basic_information/account_code")) .andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true)); assertAuditCount("V4_CARD_REVIEW_RESOLVE", "v4-command-admin", seeded.orderTask().id().toString(), 1); } + @Test + void shouldRejectBasicInformationReviewWhenAccountCodeUnknown() throws Exception { + Long confirmedOrderId = 990000000000070012L; + seedReservationOrder(HOTEL_ID, confirmedOrderId); + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-unknown-account-001", + Instant.parse("2026-07-19T01:21:10Z"), + null, + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name()); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.basicCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_order_id": "990000000000070012", + "field_overrides": [ + {"field_pointer": "/basic_information/account_code", "value": "UNKNOWN_ACCOUNT"} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED")); + } + @Test void shouldResolveOrderOwnershipStatusWhenConfirmedOrderIdAlreadyBound() throws Exception { Long confirmedOrderId = 990000000000070009L; @@ -319,6 +385,54 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射")); } + @Test + void shouldResolveBusinessCardDirectoryValidationError() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-directory-001", + Instant.parse("2026-07-19T01:22:05Z"), + 990000000000070004L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + """ + {"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}]}} + """, + """ + [ + { + "field_path": "business_fields.room_items.0.room_type_code", + "field_pointer": "/business_fields/room_items/0/room_type_code", + "message": "房型代码不在第一版目录中。", + "detail": "business_fields.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": "修正房型目录", + "field_overrides": [ + { + "field_pointer": "/business_fields/room_items/0/room_type_code", + "value": "TWN" + } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.room_items[0].room_type_code") + .value("TWN")) + .andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("修正房型目录")); + } + @Test void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception { Long currentOrderId = 990000000000070111L; @@ -805,7 +919,13 @@ class ReservationV4CommandControllerTest { .content(""" { "version": 0, - "confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"} + "confirmed_payload": { + "card_type": "BASIC_INFORMATION", + "basic_information": { + "account_code": "QBD_TRAVEL" + }, + "group_code": "GRP-V4-COMMAND-001" + } } """)) .andExpect(status().isOk()); @@ -865,7 +985,7 @@ class ReservationV4CommandControllerTest { ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ - {"card_type":"BASIC_INFORMATION","company":"Q.B.D. TRAVEL GROUP CO., LTD","group_code":"GRP-V4-COMMAND-001"} + {"card_type":"BASIC_INFORMATION","order_ref":"ORDER-COMMAND","basic_information":{"account_code":"QBD_TRAVEL","manual_review":null}} """); ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, @@ -902,6 +1022,53 @@ class ReservationV4CommandControllerTest { String targetResolutionStatus, String basicStatus, String businessStatus) { + return seedReviewOrderTaskWithBusinessCard( + hotelId, + externalMessageId, + receivedAt, + orderId, + targetResolutionStatus, + basicStatus, + businessStatus, + """ + {"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":null}]}} + """, + null); + } + + private SeededOrderTask seedReviewOrderTaskWithBusinessCard( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String basicStatus, + String businessStatus, + String businessDisplayPayloadJson, + String businessValidationErrorsJson) { + return seedReviewOrderTaskWithBusinessCard( + hotelId, + externalMessageId, + receivedAt, + orderId, + orderId == null + ? ReservationV4TargetResolutionStatus.UNRESOLVED.name() + : ReservationV4TargetResolutionStatus.RESOLVED.name(), + basicStatus, + businessStatus, + businessDisplayPayloadJson, + businessValidationErrorsJson); + } + + private SeededOrderTask seedReviewOrderTaskWithBusinessCard( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String targetResolutionStatus, + String basicStatus, + String businessStatus, + String businessDisplayPayloadJson, + String businessValidationErrorsJson) { SourceMessageCaptureResult source = captureSourceMessage( hotelId, externalMessageId, @@ -934,9 +1101,7 @@ class ReservationV4CommandControllerTest { """); ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, - businessStatus, reviewStatusFor(businessStatus), """ - {"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":null}]}} - """); + businessStatus, reviewStatusFor(businessStatus), businessDisplayPayloadJson, businessValidationErrorsJson); return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard); } @@ -980,6 +1145,21 @@ class ReservationV4CommandControllerTest { String cardStatus, String reviewStatus, String displayPayloadJson) { + return insertCard(orderTask, hotelId, cardType, eventType, sourceEventIndex, sortOrder, cardStatus, reviewStatus, + displayPayloadJson, null); + } + + private ReservationV4TaskCardSnapshot insertCard( + ReservationV4OrderTaskSnapshot orderTask, + String hotelId, + String cardType, + String eventType, + Integer sourceEventIndex, + Integer sortOrder, + String cardStatus, + String reviewStatus, + String displayPayloadJson, + String validationErrorsJson) { return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( hotelId, orderTask.id(), @@ -995,7 +1175,7 @@ class ReservationV4CommandControllerTest { {"private_url":"https://private.example.test/raw","raw":"must stay internal"} """, displayPayloadJson, - null, + validationErrorsJson, orderTask.createdAt())); } 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 d794f3d..03356b9 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 @@ -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.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.matchesPattern; import static org.hamcrest.Matchers.not; @@ -240,8 +241,20 @@ class ReservationV4QueryControllerTest { .andExpect(jsonPath("$.order_task.target_locator_value").value("GRP-V4-QUERY-001")) .andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_DISPLAY")) .andExpect(jsonPath("$.basic_information_card.card_type").value("BASIC_INFORMATION")) + .andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/account_code')].control_type") + .value(contains("select"))) + .andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/account_code')].options_source") + .value(contains("reservation_v4_account_catalog"))) + .andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/market_code')].raw_readonly") + .value(contains(true))) + .andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/source_code')].raw_readonly") + .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") + .value(contains("select"))) + .andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/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)) .andExpect(jsonPath("$.availability.confirmable").value(true)) @@ -488,7 +501,7 @@ class ReservationV4QueryControllerTest { """); insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ - {"card_type":"BASIC_INFORMATION","account_code":"QBD_TRAVEL"} + {"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, """ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index aa7696e..4d2215d 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -1206,6 +1206,56 @@ class SuperAgentTaskResultControllerTest { assertThat(basicReviewCount).isEqualTo(1L); } + @Test + void shouldMarkV4BasicInformationReviewRequiredWhenAccountCodeUnknown() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unknown-account-001"); + String body = v4BusinessRootBody("mail-v4-unknown-account-001") + .replace("\"account_code\": \"QBD_TRAVEL\"", "\"account_code\": \"UNKNOWN_ACCOUNT\"") + .replace("GRP-V4-001", "GRP-V4-UNKNOWN-ACCOUNT-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-unknown-account-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)); + + String basicValidationErrors = jdbcTemplate.queryForObject(""" + SELECT validation_errors_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'BASIC_INFORMATION' + AND card_status = 'REVIEW_REQUIRED' + AND review_status = 'PENDING' + LIMIT 1 + """, String.class, source.inboxId()); + assertThat(basicValidationErrors) + .contains("basic_information.account_code") + .contains("Account Code 不在信息系统目录中"); + } + + @Test + void shouldMarkV4BusinessCardReviewRequiredWhenRoomTypeCodeUnknown() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unknown-room-type-001"); + String body = v4BusinessRootBody("mail-v4-unknown-room-type-001") + .replace("\"room_type_code\": \"TWN\"", "\"room_type_code\": \"UNKNOWN_ROOM\"") + .replace("GRP-V4-001", "GRP-V4-UNKNOWN-ROOM-001"); + + mockMvc.perform(signedPost(body, "nonce-v4-unknown-room-type-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(2)); + + String roomValidationErrors = jdbcTemplate.queryForObject(""" + SELECT validation_errors_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'ROOM_INFORMATION' + AND card_status = 'REVIEW_REQUIRED' + AND review_status = 'PENDING' + LIMIT 1 + """, String.class, source.inboxId()); + assertThat(roomValidationErrors) + .contains("business_fields.room_items.0.room_type_code") + .contains("房型代码不在第一版目录中"); + } + @Test void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");