From fdb16cc4c1ba701375ed715a4f6c35eaf0b4041b Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 10:40:23 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20V4=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=92=8C=E6=9D=A5=E6=BA=90=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 16 +- .../backend-to-frontend-notes.md | 12 +- .../M002-order-task-workflow-v3.md | 4 +- .../M002-v4-agent-callback-field-contract.md | 12 +- ...002-v4-order-task-card-domain-model-cp2.md | 29 +- .../security-access-control-boundary.md | 4 +- .../ReservationV4ReadonlyReasonCode.java | 3 +- .../ReservationV4CardConfirmRequest.java | 17 + ...rvationV4SourceNotificationAckRequest.java | 13 + ...ReservationV4ActionAvailabilityResult.java | 2 +- .../ReservationTaskControllerAdvice.java | 1 + .../ReservationV4CommandController.java | 68 +++ ...ybatisReservationV4WorkflowRepository.java | 46 ++ .../ReservationV4WorkflowRepository.java | 20 + .../service/ReservationV4CommandService.java | 30 ++ .../impl/ReservationV4CommandServiceImpl.java | 408 ++++++++++++++ .../impl/ReservationV4QueryServiceImpl.java | 37 +- .../ReservationV4CommandControllerTest.java | 508 ++++++++++++++++++ .../ReservationV4QueryControllerTest.java | 15 +- 19 files changed, 1190 insertions(+), 55 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CardConfirmRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4SourceNotificationAckRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CommandService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 09a36e9..ae70d65 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入和前端查询接口并行 | -| 当前重点 | M002 V4 CP5 已开放 V4 工作台统一列表、订单任务列表 / 详情、S10/S99 来源通知详情查询接口;后续准备做 V4 卡片确认、复核和来源通知 ack 写接口 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口和卡片确认接口并行 | +| 当前重点 | M002 V4 CP6 已开放 V4 卡片确认和 S10/S99 来源通知 ack;后续准备做 V4 复核解阻、前端页面和目录校验 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP5-v4-query-interfaces` -- 状态:Done,已实现 `GET /api/reservation/workbench-items`、`GET /api/reservation/order-tasks`、`GET /api/reservation/order-tasks/{orderTaskId}`、`GET /api/reservation/source-notifications/{notificationId}`。 -- 目标:让 V4 新模型中的业务订单任务、多卡和 S10/S99 来源通知可以被前端安全查询。 -- 边界:CP5 只做查询;不实现 V4 卡片确认 / 复核、S10/S99 ack、订单详情 V4 专属时间线、OPERA / OHIP、前端页面和历史数据迁移。 +- 名称:`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、前端页面和历史数据迁移。 ## 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 已完成前端查询接口。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。V4 复核接口和 V4 前端页面仍未完成代码实现。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP6`:实现 V4 卡片确认、复核解阻、S10/S99 来源通知 ack、审计和 version 并发校验。 +- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP7`:实现 V4 复核解阻 / 复核场景订单归属确认,或进入 V4 前端页面接入 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 b96f01d..1344eab 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -38,7 +38,7 @@ - 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。 - M002 V3 新入口采用结构化 `S10/S99`:`S10` 表示未匹配当前支持的业务事件,`S99` 表示输入不足或无法形成业务素材包;旧 `S000/S999` 继续按历史数据兼容展示。 - V3 S10/S99 会创建旧 `SOURCE_MESSAGE_ONLY` 只读源邮件通知卡,任务列表可见,订单列表不可见;V4 S10/S99 已改为独立来源通知模型,并通过 V4 工作台 / 来源通知详情接口展示。 -- 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- 旧 V3 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;V4 S10/S99 来源通知支持确认已读 / 已处理 ack,但不支持编辑、复核、OPERA 或人工终止,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 - type-known manual review 已支持同卡复核解阻第一版:应展示为原业务任务卡的复核模式,不应统一展示成 Fallback。只有业务类型或 subtype 本身未知时才进入 Fallback。 - 复核场景下允许用户确认订单归属;当前第一版只允许确认当前任务所属订单,不等于开放普通任务任意切换订单。 - 历史 Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 @@ -58,6 +58,8 @@ | `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/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/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;该动作不创建订单、不参与订单阻塞。 | | `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`。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | @@ -92,7 +94,7 @@ | `GET /api/reservation/tasks/{taskId}` | 补齐顶层来源邮件字段,并扩展 `fields[]` 元数据。 | 顶层来源字段用于打开邮件会话;`fields[]` 中的 `result_type`、`task_type`、`task_subtype`、`default_value_source` 用于前端字段分组、调试和白名单对齐。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | | `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留、安全 HTML 字段和入口通知识别。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报;SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。 | -| `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口。 | 这是 V4 新模型前端主入口;查询阶段只读,`confirmable=false`、`ackable=false` 表示写接口还没开放,不是前端按钮 bug。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 | +| `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口,并在 CP6 打开卡片确认 / 来源通知 ack availability。 | 这是 V4 新模型前端主入口;前端应按每张卡或通知返回的 `availability.confirmable`、`availability.ackable`、`readonly_reason_code` 控制按钮。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 | V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100;`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。`order_task_status` 和 `card_status` 是稳定枚举查询参数,前端不要传中文文案或自造状态码。 @@ -461,15 +463,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}` 查看。卡片确认、复核和 S10/S99 ack 写接口仍未开放。 +- 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 复核写接口仍未开放。 - 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 已开放查询。当前仍不代表 V4 卡片确认、复核或 S10/S99 ack API 已经可用。 +- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack。当前仍不代表 V4 复核 API 已经可用。 - 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 兼容数据。 +- V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 - 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 - 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index c455931..0dfb18a 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -477,12 +477,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 查询接口已完成;V4 卡片确认 / 复核、S10/S99 ack 和前端页面仍未实现。 +- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型、CP5 查询接口和 CP6 普通卡片确认 / S10/S99 ack 已完成;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 卡片确认 / 复核 / 锁定、S10/S99 ack、同订单阻塞写侧校验和业务审计。 +- V4 复核解阻、复核场景订单归属确认和前端页面模型切换。 - V4 前端页面模型、任务详情字段矩阵和目录校验完全切换。 - 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。 diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index 75aef07..6906eb0 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 1.5 | | 日期 | 2026-07-19 | -| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线、CP4 入站写入新模型和 CP5 查询接口,V4 写接口仍需后续 checkpoint | +| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线、CP4 入站写入新模型、CP5 查询接口和 CP6 普通卡片确认 / S10/S99 ack | | 适用范围 | 0718 业务基线下,Agent → Adapter / MCP → 信息系统的业务回调字段 | | 不适用范围 | 数据库表设计、前端视觉细节、真实 PMS API、技术失败后台重试、旧 M002 V3 数据兼容 | @@ -16,7 +16,7 @@ 本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前后端已按本文完成 V4 入站解析基线:能识别 V4 包、校验关键契约、保存 AI transition / 任务卡原始 payload,并把可映射的六类 event 先接入现有订单任务链路。 -V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP5,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 工作台、订单任务列表 / 详情和来源通知详情查询接口已实现,卡片确认、复核和来源通知 ack 写接口仍未实现。 +V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP6,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、普通卡片确认和 S10/S99 ack 写接口已实现,V4 复核写接口仍未实现。 当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。 @@ -723,18 +723,20 @@ AI 回调包 - 合法 V4 event 按 `event_type` 创建 `ROOM_INFORMATION`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST` 或 `PAYMENT` 业务卡;event 契约错误只落 AI transition。 - V4 S10/S99 写入 `workflow_reservation_v4_source_notification`,状态为 `ACK_REQUIRED`。 -当前 CP5 已完成: +当前 CP5 / CP6 已完成: - `GET /api/reservation/workbench-items` 查询 V4 工作台统一列表,混排 V4 业务订单任务和 S10/S99 来源通知。 - `GET /api/reservation/order-tasks` 查询 V4 业务订单任务列表,不包含 S10/S99 来源通知。 - `GET /api/reservation/order-tasks/{orderTaskId}` 查询 V4 订单任务详情,按来源邮件展示卡、Basic Information 卡和业务卡拆分。 - `GET /api/reservation/source-notifications/{notificationId}` 查询 V4 S10/S99 来源通知详情。 - 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 并发校验。 当前仍未完成: - 普通 V4 业务包暂时仍保留旧 V3 任务状态、草稿和 OPERA 模拟骨架兼容,便于前端过渡;V4 写接口和前端页面完成后再逐步废弃旧链路。 -- V4 卡片确认、复核解阻和 S10/S99 来源通知 ack 写接口。 +- V4 复核解阻写接口。 - 尚未接入真实 PMS / OPERA / OHIP。 - 尚未改造前端 V4 页面模型。 @@ -754,4 +756,4 @@ AI 回调包 - 技术异常只进入 AI transition / 技术运行记录,不进入用户可处理卡。 - CP3 已新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,继续复用 SourceMessage、AI batch、AI transition、Reservation Order 和业务审计表。 -CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。CP4 已把 V4 回调写入新模型。CP5 已开放查询接口。后续开发应继续补卡片确认 / 复核、S10/S99 ack 和前端页面。 +CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。CP4 已把 V4 回调写入新模型,CP5 已开放查询接口,CP6 已开放普通卡片确认和 S10/S99 ack。后续开发应继续补 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 77633aa..168f035 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 @@ -4,11 +4,11 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.3 | +| 文档版本 | 0.4 | | 日期 | 2026-07-19 | -| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口已实现 | +| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口已实现;CP6 卡片确认和 S10/S99 ack 已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | -| 不适用范围 | 卡片确认 / 复核接口、S10/S99 ack 接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | +| 不适用范围 | V4 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | ## 1. 文档定位 @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP5,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情和来源通知详情查询接口;V4 卡片确认、复核和来源通知 ack 写接口仍未开放。 +截至 CP6,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、V4 卡片确认接口和 S10/S99 来源通知 ack 接口;V4 复核写接口仍未开放。 后续如本文与 `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 / CP7 | +| Basic Information | 已写入 V4 Basic Information 独立卡 | CP6 已支持确认并锁定;目录校验和复核写接口仍待后续 checkpoint | | 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task`,并已额外创建 V4 业务卡 | 旧任务链路仍作前端过渡兼容,后续 V4 查询和写接口完成后再逐步废弃 | | 技术错误 | 已落 `adapter_contract_error` transition | 已符合目标方向:不创建用户可处理卡 | | 草稿 / READY / OPERA | 仍复用 V3 草稿、READY 和 OPERA 模拟骨架 | V4 新数据确认口径是不保存草稿、确认后锁定、当前不生成 OPERA | @@ -417,7 +417,7 @@ CP3 Repository 已封装: - 来源通知状态的 version 乐观锁更新基础方法。 - event 卡必须传入正数一基 `source_event_index`;漏传会拒绝写入,避免被误当成非 event 卡。 -CP5 尚未实现卡片确认、复核解阻、订单归属确认、来源通知 ack 的业务 Service 和 API;这些仍属于后续 checkpoint。 +CP6 已实现普通卡片确认和 S10/S99 来源通知 ack 的业务 Service 和 API;V4 复核解阻、复核场景订单归属确认仍属于后续 checkpoint。 Service 不直接访问 Mapper。 @@ -427,10 +427,10 @@ Service 不直接访问 Mapper。 - `ReservationV4TaskIntakeService`:已实现,V4 入站从 AI transition 落 V4 订单任务、卡片和 S10/S99 来源通知。 - `ReservationV4OrderTaskQueryService`:前端查询订单任务列表和详情。 -- `ReservationV4TaskCardCommandService`:处理卡片确认、复核和订单归属确认。 -- `ReservationV4SourceNotificationService`:处理 S10/S99 来源通知查询和确认。 +- `ReservationV4CommandService`:已实现卡片确认和 S10/S99 来源通知确认;后续继续承接 V4 复核和订单归属确认。 +- `ReservationV4SourceNotificationService`:来源通知查询已并入 V4 查询服务,确认已并入 V4 命令服务。 -当前 `ReservationAiTaskIntakeServiceImpl` 已在 V4 分支调用 `ReservationV4TaskIntakeService` 完成新模型写入;后续查询、确认和复核仍应继续拆到独立 V4 service,避免主入站类继续膨胀。 +当前 `ReservationAiTaskIntakeServiceImpl` 已在 V4 分支调用 `ReservationV4TaskIntakeService` 完成新模型写入;查询和确认已拆到独立 V4 service,后续复核仍应继续留在 V4 命令侧,避免主入站类继续膨胀。 ## 12. 前端查询接口 @@ -578,7 +578,7 @@ GET /api/reservation/orders/{orderId}/order-tasks 用于订单详情页展示 V4 订单任务时间线。旧 `GET /api/reservation/orders/{orderId}` 可以在过渡期继续返回 V3 `tasks[]`。 -## 13. 前端写操作接口草案 +## 13. 前端写操作接口 ### 13.1 确认卡片 @@ -590,10 +590,14 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm 请求要点: -- 只提交当前卡允许编辑的字段。 +- 请求 JSON 必须携带 `version`;`confirmed_payload` 可选,未传时后端使用当前展示 payload 作为确认快照。 +- 只提交当前卡允许编辑的字段或当前卡确认快照。 - 不提交草稿。 - 必须带 `version` 做并发校验。 - 后端确认后卡片 `CONFIRMED` 并锁定。 +- 返回刷新后的 `GET /api/reservation/order-tasks/{orderTaskId}` 详情结构。 +- Basic Information 必须先确认;业务卡第一版不强制逐张顺序确认。 +- `SOURCE_MESSAGE_DISPLAY`、`CONFIRMED`、`REVIEW_REQUIRED` 卡不能通过该接口确认。 ### 13.2 复核并确认卡片 @@ -625,6 +629,7 @@ S10/S99 已确认采用来源通知模型,不继续复用隐藏技术订单或 - 必须带 `version` 做并发校验。 - 确认后 `notification_status=ACKED`,写确认人和 UTC 确认时间。 - 重复提交已确认通知应返回幂等成功或明确的已确认状态,不允许回退到 `ACK_REQUIRED`。 +- 返回刷新后的 `GET /api/reservation/source-notifications/{notificationId}` 详情结构。 ## 14. 旧 V3 / V2 兼容和废弃边界 @@ -681,7 +686,7 @@ 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 来源通知确认 | +| 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 能力收口评估 | 明确哪些兼容入口可以关闭,哪些仍保留只读历史 | diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 0b1bb97..f6d967a 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -53,9 +53,9 @@ | `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 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 | 必须写业务审计 | +| `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/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 必须写业务审计,记录已读 / 已处理确认 | +| `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 V4 S10/S99 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞 | 必须写业务审计,记录已读 / 已处理确认,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` + 酒店访问权 | 必须写业务审计 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4ReadonlyReasonCode.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4ReadonlyReasonCode.java index 7bd57a5..924f579 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4ReadonlyReasonCode.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4ReadonlyReasonCode.java @@ -10,5 +10,6 @@ public enum ReservationV4ReadonlyReasonCode { PRIOR_CARD_NOT_CONFIRMED, PRIOR_ORDER_TASK_NOT_COMPLETED, COMMAND_API_PENDING, - ACK_API_PENDING + ACK_API_PENDING, + REVIEW_API_PENDING } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CardConfirmRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CardConfirmRequest.java new file mode 100644 index 0000000..32a98d9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CardConfirmRequest.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 卡片确认请求。confirmed_payload 为空时后端使用当前展示 payload 作为确认快照。 + * + * @param version 前端读取到的卡片乐观锁版本 + * @param confirmedPayload 用户确认后的卡片 payload + */ +public record ReservationV4CardConfirmRequest( + Long version, + @JsonProperty("confirmed_payload") + JsonNode confirmedPayload +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4SourceNotificationAckRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4SourceNotificationAckRequest.java new file mode 100644 index 0000000..9fbc246 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4SourceNotificationAckRequest.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +/** + * Reservation V4 来源通知确认请求。第一版只要求带上前端读取到的 version。 + * + * @param version 来源通知乐观锁版本 + * @param reason 可选处理说明,当前只写入审计摘要 + */ +public record ReservationV4SourceNotificationAckRequest( + Long version, + String reason +) { +} 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 750b127..2c7f842 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 @@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; /** - * Reservation V4 查询侧动作可用性。CP5 只读查询阶段不开放写接口,因此 confirmable / ackable 默认由后续 CP 打开。 + * Reservation V4 查询侧动作可用性。用于前端判断卡片确认、来源通知确认和只读原因。 * * @param blocked 是否被前置订单任务或卡片阻塞 * @param readOnly 当前是否只读 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java index 653c3bb..3a2e217 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; ReservationTaskController.class, ReservationFrontendQueryController.class, ReservationV4QueryController.class, + ReservationV4CommandController.class, ReservationDemoDataController.class, ReservationInvoiceGenerationController.class, ReservationRoomingListGenerationController.class 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 new file mode 100644 index 0000000..b882194 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandController.java @@ -0,0 +1,68 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +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.ReservationV4SourceNotificationAckRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reservation V4 前端写操作 Controller。只暴露卡片确认和来源通知确认入口。 + */ +@RestController +@RequestMapping("/api/reservation") +public class ReservationV4CommandController { + + private final ReservationV4CommandService commandService; + private final FrontendAuthorizationService authorizationService; + + /** + * 注入 V4 命令服务和前端鉴权服务,Controller 不直接访问 Repository。 + */ + public ReservationV4CommandController( + ReservationV4CommandService commandService, + FrontendAuthorizationService authorizationService) { + this.commandService = commandService; + this.authorizationService = authorizationService; + } + + /** + * 确认 V4 订单任务下的一张业务卡;Basic Information 未确认时业务卡不可确认。 + */ + @PostMapping( + value = "/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4OrderTaskDetailResult confirmTaskCard( + @PathVariable Long orderTaskId, + @PathVariable Long cardId, + @RequestBody(required = false) ReservationV4CardConfirmRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name()); + return commandService.confirmTaskCard(orderTaskId, cardId, request, actor); + } + + /** + * 确认 S10/S99 来源通知已读或已处理;不创建订单、不参与订单阻塞。 + */ + @PostMapping( + value = "/source-notifications/{notificationId}/ack", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4SourceNotificationDetailResult ackSourceNotification( + @PathVariable Long notificationId, + @RequestBody(required = false) ReservationV4SourceNotificationAckRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name()); + return commandService.ackSourceNotification(notificationId, request, actor); + } +} 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 7ad1c94..b320f6e 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 @@ -5,6 +5,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTask import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; 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.request.ReservationV4OrderTaskQueryRequest; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity; @@ -282,6 +283,51 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work return updated == 1; } + /** + * 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。 + */ + @Override + public boolean confirmTaskCardWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt) { + int updated = taskCardMapper.update(Wrappers.lambdaUpdate() + .set(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.CONFIRMED.name()) + .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.PENDING_CONFIRM.name()) + .isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)); + return updated == 1; + } + + /** + * 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。 + */ + @Override + public boolean updateOrderTaskStatus( + String hotelId, + Long orderTaskId, + String orderTaskStatus, + LocalDateTime now) { + int updated = orderTaskMapper.update(Wrappers.lambdaUpdate() + .set(ReservationV4OrderTaskEntity::getOrderTaskStatus, orderTaskStatus) + .set(ReservationV4OrderTaskEntity::getUpdatedAt, now) + .setSql("version = version + 1") + .eq(ReservationV4OrderTaskEntity::getHotelId, hotelId) + .eq(ReservationV4OrderTaskEntity::getId, orderTaskId) + .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)); + return updated == 1; + } + private ReservationV4OrderTaskSnapshot toOrderTaskSnapshot(ReservationV4OrderTaskEntity entity) { return new ReservationV4OrderTaskSnapshot( entity.getId(), 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 7aadc2f..03c65dc 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 @@ -86,4 +86,24 @@ public interface ReservationV4WorkflowRepository { Long expectedVersion, String cardStatus, LocalDateTime now); + + /** + * 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。 + */ + boolean confirmTaskCardWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt); + + /** + * 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。 + */ + boolean updateOrderTaskStatus( + String hotelId, + Long orderTaskId, + String orderTaskStatus, + LocalDateTime now); } 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 new file mode 100644 index 0000000..a5137c6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CommandService.java @@ -0,0 +1,30 @@ +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.ReservationV4SourceNotificationAckRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; + +/** + * Reservation V4 命令服务。承接卡片确认和 S10/S99 来源通知确认写操作。 + */ +public interface ReservationV4CommandService { + + /** + * 确认指定 V4 任务卡,并返回刷新后的订单任务详情。 + */ + ReservationV4OrderTaskDetailResult confirmTaskCard( + Long orderTaskId, + Long cardId, + ReservationV4CardConfirmRequest request, + AuthenticatedUserContext actor); + + /** + * 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。 + */ + ReservationV4SourceNotificationDetailResult ackSourceNotification( + Long notificationId, + ReservationV4SourceNotificationAckRequest request, + AuthenticatedUserContext actor); +} 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 new file mode 100644 index 0000000..c9d3a18 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java @@ -0,0 +1,408 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +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.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.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.request.ReservationV4CardConfirmRequest; +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; +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.ReservationV4CommandService; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reservation V4 命令服务实现。负责卡片确认、来源通知确认、状态刷新和业务审计。 + */ +@Service +public class ReservationV4CommandServiceImpl implements ReservationV4CommandService { + + 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_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK"; + + private final ReservationV4WorkflowRepository workflowRepository; + private final ReservationV4SourceNotificationRepository sourceNotificationRepository; + private final ReservationAiWorkflowRepository auditRepository; + private final ReservationV4QueryService queryService; + private final HotelContextService hotelContextService; + private final ObjectMapper objectMapper; + + /** + * 注入 V4 Repository、查询服务、酒店上下文和审计边界,命令服务不直接依赖 Mapper。 + */ + public ReservationV4CommandServiceImpl( + ReservationV4WorkflowRepository workflowRepository, + ReservationV4SourceNotificationRepository sourceNotificationRepository, + ReservationAiWorkflowRepository auditRepository, + ReservationV4QueryService queryService, + HotelContextService hotelContextService, + ObjectMapper objectMapper) { + this.workflowRepository = workflowRepository; + this.sourceNotificationRepository = sourceNotificationRepository; + this.auditRepository = auditRepository; + this.queryService = queryService; + this.hotelContextService = hotelContextService; + this.objectMapper = objectMapper; + } + + /** + * 确认指定 V4 任务卡,并返回刷新后的订单任务详情。 + */ + @Override + @Transactional + public ReservationV4OrderTaskDetailResult confirmTaskCard( + Long orderTaskId, + Long cardId, + ReservationV4CardConfirmRequest 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()); + validateCardConfirmable(orderTask, card); + ensureNoPriorOrderTaskBlocking(orderTask); + ensureBasicInformationConfirmed(orderTask, card); + + String confirmedPayloadJson = confirmedPayloadJson(card, request == null ? null : request.confirmedPayload()); + String actorId = actorIdentifier(actor); + boolean updated = workflowRepository.confirmTaskCardWithVersion( + orderTask.hotelId(), + card.id(), + expectedVersion, + confirmedPayloadJson, + actorId, + now); + if (!updated) { + handleCardConfirmRace(orderTask.hotelId(), card.id()); + } + refreshOrderTaskStatus(orderTask, now); + writeCardConfirmAudit(orderTask, card, actorId, confirmedPayloadJson, now); + return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id()); + } + + /** + * 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。 + */ + @Override + @Transactional + public ReservationV4SourceNotificationDetailResult ackSourceNotification( + Long notificationId, + ReservationV4SourceNotificationAckRequest request, + AuthenticatedUserContext actor) { + Long expectedVersion = requireVersion(request == null ? null : request.version()); + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId); + requireHotelAccess(notification.hotelId()); + if (ReservationV4NotificationStatus.ACKED.name().equals(notification.notificationStatus())) { + return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id()); + } + if (!ReservationV4NotificationStatus.ACK_REQUIRED.name().equals(notification.notificationStatus())) { + throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_NOT_ACKABLE", "当前来源通知状态不允许确认。"); + } + String actorId = actorIdentifier(actor); + boolean updated = sourceNotificationRepository.updateNotificationStatusWithVersion( + notification.hotelId(), + notification.id(), + expectedVersion, + ReservationV4NotificationStatus.ACKED.name(), + actorId, + now); + if (!updated) { + ReservationV4SourceNotificationSnapshot latest = requireSourceNotification(notificationId); + if (ReservationV4NotificationStatus.ACKED.name().equals(latest.notificationStatus())) { + return queryService.getSourceNotificationDetail(latest.hotelId(), latest.id()); + } + throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_VERSION_CONFLICT", "来源通知版本已变化,请刷新后重试。"); + } + writeSourceNotificationAckAudit(notification, actorId, request == null ? null : request.reason(), now); + return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id()); + } + + private void validateCardConfirmable( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card) { + if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) { + throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "来源邮件展示卡不允许确认。"); + } + 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_REVIEW_REQUIRED", "该卡片需要先完成人工复核。"); + } + if (!ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) { + throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "当前卡片状态不允许确认。"); + } + if (ReservationV4OrderTaskStatus.COMPLETED.name().equals(orderTask.orderTaskStatus())) { + throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_ALREADY_COMPLETED", "该 V4 订单任务已经完成。"); + } + } + + private void ensureBasicInformationConfirmed( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card) { + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + return; + } + ReservationV4TaskCardSnapshot basicCard = workflowRepository.findTaskCardsByOrderTaskId( + orderTask.hotelId(), + orderTask.id()).stream() + .filter(candidate -> ReservationV4CardType.BASIC_INFORMATION.name().equals(candidate.cardType())) + .findFirst() + .orElseThrow(() -> error( + HttpStatus.CONFLICT, + "V4_BASIC_INFORMATION_MISSING", + "V4 订单任务缺少 Basic Information 卡。")); + if (!ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus())) { + throw error( + HttpStatus.CONFLICT, + "V4_BASIC_INFORMATION_NOT_CONFIRMED", + "Basic Information 卡未确认前,业务卡不可确认。"); + } + } + + private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask) { + if (orderTask.orderId() == null) { + return; + } + List sameOrderTasks = workflowRepository.findOrderTasksByOrderIds( + orderTask.hotelId(), + List.of(orderTask.orderId())); + for (ReservationV4OrderTaskSnapshot candidate : sameOrderTasks) { + if (Objects.equals(candidate.id(), orderTask.id())) { + return; + } + if (!ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) { + throw error( + HttpStatus.CONFLICT, + "V4_PRIOR_ORDER_TASK_NOT_COMPLETED", + "同一本地订单下存在更早未完成的 V4 订单任务。"); + } + } + } + + private void refreshOrderTaskStatus(ReservationV4OrderTaskSnapshot orderTask, LocalDateTime now) { + List cards = workflowRepository.findTaskCardsByOrderTaskId( + orderTask.hotelId(), + orderTask.id()); + boolean hasUnfinishedCard = cards.stream() + .filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) + .anyMatch(card -> !ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())); + String nextStatus = hasUnfinishedCard + ? ReservationV4OrderTaskStatus.OPEN.name() + : ReservationV4OrderTaskStatus.COMPLETED.name(); + if (!Objects.equals(orderTask.orderTaskStatus(), nextStatus)) { + boolean updated = workflowRepository.updateOrderTaskStatus(orderTask.hotelId(), orderTask.id(), nextStatus, now); + if (!updated) { + throw error( + HttpStatus.CONFLICT, + "V4_ORDER_TASK_STATUS_REFRESH_FAILED", + "V4 订单任务状态刷新失败,请刷新后重试。"); + } + } + } + + private void handleCardConfirmRace(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); + } + if (hasText(card.displayPayloadJson())) { + return card.displayPayloadJson(); + } + return "{}"; + } + + private ReservationV4OrderTaskSnapshot requireOrderTask(Long orderTaskId) { + return workflowRepository.findOrderTaskById(orderTaskId) + .orElseThrow(() -> notFound("V4_ORDER_TASK_NOT_FOUND", "V4 订单任务不存在。")); + } + + private ReservationV4TaskCardSnapshot requireTaskCard(String hotelId, Long cardId, Long orderTaskId) { + ReservationV4TaskCardSnapshot card = workflowRepository.findTaskCardById(hotelId, cardId) + .orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。")); + if (!Objects.equals(orderTaskId, card.v4OrderTaskId())) { + throw notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。"); + } + return card; + } + + private ReservationV4SourceNotificationSnapshot requireSourceNotification(Long notificationId) { + return sourceNotificationRepository.findSourceNotificationById(notificationId) + .orElseThrow(() -> notFound("V4_SOURCE_NOTIFICATION_NOT_FOUND", "V4 来源通知不存在。")); + } + + private void requireHotelAccess(String hotelId) { + try { + hotelContextService.requireAccessibleHotel(hotelId); + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + + private Long requireVersion(Long version) { + if (version == null) { + throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_REQUIRED", "请求必须携带当前 version。"); + } + if (version < 0) { + throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_INVALID", "version 不能小于 0。"); + } + return version; + } + + private String actorIdentifier(AuthenticatedUserContext actor) { + if (actor == null) { + return "unknown-user"; + } + if (hasText(actor.username())) { + return actor.username(); + } + return actor.userId() == null ? "unknown-user" : actor.userId().toString(); + } + + private void writeCardConfirmAudit( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot card, + String actorId, + String confirmedPayloadJson, + 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("version", card.version()); + beforeSnapshot.put("has_confirmed_payload", hasText(card.confirmedPayloadJson())); + 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("confirmed_by", actorId); + afterSnapshot.put("confirmed_payload_keys", payloadFieldNames(confirmedPayloadJson)); + auditRepository.insertAuditLog(new ReservationAuditLogDraft( + orderTask.hotelId(), + orderTask.orderId(), + null, + null, + ACTOR_TYPE_USER, + actorId, + ACTION_V4_CARD_CONFIRM, + null, + toJson(beforeSnapshot), + toJson(afterSnapshot), + now)); + } + + private void writeSourceNotificationAckAudit( + ReservationV4SourceNotificationSnapshot notification, + String actorId, + String reason, + LocalDateTime now) { + Map beforeSnapshot = new LinkedHashMap<>(); + beforeSnapshot.put("v4_source_notification_id", notification.id().toString()); + beforeSnapshot.put("route_code", notification.routeCode()); + beforeSnapshot.put("notification_status", notification.notificationStatus()); + beforeSnapshot.put("version", notification.version()); + Map afterSnapshot = new LinkedHashMap<>(); + afterSnapshot.put("v4_source_notification_id", notification.id().toString()); + afterSnapshot.put("route_code", notification.routeCode()); + afterSnapshot.put("notification_status", ReservationV4NotificationStatus.ACKED.name()); + afterSnapshot.put("ack_by", actorId); + auditRepository.insertAuditLog(new ReservationAuditLogDraft( + notification.hotelId(), + null, + null, + null, + ACTOR_TYPE_USER, + actorId, + ACTION_V4_SOURCE_NOTIFICATION_ACK, + trimToNull(reason), + toJson(beforeSnapshot), + toJson(afterSnapshot), + now)); + } + + private List payloadFieldNames(String json) { + if (!hasText(json)) { + return List.of(); + } + try { + JsonNode node = objectMapper.readTree(json); + if (node == null || !node.isObject()) { + return List.of(); + } + List fieldNames = new ArrayList<>(); + Iterator iterator = node.fieldNames(); + while (iterator.hasNext()) { + fieldNames.add(iterator.next()); + } + return fieldNames; + } catch (Exception exception) { + return List.of(); + } + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception exception) { + return "{}"; + } + } + + private ReservationTaskWorkflowException notFound(String errorCode, String message) { + return new ReservationTaskWorkflowException(HttpStatus.NOT_FOUND, errorCode, message); + } + + private ReservationTaskWorkflowException error(HttpStatus status, String errorCode, String message) { + return new ReservationTaskWorkflowException(status, errorCode, message); + } + + private boolean hasText(String value) { + return trimToNull(value) != null; + } + + private String trimToNull(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } +} 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 f86bc90..50e056d 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 @@ -423,14 +423,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService } return new ReservationV4ActionAvailabilityResult( false, + false, + true, true, false, - false, - false, - ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(), + ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), null, null, - "V4 卡片确认和复核写接口将在后续 checkpoint 开放。"); + null); } private ReservationV4ActionAvailabilityResult cardAvailability( @@ -467,18 +467,29 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) { return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); } - if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus()) - || ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) { + if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) { + return new ReservationV4ActionAvailabilityResult( + false, + false, + true, + true, + false, + ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), + null, + null, + null); + } + if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) { return new ReservationV4ActionAvailabilityResult( false, true, false, false, false, - ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(), + ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(), null, null, - "V4 卡片确认和复核写接口将在后续 checkpoint 开放。"); + "V4 人工复核写接口将在后续 checkpoint 开放。"); } return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); } @@ -489,15 +500,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); } return new ReservationV4ActionAvailabilityResult( + false, + false, + false, false, true, - false, - false, - false, - ReservationV4ReadonlyReasonCode.ACK_API_PENDING.name(), + ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), null, null, - "V4 来源通知确认写接口将在后续 checkpoint 开放。"); + null); } private ReservationV4ActionAvailabilityResult readOnlyAvailability( 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 new file mode 100644 index 0000000..5f35ab7 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java @@ -0,0 +1,508 @@ +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.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; +import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:reservation_v4_command_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=v4-command-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=V4命令管理员", + "auth.bootstrap.default-hotel-id=HOTEL-TEST", + "auth.bootstrap.default-hotel-name=测试酒店", + "auth.bootstrap.default-hotel-time-zone=Asia/Bangkok", + "auth.session.ttl-minutes=720" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ReservationV4CommandControllerTest { + + private static final String HOTEL_ID = "HOTEL-TEST"; + private static final String OTHER_HOTEL_ID = "HOTEL-OTHER"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SourceMessageCaptureService captureService; + + @Autowired + private ReservationV4WorkflowRepository workflowRepository; + + @Autowired + private ReservationV4SourceNotificationRepository sourceNotificationRepository; + + @Autowired + private PlatformIdentityRepository identityRepository; + + @Autowired + private PlatformHotelRepository hotelRepository; + + @Autowired + private AuthPasswordService passwordService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private String adminToken; + private String noPermissionToken; + + @BeforeEach + void ensureNoPermissionUser() { + PlatformUserEntity user = identityRepository.findUserByUsername("v4-command-no-permission") + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername("v4-command-no-permission"); + created.setPasswordHash(passwordService.hash("NoPerm@123456")); + created.setDisplayName("V4 命令无权限用户"); + created.setUserStatus(PlatformUserStatus.ACTIVE.name()); + created.setSuperAdmin(false); + created.setPasswordChangedAt(now); + created.setCreatedAt(now); + created.setUpdatedAt(now); + identityRepository.insertUser(created); + return created; + }); + hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true); + } + + @Test + void shouldRejectBusinessCardConfirmBeforeBasicInformation() throws Exception { + SeededOrderTask seeded = seedOrderTask( + HOTEL_ID, + "mail-v4-command-basic-block-001", + Instant.parse("2026-07-19T01:00:00Z")); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "room_items": [{"room_type_code": "TWN", "room_count": 2}] + } + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("V4_BASIC_INFORMATION_NOT_CONFIRMED")); + } + + @Test + void shouldConfirmBasicInformationAndThenBusinessCard() throws Exception { + SeededOrderTask seeded = seedOrderTask( + HOTEL_ID, + "mail-v4-command-confirm-001", + Instant.parse("2026-07-19T01:10: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": { + "company": "Q.B.D. TRAVEL GROUP CO., LTD", + "group_code": "GRP-V4-COMMAND-001" + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED")) + .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("$.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")))); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "room_items": [{"room_type_code": "TWN", "room_count": 2}] + } + } + """)) + .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].confirmed_payload.room_items[0].room_type_code") + .value("TWN")) + .andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(false)) + .andExpect(jsonPath("$.business_cards[0].availability.readonly_reason_code").value("CARD_LOCKED")); + assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2); + } + + @Test + void shouldRejectRepeatedCardConfirm() throws Exception { + SeededOrderTask seeded = seedOrderTask( + HOTEL_ID, + "mail-v4-command-repeat-001", + Instant.parse("2026-07-19T01:20:00Z")); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.basicCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 1, + "confirmed_payload": {"group_code": "SHOULD-NOT-REWRITE"} + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED")); + } + + @Test + void shouldRejectCardConfirmWhenPriorOrderTaskIsOpen() throws Exception { + Long sharedOrderId = 990000000000040001L; + seedOrderTask( + HOTEL_ID, + "mail-v4-command-prior-open-001", + Instant.parse("2026-07-19T01:25:00Z"), + sharedOrderId); + SeededOrderTask later = seedOrderTask( + HOTEL_ID, + "mail-v4-command-prior-open-002", + Instant.parse("2026-07-19T01:26:00Z"), + sharedOrderId); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + later.orderTask().id(), + later.basicCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"} + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("V4_PRIOR_ORDER_TASK_NOT_COMPLETED")); + } + + @Test + void shouldAckS10SourceNotification() throws Exception { + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + HOTEL_ID, + "mail-v4-command-s10-ack-001", + "S10", + Instant.parse("2026-07-19T01:30:00Z")); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack", + notification.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notification.notification_status").value("ACKED")) + .andExpect(jsonPath("$.notification.ack_by").value("v4-command-admin")) + .andExpect(jsonPath("$.source_message_card.card_status").value("ACKED")) + .andExpect(jsonPath("$.availability.ackable").value(false)) + .andExpect(jsonPath("$.availability.readonly_reason_code").value("CARD_LOCKED")); + assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1); + } + + @Test + void shouldRejectV4CommandsWhenPermissionMissing() throws Exception { + SeededOrderTask seeded = seedOrderTask( + HOTEL_ID, + "mail-v4-command-no-permission-001", + Instant.parse("2026-07-19T01:40:00Z")); + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + HOTEL_ID, + "mail-v4-command-no-permission-s10-001", + "S10", + Instant.parse("2026-07-19T01:41:00Z")); + + performAuthorized(mockMvc, noPermissionToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.basicCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"version": 0} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + performAuthorized(mockMvc, noPermissionToken(), post("/api/reservation/source-notifications/{notificationId}/ack", + notification.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"version": 0} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + } + + @Test + void shouldRejectV4CommandsWhenHotelAccessDenied() throws Exception { + SeededOrderTask seeded = seedOrderTask( + OTHER_HOTEL_ID, + "mail-v4-command-cross-hotel-001", + Instant.parse("2026-07-19T01:50:00Z")); + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + OTHER_HOTEL_ID, + "mail-v4-command-cross-hotel-s10-001", + "S99", + Instant.parse("2026-07-19T01:51: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} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack", + notification.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"version": 0} + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + } + + /** + * 获取 V4 命令管理员 token,测试通过真实登录链路覆盖前端鉴权。 + */ + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "v4-command-admin", "Admin@123456"); + } + return adminToken; + } + + /** + * 获取没有 RESERVATION_TASK_CONFIRM 权限的普通用户 token。 + */ + private String noPermissionToken() throws Exception { + if (noPermissionToken == null) { + noPermissionToken = loginToken(mockMvc, "v4-command-no-permission", "NoPerm@123456"); + } + return noPermissionToken; + } + + private void confirmBasicCard(SeededOrderTask seeded) throws Exception { + 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": {"group_code": "GRP-V4-COMMAND-001"} + } + """)) + .andExpect(status().isOk()); + } + + private SeededOrderTask seedOrderTask(String hotelId, String externalMessageId, Instant receivedAt) { + return seedOrderTask(hotelId, externalMessageId, receivedAt, null); + } + + private SeededOrderTask seedOrderTask( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId) { + SourceMessageCaptureResult source = captureSourceMessage( + hotelId, + externalMessageId, + "V4 Command Business", + receivedAt); + LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( + hotelId, + source.inboxId(), + 990000000000010001L + Math.abs(externalMessageId.hashCode()), + "order-command-" + externalMessageId, + 1, + orderId, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-001", + ReservationV4TargetResolutionStatus.RESOLVED.name(), + 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 Business"}} + """); + 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"} + """); + ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId, + ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, + ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ + {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]} + """); + return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard); + } + + private ReservationV4SourceNotificationSnapshot seedSourceNotification( + String hotelId, + String externalMessageId, + String routeCode, + Instant receivedAt) { + SourceMessageCaptureResult source = captureSourceMessage( + hotelId, + externalMessageId, + "V4 Command Notification", + receivedAt); + return sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft( + hotelId, + source.inboxId(), + 990000000000020001L + Math.abs(externalMessageId.hashCode()), + null, + routeCode, + ReservationV4NotificationStatus.ACK_REQUIRED.name(), + """ + {"route_code":"%s","source_message":{"body":"Sensitive raw notification body"}} + """.formatted(routeCode), + LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), + LocalDateTime.ofInstant(receivedAt.plusSeconds(20), ZoneOffset.UTC))); + } + + private ReservationV4TaskCardSnapshot insertCard( + ReservationV4OrderTaskSnapshot orderTask, + String hotelId, + String cardType, + String eventType, + Integer sourceEventIndex, + Integer sortOrder, + String cardStatus, + String reviewStatus, + String displayPayloadJson) { + return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( + hotelId, + orderTask.id(), + orderTask.sourceMessageId(), + eventType == null ? null : 990000000000030001L + sourceEventIndex, + cardType, + eventType, + sourceEventIndex, + sortOrder, + cardStatus, + reviewStatus, + """ + {"private_url":"https://private.example.test/raw","raw":"must stay internal"} + """, + displayPayloadJson, + null, + orderTask.createdAt())); + } + + private SourceMessageCaptureResult captureSourceMessage( + String hotelId, + String externalMessageId, + String subject, + Instant receivedAt) { + return captureService.capture(new CaptureSourceMessageCommand( + hotelId, + "AGENTBUS", + "EMAIL", + externalMessageId, + "thread-" + externalMessageId, + "frame-" + externalMessageId, + "session-v4-command", + receivedAt, + null, + "guest@example.test", + subject, + "Please handle V4 command message.", + "Please handle V4 command message.", + "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", + "agentbus-outlook-v1", + List.of() + )); + } + + private void assertAuditCount(String action, String actorId, String snapshotFragment, int expectedCount) { + Integer count = jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM workflow_reservation_audit_log + WHERE hotel_id = ? + AND action = ? + AND actor_id = ? + AND after_snapshot_json LIKE ? + """, + Integer.class, + HOTEL_ID, + action, + actorId, + "%" + snapshotFragment + "%"); + org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount); + } + + private record SeededOrderTask( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot sourceCard, + ReservationV4TaskCardSnapshot basicCard, + ReservationV4TaskCardSnapshot businessCard + ) { + } +} 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 15efa2a..400346e 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 @@ -243,8 +243,9 @@ class ReservationV4QueryControllerTest { .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].ai_payload_json").doesNotExist()) - .andExpect(jsonPath("$.availability.read_only").value(true)) - .andExpect(jsonPath("$.availability.readonly_reason_code").value("COMMAND_API_PENDING")) + .andExpect(jsonPath("$.availability.read_only").value(false)) + .andExpect(jsonPath("$.availability.confirmable").value(true)) + .andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE")) .andExpect(jsonPath("$.source_message_summary.subject").value("V4 Query Business")) .andExpect(content().string(not(containsString("https://private.example.test")))); } @@ -266,8 +267,9 @@ class ReservationV4QueryControllerTest { .andExpect(jsonPath("$.items[0].order_task.display_order_key").value("GRP-V4-QUERY-001")) .andExpect(jsonPath("$.items[0].card_counts.pending_confirm_count").value(2)) .andExpect(jsonPath("$.items[0].display_status").value("OPEN")) - .andExpect(jsonPath("$.items[0].availability.read_only").value(true)) - .andExpect(jsonPath("$.items[0].availability.readonly_reason_code").value("COMMAND_API_PENDING")) + .andExpect(jsonPath("$.items[0].availability.read_only").value(false)) + .andExpect(jsonPath("$.items[0].availability.confirmable").value(true)) + .andExpect(jsonPath("$.items[0].availability.readonly_reason_code").value("PROCESSABLE")) .andExpect(jsonPath("$.page.total").value(1)); } @@ -326,8 +328,9 @@ class ReservationV4QueryControllerTest { .andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_NOTIFICATION")) .andExpect(jsonPath("$.conversation_summary.source_message_id") .value(notification.sourceMessageId().toString())) - .andExpect(jsonPath("$.availability.ackable").value(false)) - .andExpect(jsonPath("$.availability.read_only").value(true)) + .andExpect(jsonPath("$.availability.ackable").value(true)) + .andExpect(jsonPath("$.availability.read_only").value(false)) + .andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE")) .andExpect(jsonPath("$.raw_payload_json").doesNotExist()) .andExpect(content().string(not(containsString("Sensitive raw notification body")))); }