From d0d26c7fbe437b488adb33c7c0cb84b5791dc9ef Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 11:36:20 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20V4=20=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E8=A7=A3=E9=98=BB=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 16 +- .../backend-to-frontend-notes.md | 7 +- ...002-v4-order-task-card-domain-model-cp2.md | 26 +- .../security-access-control-boundary.md | 2 +- .../common/enums/PlatformPermissionCode.java | 1 + .../impl/PlatformIdentityBootstrapRunner.java | 5 +- ...servationV4ReviewFieldOverrideRequest.java | 17 + .../ReservationV4ReviewResolutionRequest.java | 22 + ...ReservationV4ActionAvailabilityResult.java | 2 + .../ReservationV4CommandController.java | 17 + ...ybatisReservationV4WorkflowRepository.java | 65 +++ .../ReservationV4WorkflowRepository.java | 25 + .../service/ReservationV4CommandService.java | 10 + .../impl/ReservationV4CommandServiceImpl.java | 505 +++++++++++++++++- .../impl/ReservationV4QueryServiceImpl.java | 46 +- .../identity/control/AuthControllerTest.java | 1 + .../ReservationV4CommandControllerTest.java | 428 +++++++++++++++ .../ReservationV4QueryControllerTest.java | 10 +- 18 files changed, 1162 insertions(+), 43 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewFieldOverrideRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewResolutionRequest.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index ae70d65..9a459e5 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口和卡片确认接口并行 | -| 当前重点 | M002 V4 CP6 已开放 V4 卡片确认和 S10/S99 来源通知 ack;后续准备做 V4 复核解阻、前端页面和目录校验 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认和复核解阻并行 | +| 当前重点 | M002 V4 CP7 已开放 V4 复核解阻与复核场景订单归属确认;后续准备做 V4 前端页面和目录校验 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP6-v4-card-confirm-and-source-notification-ack` -- 状态:Done,已实现 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` 和 `POST /api/reservation/source-notifications/{notificationId}/ack`。 -- 目标:让 V4 新模型中的 Basic Information、业务卡和 S10/S99 来源通知可以由前端确认,并写入当前登录用户审计。 -- 边界:CP6 不实现 V4 复核解阻、普通任务任意切换订单、订单详情 V4 专属时间线、OPERA / OHIP、前端页面和历史数据迁移。 +- 名称:`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、前端页面和历史数据迁移。 ## 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。V4 复核接口和 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` 卡复核解阻和复核场景订单归属确认。V4 前端页面仍未完成代码实现。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP7`:实现 V4 复核解阻 / 复核场景订单归属确认,或进入 V4 前端页面接入 checkpoint。 +- 后续如继续做 M002 V4,应优先进入 V4 前端页面接入 checkpoint,或进入受控目录第一版校验 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 24b4142..f1a3237 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -59,6 +59,7 @@ | `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/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/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`。 | @@ -463,13 +464,15 @@ 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 写接口;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` 复核解阻接口。 - 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。当前仍不代表 V4 复核 API 已经可用。 +- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认。 +- 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` 在订单任务归属未解决时必填;`field_pointer` 必须来自当前卡允许编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段,不允许提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence 或校验诊断字段,也不能替换整个对象 / 数组。 - 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-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index 621e3ea..d90065b 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 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP6,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、V4 卡片确认接口和 S10/S99 来源通知 ack 接口;V4 复核写接口仍未开放。 +截至 CP7,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、V4 卡片确认接口、S10/S99 来源通知 ack 接口,以及 V4 `REVIEW_REQUIRED` 卡复核解阻接口。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -171,8 +171,12 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 - 不改写 AI 原始 payload。 - 用户修正写入 `review_resolution_json` 和 `confirmed_payload_json`。 -- 目录值必须来自信息系统受控目录。 +- CP7 第一版只校验 JSON Pointer 是否指向当前卡允许编辑字段;目录值必须来自信息系统受控目录的校验放到受控目录 checkpoint。 - 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。 +- `field_overrides[].field_pointer` 必须是当前卡 `display_payload_json` 中允许编辑的 RFC 6901 JSON Pointer;第一版只允许 `basic_information.*` 或 `business_fields.*` 下已经存在的叶子标量字段,不允许替换对象或数组。 +- 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。 +- 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。 +- `availability.reviewable=true` 且 `card_status=REVIEW_REQUIRED` 时,前端可以展示复核提交入口;普通确认接口仍拒绝 `REVIEW_REQUIRED` 卡。 ### 7.4 Basic Information 目录规则 @@ -222,6 +226,8 @@ V4 CP2 只设计复核场景下的订单归属确认,不开放普通任务任 - 订单归属确认必须写业务审计。 - 若确认到其他订单,原临时订单可在无其它任务引用时逻辑删除。 - 已确认锁定卡片不能通过该接口二次迁移订单。 +- CP7 第一版只支持在复核解阻请求中提交 `confirmed_order_id` 完成一次性归属确认;不提供普通任务任意切换订单接口。 +- 如果确认到的订单下存在更早未完成 V4 订单任务,后端拒绝当前复核提交,避免绕过同订单阻塞规则。 ## 9. 同订单阻塞规则 @@ -611,8 +617,15 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution - 提交 `field_overrides[]`。 - 可提交复核场景订单归属确认。 +- 请求 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`;只能改已存在的叶子标量或 `null` 字段,不能替换整个对象、数组或新增未知字段。 +- Basic Information 必须先确认;如果 Basic Information 自身是 `REVIEW_REQUIRED`,允许通过本接口先复核并确认 Basic。 +- 解阻过程不改写 `ai_payload_json`;用户修正写入 `review_resolution_json`,最终确认快照写入 `confirmed_payload_json`。 - 校验通过后直接 `CONFIRMED`。 - 写业务审计。 +- 返回刷新后的 `GET /api/reservation/order-tasks/{orderTaskId}` 详情结构;对应卡片 `review_status=RESOLVED`、`confirmed_by` 和 `confirmed_at` 会返回。 ### 13.3 S10/S99 通知确认 @@ -687,10 +700,11 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | M002-V4-CP3 | V4 表结构和基础 Repository | 新增 V4 order task / card / source notification 表、Entity、Mapper、Repository、测试 | | M002-V4-CP4 | V4 入站落新模型 | 已完成:SuperAgent V4 回调创建订单任务、Basic Information 卡、业务卡、邮件展示卡和 S10/S99 来源通知 | | M002-V4-CP5 | V4 查询接口 | 已完成:工作台统一列表、订单任务列表、订单任务详情和来源通知详情查询接口;订单详情 V4 专属时间线后续再做 | -| M002-V4-CP6 | V4 卡片确认和 S10/S99 ack | 已完成:不保存草稿,支持普通卡片确认、确认后锁定、Basic Information 前置约束、同订单前置任务写侧阻塞、业务审计、version 并发校验和 S10/S99 来源通知确认;V4 复核接口后置 | -| M002-V4-CP7 | 受控目录第一版 | Account、RoomType、RateCode、Department 固定目录或版本化快照校验 | -| M002-V4-CP8 | V4 前端契约收口 | 字段、控件、availability、错误展示和旧任务入口切换 | -| M002-V4-CP9 | 旧 V3 / V2 能力收口评估 | 明确哪些兼容入口可以关闭,哪些仍保留只读历史 | +| 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-CP9 | V4 前端契约收口 | 字段、控件、availability、错误展示和旧任务入口切换 | +| M002-V4-CP10 | 旧 V3 / V2 能力收口评估 | 明确哪些兼容入口可以关闭,哪些仍保留只读历史 | ## 17. 已确认设计决策 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 4c4e1a0..e868fe3 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -54,7 +54,7 @@ | `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 CP2 草案,尚未实现 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 必须写业务审计,记录复核修正和订单归属确认摘要 | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡可编辑业务字段;订单归属未解决时必须提交当前酒店下真实可见订单 ID | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 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/platform/access/common/enums/PlatformPermissionCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java index 9ac5e7f..46f2229 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java @@ -10,6 +10,7 @@ public enum PlatformPermissionCode { RESERVATION_TASK_READ, RESERVATION_TASK_EDIT, RESERVATION_TASK_CONFIRM, + RESERVATION_MANUAL_REVIEW_RESOLVE, RESERVATION_OPERA_SIM_EXECUTE, RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE, diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java index 251ae9c..ae0c9af 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java @@ -288,6 +288,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { PlatformPermissionCode.RESERVATION_TASK_READ, PlatformPermissionCode.RESERVATION_TASK_EDIT, PlatformPermissionCode.RESERVATION_TASK_CONFIRM, + PlatformPermissionCode.RESERVATION_MANUAL_REVIEW_RESOLVE, PlatformPermissionCode.RESERVATION_OPERA_SIM_EXECUTE, PlatformPermissionCode.RESERVATION_AUDIT_READ, PlatformPermissionCode.RESERVATION_INVOICE_GENERATE, @@ -315,6 +316,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { case RESERVATION_TASK_READ -> "读取任务"; case RESERVATION_TASK_EDIT -> "编辑任务草稿"; case RESERVATION_TASK_CONFIRM -> "确认任务"; + case RESERVATION_MANUAL_REVIEW_RESOLVE -> "处理人工复核"; case RESERVATION_OPERA_SIM_EXECUTE -> "执行 OPERA 模拟"; case RESERVATION_AUDIT_READ -> "读取任务审计"; case RESERVATION_INVOICE_GENERATE -> "生成预订发票"; @@ -337,7 +339,8 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { return switch (code) { case SOURCE_MESSAGE_READ, SOURCE_MESSAGE_ORIGINAL_READ -> "SOURCE_MESSAGE"; case RESERVATION_ORDER_READ, RESERVATION_TASK_READ, RESERVATION_TASK_EDIT, - RESERVATION_TASK_CONFIRM, RESERVATION_OPERA_SIM_EXECUTE, + RESERVATION_TASK_CONFIRM, RESERVATION_MANUAL_REVIEW_RESOLVE, + RESERVATION_OPERA_SIM_EXECUTE, RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE, RESERVATION_ROOMING_LIST_GENERATE -> "RESERVATION"; case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL"; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewFieldOverrideRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewFieldOverrideRequest.java new file mode 100644 index 0000000..6318030 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewFieldOverrideRequest.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Reservation V4 复核字段修正项。field_pointer 必须是当前卡展示 payload 内允许编辑的 RFC 6901 JSON Pointer。 + * + * @param fieldPointer 当前卡字段 JSON Pointer + * @param value 用户复核后的字段值 + */ +public record ReservationV4ReviewFieldOverrideRequest( + @JsonProperty("field_pointer") + String fieldPointer, + JsonNode value +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewResolutionRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewResolutionRequest.java new file mode 100644 index 0000000..a8eef75 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4ReviewResolutionRequest.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Reservation V4 人工复核解阻请求。只用于 REVIEW_REQUIRED 卡片,不用于普通任务任意切换订单。 + * + * @param version 前端读取到的任务卡乐观锁版本 + * @param fieldOverrides 用户复核提交的字段修正列表 + * @param reason 可选复核说明,用于业务审计摘要 + * @param confirmedOrderId 复核场景确认后的本地订单 ID,订单归属未解决时必填 + */ +public record ReservationV4ReviewResolutionRequest( + Long version, + @JsonProperty("field_overrides") + List fieldOverrides, + String reason, + @JsonProperty("confirmed_order_id") + String confirmedOrderId +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4ActionAvailabilityResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4ActionAvailabilityResult.java index 2c7f842..a63a856 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4ActionAvailabilityResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4ActionAvailabilityResult.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @param readOnly 当前是否只读 * @param editable 当前是否允许编辑 * @param confirmable 当前是否允许最终确认 + * @param reviewable 当前是否允许提交人工复核解阻 * @param ackable 当前是否允许确认来源通知已处理 * @param readonlyReasonCode 只读原因稳定码 * @param blockedByOrderTaskId 阻塞当前条目的前置订单任务 ID @@ -21,6 +22,7 @@ public record ReservationV4ActionAvailabilityResult( boolean readOnly, boolean editable, boolean confirmable, + boolean reviewable, boolean ackable, @JsonProperty("readonly_reason_code") String readonlyReasonCode, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandController.java index b882194..6d32103 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandController.java @@ -4,6 +4,7 @@ import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewResolutionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; @@ -51,6 +52,22 @@ public class ReservationV4CommandController { return commandService.confirmTaskCard(orderTaskId, cardId, request, actor); } + /** + * 复核解阻 V4 REVIEW_REQUIRED 卡片;支持字段修正和复核场景订单归属确认。 + */ + @PostMapping( + value = "/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4OrderTaskDetailResult resolveTaskCardReview( + @PathVariable Long orderTaskId, + @PathVariable Long cardId, + @RequestBody(required = false) ReservationV4ReviewResolutionRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_MANUAL_REVIEW_RESOLVE.name()); + return commandService.resolveTaskCardReview(orderTaskId, cardId, request, actor); + } + /** * 确认 S10/S99 来源通知已读或已处理;不创建订单、不参与订单阻塞。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java index b320f6e..3624a30 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java @@ -6,11 +6,13 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapsho 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.ReservationV4CardStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4OrderTaskMapper; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4TaskCardMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import java.time.LocalDateTime; @@ -138,6 +140,8 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt) .orderByAsc(ReservationV4OrderTaskEntity::getOrderId) .orderByAsc(ReservationV4OrderTaskEntity::getSourceReceivedAt) + .orderByAsc(ReservationV4OrderTaskEntity::getSourceMessageId) + .orderByAsc(ReservationV4OrderTaskEntity::getOrderContextIndex) .orderByAsc(ReservationV4OrderTaskEntity::getCreatedAt) .orderByAsc(ReservationV4OrderTaskEntity::getId)) .stream() @@ -309,6 +313,67 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work return updated == 1; } + /** + * 按 version 乐观锁完成 V4 人工复核卡,同时写入复核结果、确认 payload、确认人和确认时间。 + */ + @Override + public boolean resolveTaskCardReviewWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String reviewResolutionJson, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt) { + int updated = taskCardMapper.update(Wrappers.lambdaUpdate() + .set(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.CONFIRMED.name()) + .set(ReservationV4TaskCardEntity::getReviewStatus, ReservationReviewStatus.RESOLVED.name()) + .set(ReservationV4TaskCardEntity::getReviewResolutionJson, reviewResolutionJson) + .set(ReservationV4TaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson) + .set(ReservationV4TaskCardEntity::getConfirmedBy, confirmedBy) + .set(ReservationV4TaskCardEntity::getConfirmedAt, confirmedAt) + .set(ReservationV4TaskCardEntity::getUpdatedAt, confirmedAt) + .setSql("version = version + 1") + .eq(ReservationV4TaskCardEntity::getHotelId, hotelId) + .eq(ReservationV4TaskCardEntity::getId, taskCardId) + .eq(ReservationV4TaskCardEntity::getVersion, expectedVersion) + .eq(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.REVIEW_REQUIRED.name()) + .isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)); + return updated == 1; + } + + /** + * 在复核场景确认 V4 订单任务归属,只更新当前订单任务的本地订单绑定和归属状态。 + */ + @Override + public boolean updateOrderTaskBinding( + String hotelId, + Long orderTaskId, + Long expectedVersion, + Long expectedOrderId, + String expectedTargetResolutionStatus, + Long confirmedOrderId, + String targetResolutionStatus, + LocalDateTime now) { + LambdaUpdateWrapper update = Wrappers.lambdaUpdate(ReservationV4OrderTaskEntity.class) + .set(ReservationV4OrderTaskEntity::getOrderId, confirmedOrderId) + .set(ReservationV4OrderTaskEntity::getTargetResolutionStatus, targetResolutionStatus) + .set(ReservationV4OrderTaskEntity::getUpdatedAt, now) + .setSql("version = version + 1") + .eq(ReservationV4OrderTaskEntity::getHotelId, hotelId) + .eq(ReservationV4OrderTaskEntity::getId, orderTaskId) + .eq(ReservationV4OrderTaskEntity::getVersion, expectedVersion) + .eq(ReservationV4OrderTaskEntity::getTargetResolutionStatus, expectedTargetResolutionStatus) + .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt); + if (expectedOrderId == null) { + update.isNull(ReservationV4OrderTaskEntity::getOrderId); + } else { + update.eq(ReservationV4OrderTaskEntity::getOrderId, expectedOrderId); + } + int updated = orderTaskMapper.update(update); + return updated == 1; + } + /** * 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java index 03c65dc..279e459 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java @@ -98,6 +98,31 @@ public interface ReservationV4WorkflowRepository { String confirmedBy, LocalDateTime confirmedAt); + /** + * 按 version 乐观锁完成 V4 人工复核卡,同时写入复核结果、确认 payload、确认人和确认时间。 + */ + boolean resolveTaskCardReviewWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String reviewResolutionJson, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt); + + /** + * 在复核场景确认 V4 订单任务归属,只更新当前订单任务的本地订单绑定和归属状态。 + */ + boolean updateOrderTaskBinding( + String hotelId, + Long orderTaskId, + Long expectedVersion, + Long expectedOrderId, + String expectedTargetResolutionStatus, + Long confirmedOrderId, + String targetResolutionStatus, + LocalDateTime now); + /** * 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CommandService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CommandService.java index a5137c6..1bd91aa 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CommandService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CommandService.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.service; import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewResolutionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; @@ -20,6 +21,15 @@ public interface ReservationV4CommandService { ReservationV4CardConfirmRequest request, AuthenticatedUserContext actor); + /** + * 复核解阻指定 V4 REVIEW_REQUIRED 任务卡,并返回刷新后的订单任务详情。 + */ + ReservationV4OrderTaskDetailResult resolveTaskCardReview( + Long orderTaskId, + Long cardId, + ReservationV4ReviewResolutionRequest request, + AuthenticatedUserContext actor); + /** * 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。 */ 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 8eb1524..d82a28e 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 @@ -4,15 +4,22 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; 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.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewFieldOverrideRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewResolutionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; @@ -23,14 +30,18 @@ import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandServi import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; +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.Set; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,7 +54,34 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private static final String ACTOR_TYPE_USER = "USER"; 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 Set REVIEW_READONLY_ROOT_FIELDS = Set.of( + "ai_payload_json", + "attachments", + "blocking_points", + "card_type", + "card_status", + "conflicting_points", + "evidence_to_check", + "event_type", + "field_contract_version", + "known_fields", + "manual_review", + "missing_fields", + "order_ref", + "raw_evidence", + "review_status", + "result_type", + "route", + "route_code", + "source_event_index", + "source_message", + "source_message_id", + "target_order", + "task_subtype", + "task_type", + "validation_errors"); private final ReservationV4WorkflowRepository workflowRepository; private final ReservationV4SourceNotificationRepository sourceNotificationRepository; @@ -106,6 +144,75 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id()); } + /** + * 复核解阻指定 V4 REVIEW_REQUIRED 卡片,并返回刷新后的订单任务详情。 + */ + @Override + @Transactional + public ReservationV4OrderTaskDetailResult resolveTaskCardReview( + Long orderTaskId, + Long cardId, + ReservationV4ReviewResolutionRequest request, + AuthenticatedUserContext actor) { + Long expectedVersion = requireVersion(request == null ? null : request.version()); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + ReservationV4OrderTaskSnapshot orderTask = requireOrderTask(orderTaskId); + requireHotelAccess(orderTask.hotelId()); + ReservationV4TaskCardSnapshot card = requireTaskCard(orderTask.hotelId(), cardId, orderTask.id()); + validateCardReviewable(orderTask, card); + Long confirmedOrderId = parseConfirmedOrderId(request == null ? null : request.confirmedOrderId()); + Long targetOrderId = resolveReviewTargetOrderId(orderTask, confirmedOrderId); + ensureNoPriorOrderTaskBlocking(orderTask, targetOrderId); + ensureBasicInformationConfirmed(orderTask, card); + + ObjectNode confirmedPayload = mutableDisplayPayload(card); + List> normalizedOverrides = applyReviewFieldOverrides( + card, + confirmedPayload, + request == null ? null : request.fieldOverrides()); + String actorId = actorIdentifier(actor); + String confirmedPayloadJson = toJson(confirmedPayload); + String reviewResolutionJson = reviewResolutionJson( + request, + normalizedOverrides, + confirmedOrderId, + actorId, + now); + boolean updated = workflowRepository.resolveTaskCardReviewWithVersion( + orderTask.hotelId(), + card.id(), + expectedVersion, + reviewResolutionJson, + confirmedPayloadJson, + actorId, + now); + if (!updated) { + handleCardReviewRace(orderTask.hotelId(), card.id()); + } + ReservationV4OrderTaskSnapshot latestOrderTask = orderTask; + boolean shouldUpdateOrderBinding = confirmedOrderId != null + && (!Objects.equals(confirmedOrderId, orderTask.orderId()) + || !ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus())); + if (shouldUpdateOrderBinding) { + boolean bindingUpdated = workflowRepository.updateOrderTaskBinding( + orderTask.hotelId(), + orderTask.id(), + orderTask.version(), + orderTask.orderId(), + orderTask.targetResolutionStatus(), + confirmedOrderId, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + now); + if (!bindingUpdated) { + throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_BINDING_CONFLICT", "V4 订单任务归属已变化,请刷新后重试。"); + } + latestOrderTask = requireOrderTask(orderTask.id()); + } + refreshOrderTaskStatus(latestOrderTask, now); + writeCardReviewResolveAudit(orderTask, card, actorId, normalizedOverrides, confirmedOrderId, request, now); + return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id()); + } + /** * 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。 */ @@ -177,6 +284,23 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + private void validateCardReviewable( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card) { + if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) { + throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_REVIEWABLE", "来源邮件展示卡不允许复核解阻。"); + } + if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) { + throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复复核。"); + } + if (!ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) { + throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_REVIEW_REQUIRED", "当前卡片不是待复核状态。"); + } + if (ReservationV4OrderTaskStatus.COMPLETED.name().equals(orderTask.orderTaskStatus())) { + throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_ALREADY_COMPLETED", "该 V4 订单任务已经完成。"); + } + } + private void ensureBasicInformationConfirmed( ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot card) { @@ -201,17 +325,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask) { - if (orderTask.orderId() == null) { + ensureNoPriorOrderTaskBlocking(orderTask, orderTask.orderId()); + } + + private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask, Long targetOrderId) { + if (targetOrderId == null) { return; } List sameOrderTasks = workflowRepository.findOrderTasksByOrderIds( orderTask.hotelId(), - List.of(orderTask.orderId())); + List.of(targetOrderId)); for (ReservationV4OrderTaskSnapshot candidate : sameOrderTasks) { if (Objects.equals(candidate.id(), orderTask.id())) { return; } - if (!ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) { + if (isPriorOrderTask(candidate, orderTask) + && !ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) { throw error( HttpStatus.CONFLICT, "V4_PRIOR_ORDER_TASK_NOT_COMPLETED", @@ -220,6 +349,70 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + private boolean isPriorOrderTask( + ReservationV4OrderTaskSnapshot candidate, + ReservationV4OrderTaskSnapshot current) { + int sourceReceivedCompare = compareNullableTime(candidate.sourceReceivedAt(), current.sourceReceivedAt()); + if (sourceReceivedCompare != 0) { + return sourceReceivedCompare < 0; + } + int sourceMessageCompare = compareNullableLong(candidate.sourceMessageId(), current.sourceMessageId()); + if (sourceMessageCompare != 0) { + return sourceMessageCompare < 0; + } + int orderContextCompare = compareNullableInteger(candidate.orderContextIndex(), current.orderContextIndex()); + if (orderContextCompare != 0) { + return orderContextCompare < 0; + } + int createdCompare = compareNullableTime(candidate.createdAt(), current.createdAt()); + if (createdCompare != 0) { + return createdCompare < 0; + } + if (candidate.id() == null || current.id() == null) { + return false; + } + return candidate.id() < current.id(); + } + + private int compareNullableTime(LocalDateTime left, LocalDateTime right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return left.compareTo(right); + } + + private int compareNullableLong(Long left, Long right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return left.compareTo(right); + } + + private int compareNullableInteger(Integer left, Integer right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return left.compareTo(right); + } + private void refreshOrderTaskStatus(ReservationV4OrderTaskSnapshot orderTask, LocalDateTime now) { List cards = workflowRepository.findTaskCardsByOrderTaskId( orderTask.hotelId(), @@ -250,6 +443,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。"); } + private void handleCardReviewRace(String hotelId, Long cardId) { + ReservationV4TaskCardSnapshot latest = workflowRepository.findTaskCardById(hotelId, cardId) + .orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。")); + if (ReservationV4CardStatus.CONFIRMED.name().equals(latest.cardStatus())) { + throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复复核。"); + } + throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。"); + } + private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) { return toJson(confirmedPayload); @@ -260,6 +462,261 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return "{}"; } + private ObjectNode mutableDisplayPayload(ReservationV4TaskCardSnapshot card) { + if (!hasText(card.displayPayloadJson())) { + return objectMapper.createObjectNode(); + } + try { + JsonNode node = objectMapper.readTree(card.displayPayloadJson()); + if (node == null || node.isNull()) { + return objectMapper.createObjectNode(); + } + if (!node.isObject()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_PAYLOAD_NOT_OBJECT", "当前卡展示 payload 不是对象结构。"); + } + return ((ObjectNode) node).deepCopy(); + } catch (ReservationTaskWorkflowException exception) { + throw exception; + } catch (Exception exception) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_PAYLOAD_INVALID", "当前卡展示 payload 无法解析。"); + } + } + + private List> applyReviewFieldOverrides( + ReservationV4TaskCardSnapshot card, + ObjectNode confirmedPayload, + List fieldOverrides) { + if (fieldOverrides == null || fieldOverrides.isEmpty()) { + return List.of(); + } + List> normalized = new ArrayList<>(); + Set seenPointers = new HashSet<>(); + for (ReservationV4ReviewFieldOverrideRequest override : fieldOverrides) { + String pointer = trimToNull(override == null ? null : override.fieldPointer()); + List segments = decodeJsonPointer(pointer); + JsonNode value = override == null || override.value() == null ? objectMapper.nullNode() : override.value(); + ensureReviewPointerWritable(card, confirmedPayload, segments, value); + if (!seenPointers.add(pointer)) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_DUPLICATED", "复核字段不能重复提交。"); + } + setPointerValue(confirmedPayload, segments, value); + Map normalizedOverride = new LinkedHashMap<>(); + normalizedOverride.put("field_pointer", pointer); + normalizedOverride.put("value", value); + normalized.add(normalizedOverride); + } + return normalized; + } + + private List decodeJsonPointer(String pointer) { + if (!hasText(pointer) || !pointer.startsWith("/")) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针必须是 RFC 6901 JSON Pointer。"); + } + String[] rawSegments = pointer.substring(1).split("/", -1); + List segments = new ArrayList<>(rawSegments.length); + for (String rawSegment : rawSegments) { + segments.add(decodeJsonPointerSegment(rawSegment)); + } + return segments; + } + + private String decodeJsonPointerSegment(String rawSegment) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < rawSegment.length(); i++) { + char current = rawSegment.charAt(i); + if (current != '~') { + builder.append(current); + continue; + } + if (i + 1 >= rawSegment.length()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针转义不合法。"); + } + char escaped = rawSegment.charAt(++i); + if (escaped == '0') { + builder.append('~'); + } else if (escaped == '1') { + builder.append('/'); + } else { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针转义不合法。"); + } + } + return builder.toString(); + } + + private void ensureReviewPointerWritable( + ReservationV4TaskCardSnapshot card, + ObjectNode confirmedPayload, + List segments, + JsonNode value) { + if (segments.isEmpty()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针不能为空。"); + } + if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。"); + } + ensureReviewPointerInsideEditableContainer(card, segments); + if (value != null && value.isContainerNode()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。"); + } + JsonNode current = findPointerValue(confirmedPayload, segments); + if (current == null || current.isMissingNode()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); + } + if (current.isContainerNode()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。"); + } + } + + private void ensureReviewPointerInsideEditableContainer( + ReservationV4TaskCardSnapshot card, + List segments) { + if (segments.size() < 2) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); + } + String root = segments.get(0); + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + if (!"basic_information".equals(root)) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Basic Information 卡允许编辑字段内。"); + } + return; + } + if (!"business_fields".equals(root)) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。"); + } + } + + private JsonNode findPointerValue(JsonNode root, List segments) { + JsonNode current = root; + for (String segment : segments) { + if (current == null || current.isMissingNode()) { + return null; + } + if (current.isObject()) { + current = current.get(segment); + } else if (current.isArray()) { + Integer index = parseArrayIndex(segment); + current = index == null || index >= current.size() ? null : current.get(index); + } else { + return null; + } + } + return current; + } + + private void setPointerValue(ObjectNode root, List segments, JsonNode value) { + JsonNode parent = root; + for (int i = 0; i < segments.size() - 1; i++) { + String segment = segments.get(i); + if (parent.isObject()) { + parent = parent.get(segment); + } else if (parent.isArray()) { + Integer index = parseArrayIndex(segment); + parent = index == null || index >= parent.size() ? null : parent.get(index); + } else { + parent = null; + } + if (parent == null || parent.isMissingNode()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); + } + } + String leaf = segments.get(segments.size() - 1); + JsonNode safeValue = value == null ? objectMapper.nullNode() : value; + if (parent.isObject()) { + ((ObjectNode) parent).set(leaf, safeValue); + return; + } + if (parent.isArray()) { + Integer index = parseArrayIndex(leaf); + if (index == null || index >= parent.size()) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); + } + ((ArrayNode) parent).set(index, safeValue); + return; + } + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); + } + + private Integer parseArrayIndex(String segment) { + if (!hasText(segment)) { + return null; + } + if (segment.length() > 1 && segment.startsWith("0")) { + return null; + } + for (int i = 0; i < segment.length(); i++) { + if (!Character.isDigit(segment.charAt(i))) { + return null; + } + } + try { + return Integer.parseInt(segment); + } catch (NumberFormatException exception) { + return null; + } + } + + private Long parseConfirmedOrderId(String confirmedOrderId) { + String normalized = trimToNull(confirmedOrderId); + if (normalized == null) { + return null; + } + try { + long parsed = Long.parseLong(normalized); + if (parsed <= 0) { + throw new NumberFormatException("order id must be positive"); + } + return parsed; + } catch (NumberFormatException exception) { + throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_ORDER_ID_INVALID", "确认订单 ID 必须是正整数。"); + } + } + + private Long resolveReviewTargetOrderId(ReservationV4OrderTaskSnapshot orderTask, Long confirmedOrderId) { + boolean ownershipUnresolved = orderTask.orderId() == null + || !ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus()); + if (confirmedOrderId == null) { + if (ownershipUnresolved) { + throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_ORDER_ID_REQUIRED", "订单归属未解决时必须提交确认订单 ID。"); + } + return orderTask.orderId(); + } + ReservationOrderSnapshot order = auditRepository.findOrderById(orderTask.hotelId(), confirmedOrderId) + .orElseThrow(() -> notFound("V4_CONFIRMED_ORDER_NOT_FOUND", "确认订单不存在或不属于当前酒店。")); + if (ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus())) { + throw error(HttpStatus.CONFLICT, "V4_CONFIRMED_ORDER_NOT_AVAILABLE", "确认订单已逻辑删除,不能作为复核归属。"); + } + if (ReservationOrderVisibility.HIDDEN_SYSTEM.name().equals(order.orderVisibility())) { + throw error(HttpStatus.CONFLICT, "V4_CONFIRMED_ORDER_NOT_AVAILABLE", "系统隐藏订单不能作为复核归属。"); + } + return confirmedOrderId; + } + + private String reviewResolutionJson( + ReservationV4ReviewResolutionRequest request, + List> normalizedOverrides, + Long confirmedOrderId, + String actorId, + LocalDateTime now) { + ObjectNode resolution = objectMapper.createObjectNode(); + resolution.put("schema_version", "reservation-v4-review-resolution-v1"); + resolution.put("resolved_by", actorId); + resolution.put("resolved_at", now.atOffset(ZoneOffset.UTC).toString()); + resolution.put("reason", trimToNull(request == null ? null : request.reason())); + resolution.put("order_ownership_confirmed", confirmedOrderId != null); + if (confirmedOrderId == null) { + resolution.putNull("confirmed_order_id"); + } else { + resolution.put("confirmed_order_id", confirmedOrderId.toString()); + } + ArrayNode overrides = resolution.putArray("field_overrides"); + for (Map normalizedOverride : normalizedOverrides) { + ObjectNode overrideNode = overrides.addObject(); + overrideNode.put("field_pointer", String.valueOf(normalizedOverride.get("field_pointer"))); + overrideNode.set("value", (JsonNode) normalizedOverride.get("value")); + } + return toJson(resolution); + } + private ReservationV4OrderTaskSnapshot requireOrderTask(Long orderTaskId) { return workflowRepository.findOrderTaskById(orderTaskId) .orElseThrow(() -> notFound("V4_ORDER_TASK_NOT_FOUND", "V4 订单任务不存在。")); @@ -344,6 +801,48 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ now)); } + private void writeCardReviewResolveAudit( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + String actorId, + List> normalizedOverrides, + Long confirmedOrderId, + ReservationV4ReviewResolutionRequest request, + LocalDateTime now) { + Map beforeSnapshot = new LinkedHashMap<>(); + beforeSnapshot.put("v4_order_task_id", orderTask.id().toString()); + beforeSnapshot.put("v4_task_card_id", card.id().toString()); + beforeSnapshot.put("card_type", card.cardType()); + beforeSnapshot.put("card_status", card.cardStatus()); + beforeSnapshot.put("review_status", card.reviewStatus()); + beforeSnapshot.put("order_id", orderTask.orderId() == null ? null : orderTask.orderId().toString()); + beforeSnapshot.put("target_resolution_status", orderTask.targetResolutionStatus()); + beforeSnapshot.put("version", card.version()); + Map afterSnapshot = new LinkedHashMap<>(); + afterSnapshot.put("v4_order_task_id", orderTask.id().toString()); + afterSnapshot.put("v4_task_card_id", card.id().toString()); + afterSnapshot.put("card_type", card.cardType()); + afterSnapshot.put("card_status", ReservationV4CardStatus.CONFIRMED.name()); + afterSnapshot.put("review_status", ReservationReviewStatus.RESOLVED.name()); + afterSnapshot.put("confirmed_by", actorId); + afterSnapshot.put("field_pointers", normalizedOverrides.stream() + .map(item -> String.valueOf(item.get("field_pointer"))) + .toList()); + afterSnapshot.put("confirmed_order_id", confirmedOrderId == null ? null : confirmedOrderId.toString()); + auditRepository.insertAuditLog(new ReservationAuditLogDraft( + orderTask.hotelId(), + confirmedOrderId == null ? orderTask.orderId() : confirmedOrderId, + null, + null, + ACTOR_TYPE_USER, + actorId, + ACTION_V4_CARD_REVIEW_RESOLVE, + trimToNull(request == null ? null : request.reason()), + toJson(beforeSnapshot), + toJson(afterSnapshot), + now)); + } + private void writeSourceNotificationAckAudit( ReservationV4SourceNotificationSnapshot notification, String actorId, 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 a9150e6..cf00c66 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 @@ -405,6 +405,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService false, false, false, + false, ReservationV4ReadonlyReasonCode.PRIOR_ORDER_TASK_NOT_COMPLETED.name(), blocker.id().toString(), null, @@ -417,6 +418,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService false, false, false, + false, ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null, @@ -431,10 +433,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService safeCards, ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null); if (basicCard != null && ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(basicCard.cardStatus())) { - return readOnlyAvailability( - ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(), - null, - "V4 人工复核写接口将在后续 checkpoint 开放。"); + return reviewableAvailability(null, null); } boolean hasConfirmableCard = safeCards.stream() .anyMatch(card -> cardConfirmableAtOrderLevel(card, basicCard)); @@ -445,6 +444,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService true, true, false, + false, ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), null, null, @@ -458,6 +458,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService false, false, false, + false, ReservationV4ReadonlyReasonCode.PRIOR_CARD_NOT_CONFIRMED.name(), null, basicCard.id().toString(), @@ -466,10 +467,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService boolean hasReviewCard = safeCards.stream() .anyMatch(card -> ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())); if (hasReviewCard) { - return readOnlyAvailability( - ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(), - null, - "V4 人工复核写接口将在后续 checkpoint 开放。"); + return reviewableAvailability(null, null); } return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); } @@ -510,6 +508,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService false, false, false, + false, orderAvailability.readonlyReasonCode(), orderAvailability.blockedByOrderTaskId(), null, @@ -522,6 +521,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService false, false, false, + false, ReservationV4ReadonlyReasonCode.PRIOR_CARD_NOT_CONFIRMED.name(), null, basicCard.id().toString(), @@ -537,22 +537,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService true, true, false, + false, ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), null, null, null); } if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) { - return new ReservationV4ActionAvailabilityResult( - false, - true, - false, - false, - false, - ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(), - null, - null, - "V4 人工复核写接口将在后续 checkpoint 开放。"); + return reviewableAvailability(null, null); } return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); } @@ -567,6 +559,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService false, false, false, + false, true, ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), null, @@ -584,12 +577,29 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService false, false, false, + false, reasonCode, null, blockedByCardId, blockedReason); } + private ReservationV4ActionAvailabilityResult reviewableAvailability( + String blockedByCardId, + String blockedReason) { + return new ReservationV4ActionAvailabilityResult( + blockedByCardId != null, + false, + true, + false, + true, + false, + ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), + null, + blockedByCardId, + blockedReason); + } + private boolean isBusinessCardBlockedByBasic( ReservationV4TaskCardSnapshot card, ReservationV4TaskCardSnapshot basicCard) { diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java index 35410ba..bf46bd9 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java @@ -73,6 +73,7 @@ class AuthControllerTest { "RESERVATION_TASK_READ", "RESERVATION_TASK_EDIT", "RESERVATION_TASK_CONFIRM", + "RESERVATION_MANUAL_REVIEW_RESOLVE", "RESERVATION_OPERA_SIM_EXECUTE", "RESERVATION_AUDIT_READ", "RESERVATION_INVOICE_GENERATE", 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 c695684..6c01608 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 @@ -28,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardTyp import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; import java.time.Instant; @@ -206,6 +207,286 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED")); } + @Test + void shouldResolveBasicInformationReviewAndConfirmOrderOwnership() throws Exception { + Long confirmedOrderId = 990000000000070002L; + seedReservationOrder(HOTEL_ID, confirmedOrderId); + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-basic-001", + Instant.parse("2026-07-19T01:21:00Z"), + 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, + "reason": "确认订单归属并补齐旅行社代码", + "confirmed_order_id": "990000000000070002", + "field_overrides": [ + { + "field_pointer": "/basic_information/account_code", + "value": "QBD_TRAVEL" + } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_task.order_id").value(confirmedOrderId.toString())) + .andExpect(jsonPath("$.order_task.target_resolution_status").value("RESOLVED")) + .andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED")) + .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.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 shouldResolveOrderOwnershipStatusWhenConfirmedOrderIdAlreadyBound() throws Exception { + Long confirmedOrderId = 990000000000070009L; + seedReservationOrder(HOTEL_ID, confirmedOrderId); + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-same-order-001", + Instant.parse("2026-07-19T01:21:30Z"), + confirmedOrderId, + ReservationV4TargetResolutionStatus.UNRESOLVED.name(), + 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": "990000000000070009", + "field_overrides": [ + {"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"} + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_task.order_id").value(confirmedOrderId.toString())) + .andExpect(jsonPath("$.order_task.target_resolution_status").value("RESOLVED")); + } + + @Test + void shouldResolveBusinessCardReviewAfterBasicInformationConfirmed() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-business-001", + Instant.parse("2026-07-19T01:22:00Z"), + 990000000000070003L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name()); + 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/pms_room_type_code", + "value": "RM2" + } + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_task.order_task_status").value("COMPLETED")) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")) + .andExpect(jsonPath("$.business_cards[0].review_status").value("RESOLVED")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.room_items[0].pms_room_type_code") + .value("RM2")) + .andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射")); + } + + @Test + void shouldRejectReviewResolutionWhenOrderOwnershipUnresolvedAndConfirmedOrderMissing() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-order-required-001", + Instant.parse("2026-07-19T01:22:30Z"), + 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, + "field_overrides": [ + {"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_CONFIRMED_ORDER_ID_REQUIRED")); + } + + @Test + void shouldRejectReviewResolutionForUnknownConfirmedOrder() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-unknown-order-001", + Instant.parse("2026-07-19T01:22:40Z"), + 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": "990000000000070199", + "field_overrides": [ + {"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"} + ] + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error_code").value("V4_CONFIRMED_ORDER_NOT_FOUND")); + } + + @Test + void shouldRejectReviewResolutionForIllegalPointer() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-illegal-pointer-001", + Instant.parse("2026-07-19T01:23:00Z"), + 990000000000070004L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name()); + 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": "business_fields/room_items/0/pms_room_type_code", "value": "RM2"} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID")); + } + + @Test + void shouldRejectReviewResolutionForReadonlyPointer() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-readonly-pointer-001", + Instant.parse("2026-07-19T01:24:00Z"), + 990000000000070005L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name()); + 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": "/card_type", "value": "PAYMENT"} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY")); + } + + @Test + void shouldRejectReviewResolutionForContainerPointer() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-container-pointer-001", + Instant.parse("2026-07-19T01:24:15Z"), + 990000000000070010L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name()); + 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": "/business_fields/room_items", "value": []} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_REVIEW_VALUE_INVALID")); + } + + @Test + void shouldRejectRepeatedReviewResolution() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-repeat-001", + Instant.parse("2026-07-19T01:24:30Z"), + 990000000000070006L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name()); + confirmBasicCard(seeded); + resolveBusinessReviewCard(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": 1, + "field_overrides": [ + {"field_pointer": "/business_fields/room_items/0/pms_room_type_code", "value": "RM3"} + ] + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED")); + } + @Test void shouldRejectCardConfirmWhenPriorOrderTaskIsOpen() throws Exception { Long sharedOrderId = 990000000000040001L; @@ -360,6 +641,24 @@ class ReservationV4CommandControllerTest { """)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + + SeededOrderTask reviewSeeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-no-permission-review-001", + Instant.parse("2026-07-19T01:42:00Z"), + 990000000000070007L, + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name()); + performAuthorized(mockMvc, noPermissionToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + reviewSeeded.orderTask().id(), + reviewSeeded.basicCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"version": 0, "field_overrides": [{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}]} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); } @Test @@ -391,6 +690,24 @@ class ReservationV4CommandControllerTest { """)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + + SeededOrderTask reviewSeeded = seedReviewOrderTask( + OTHER_HOTEL_ID, + "mail-v4-command-cross-hotel-review-001", + Instant.parse("2026-07-19T01:52:00Z"), + 990000000000070008L, + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name()); + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + reviewSeeded.orderTask().id(), + reviewSeeded.basicCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"version": 0, "field_overrides": [{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}]} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); } /** @@ -427,6 +744,23 @@ class ReservationV4CommandControllerTest { .andExpect(status().isOk()); } + private void resolveBusinessReviewCard(SeededOrderTask seeded) throws Exception { + 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": "/business_fields/room_items/0/pms_room_type_code", "value": "RM2"} + ] + } + """)) + .andExpect(status().isOk()); + } + private SeededOrderTask seedOrderTask(String hotelId, String externalMessageId, Instant receivedAt) { return seedOrderTask(hotelId, externalMessageId, receivedAt, null); } @@ -474,6 +808,77 @@ class ReservationV4CommandControllerTest { return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard); } + private SeededOrderTask seedReviewOrderTask( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String basicStatus, + String businessStatus) { + return seedReviewOrderTask( + hotelId, + externalMessageId, + receivedAt, + orderId, + orderId == null + ? ReservationV4TargetResolutionStatus.UNRESOLVED.name() + : ReservationV4TargetResolutionStatus.RESOLVED.name(), + basicStatus, + businessStatus); + } + + private SeededOrderTask seedReviewOrderTask( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String targetResolutionStatus, + String basicStatus, + String businessStatus) { + SourceMessageCaptureResult source = captureSourceMessage( + hotelId, + externalMessageId, + "V4 Command Review", + receivedAt); + LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( + hotelId, + source.inboxId(), + 990000000000060001L + Math.abs(externalMessageId.hashCode()), + "order-review-" + externalMessageId, + 1, + orderId, + "GROUP", + "GROUP_CODE", + "GRP-V4-REVIEW-001", + targetResolutionStatus, + ReservationV4OrderTaskStatus.OPEN.name(), + LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), + now)); + ReservationV4TaskCardSnapshot sourceCard = insertCard(orderTask, hotelId, + ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10, + ReservationV4CardStatus.READONLY.name(), null, """ + {"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Command Review"}} + """); + ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId, + ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, + basicStatus, reviewStatusFor(basicStatus), """ + {"card_type":"BASIC_INFORMATION","order_ref":"ORDER-REVIEW","basic_information":{"account_code":null,"manual_review":true,"missing_fields":["/basic_information/account_code"]}} + """); + 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}]}} + """); + return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard); + } + + private String reviewStatusFor(String cardStatus) { + return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(cardStatus) + ? ReservationReviewStatus.PENDING.name() + : null; + } + private ReservationV4SourceNotificationSnapshot seedSourceNotification( String hotelId, String externalMessageId, @@ -570,6 +975,29 @@ class ReservationV4CommandControllerTest { org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount); } + private void seedReservationOrder(String hotelId, Long orderId) { + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + jdbcTemplate.update( + """ + INSERT INTO workflow_reservation_order + (id, hotel_id, order_key_type, order_business_key, active_business_key, + temporary_order_code, order_status, order_visibility, business_key_source, + display_name, source_message_id, latest_activity_at, version, created_at, updated_at) + VALUES + (?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'VISIBLE', 'USER_CONFIRMED', + ?, 0, ?, 0, ?, ?) + """, + orderId, + hotelId, + "GRP-ORDER-" + orderId, + "GRP-ORDER-" + orderId, + "TMP-ORDER-" + orderId, + "订单 " + orderId, + now, + now, + now); + } + private record SeededOrderTask( ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot sourceCard, 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 52be894..d794f3d 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 @@ -251,7 +251,7 @@ class ReservationV4QueryControllerTest { } @Test - void shouldNotExposeOrderTaskAsConfirmableWhenOnlyReviewCardsRemain() throws Exception { + void shouldExposeReviewCardsAsReviewableButNotConfirmable() throws Exception { ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask( "mail-v4-query-review-only-001", Instant.parse("2026-07-18T03:05:00Z")); @@ -259,12 +259,14 @@ class ReservationV4QueryControllerTest { performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) .param("hotel_id", HOTEL_ID)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.availability.read_only").value(true)) + .andExpect(jsonPath("$.availability.read_only").value(false)) .andExpect(jsonPath("$.availability.confirmable").value(false)) - .andExpect(jsonPath("$.availability.readonly_reason_code").value("REVIEW_API_PENDING")) + .andExpect(jsonPath("$.availability.reviewable").value(true)) + .andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE")) .andExpect(jsonPath("$.basic_information_card.availability.confirmable").value(false)) + .andExpect(jsonPath("$.basic_information_card.availability.reviewable").value(true)) .andExpect(jsonPath("$.basic_information_card.availability.readonly_reason_code") - .value("REVIEW_API_PENDING")); + .value("PROCESSABLE")); } @Test