diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7915112..09a36e9 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2,17 +2,17 @@ | 项 | 内容 | | --- | --- | -| 最近更新 | 2026-07-18 | +| 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线和入站写入新模型并行 | -| 当前重点 | M002 V4 CP4 已把 SuperAgent V4 入站结果写入 V4 订单任务、多卡和 S10/S99 来源通知模型,后续准备做 V4 查询接口 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入和前端查询接口并行 | +| 当前重点 | M002 V4 CP5 已开放 V4 工作台统一列表、订单任务列表 / 详情、S10/S99 来源通知详情查询接口;后续准备做 V4 卡片确认、复核和来源通知 ack 写接口 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP4-v4-intake-write-new-model` -- 状态:Done,已在 SuperAgent V4 正式回调中写入 V4 新模型:普通 V4 业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 写入来源通知,不再创建隐藏技术订单或旧任务;V4 包级 / event 级契约错误仍只写 AI transition,不创建用户可处理卡。 -- 目标:在 CP1 入站解析、CP2 多卡模型设计、CP3 表结构和 Repository 基线之后,让 V4 回调真正落到新订单任务 + 多卡 + 来源通知持久化模型。 -- 边界:普通 V4 业务包暂时仍保留旧 V3 任务链路兼容,便于当前前端过渡;不实现 V4 前端查询接口、卡片确认 / 复核 / S10/S99 ack 接口;不做 OPERA / OHIP、不做前端页面、不做历史数据迁移。 +- 名称:`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、前端页面和历史数据迁移。 ## 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 已完成入站写入新模型。V4 查询 / 写接口和 V4 前端页面仍未完成代码实现。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口。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-CP5`:实现 V4 工作台统一列表、订单任务列表 / 详情、S10/S99 来源通知详情等查询接口。 +- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP6`:实现 V4 卡片确认、复核解阻、S10/S99 来源通知 ack、审计和 version 并发校验。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/docs/project/README.md b/docs/project/README.md index 6f5bc26..d95496a 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -45,8 +45,8 @@ | `requirements/M002-order-task-workflow-v2.md` | 阶段记录 | M002 订单任务主流程 V2,记录当前已阶段实现的 AI 过渡层、S000/S999 兼容、订单任务流转、任务确认和 OPERA 模拟骨架。 | | `requirements/M002-order-task-workflow-v3.md` | 当前有效 | M002 订单任务主流程 V3,基于 2026-07-11 P0 冻结基线和 2026-07-12 P0.1 Parent Group 修订,记录 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed 边界。 | | `requirements/M002-task-field-control-contract-v1.md` | 当前有效 | M002 任务卡字段控件契约 V1,记录任务详情 `fields[]` 控件元数据、人工复核控件复用和前后端开发边界。 | -| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10/S99 和校验口径;后端已完成 V4 入站解析 CP1、CP3 持久化基线和 CP4 入站写入新模型,V4 查询 / 写接口仍待后续实现。 | -| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3 表结构、Entity、Mapper、Repository 和 CP4 入站写入新模型已落地状态;后续仍需实现 V4 查询接口、卡片确认 / 复核和前端页面。 | +| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10/S99 和校验口径;后端已完成 V4 入站解析 CP1、CP3 持久化基线、CP4 入站写入新模型和 CP5 查询接口,V4 写接口仍待后续实现。 | +| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3 表结构、Entity、Mapper、Repository、CP4 入站写入新模型和 CP5 查询接口已落地状态;后续仍需实现卡片确认 / 复核、S10/S99 ack 和前端页面。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-ai-query-minimal-fields.md` | 阶段记录 | M002 SuperAgent 查询上下文接口 1、2 最小字段落地记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-backend-data-model-design.md` | 阶段记录 | M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 | @@ -91,6 +91,6 @@ - 接口暴露、权限、酒店隔离和审计边界以 `security-access-control-boundary.md` 为总检查清单;具体 SuperAgent / MCP / AgentBus 请求响应契约仍以 `integrations/` 下对应文档为准。 - AI-NSES 的通用标准以 `../import/reusable/ai-native-software-engineering-standard.md` 为复用来源;本项目采用方式以 `ai-native-adoption.md` 为准。 - M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 -- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,后续仍需实现查询接口、卡片确认 / 复核和前端页面模型。 +- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,CP5 已落地工作台、订单任务和来源通知查询接口,后续仍需实现卡片确认 / 复核、S10/S99 ack 和前端页面模型。 - 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表、0712 字段控件说明和 `requirements/M002-task-field-control-contract-v1.md` 为白名单和控件契约基线;后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 - 时间点语义以 `backend-time-design.md` 为准;数据库时间点按 UTC 理解,API 返回带 `Z` 的 UTC 时间,页面再按酒店或用户时区展示。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index a5ba1dc..3f6203b 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -37,7 +37,7 @@ - 同一订单下,前置任务未结束时,后续任务只能查看,不能编辑、确认或执行 OPERA 模拟操作。 - 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。 - M002 V3 新入口采用结构化 `S10/S99`:`S10` 表示未匹配当前支持的业务事件,`S99` 表示输入不足或无法形成业务素材包;旧 `S000/S999` 继续按历史数据兼容展示。 -- V3 S10/S99 会创建旧 `SOURCE_MESSAGE_ONLY` 只读源邮件通知卡,任务列表可见,订单列表不可见;V4 S10/S99 已改为独立来源通知模型,等待后续 V4 工作台 / 来源通知接口展示。 +- V3 S10/S99 会创建旧 `SOURCE_MESSAGE_ONLY` 只读源邮件通知卡,任务列表可见,订单列表不可见;V4 S10/S99 已改为独立来源通知模型,并通过 V4 工作台 / 来源通知详情接口展示。 - 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 - type-known manual review 已支持同卡复核解阻第一版:应展示为原业务任务卡的复核模式,不应统一展示成 Fallback。只有业务类型或 subtype 本身未知时才进入 Fallback。 - 复核场景下允许用户确认订单归属;当前第一版只允许确认当前任务所属订单,不等于开放普通任务任意切换订单。 @@ -53,7 +53,11 @@ | `GET /api/auth/me` | 恢复当前登录态 | 前端启动后带 `Authorization: Bearer ` 调用;401 时清理 token 并进入登录页。 | | `POST /api/auth/logout` | 登出当前 session | 带 `Authorization: Bearer `;成功后前端必须清理本地 token 和当前用户上下文。 | | `GET /api/reservation/orders` | 查询订单列表 | 必须带 `Authorization: Bearer `,需要 `RESERVATION_ORDER_READ`;默认返回全部订单状态;按后端维护的订单最近业务活动时间倒序,当前落库字段为 `workflow_reservation_order.latest_activity_at`,前端不要自行重排;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 | -| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和 V3 S10/S99 以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。V4 S10/S99 不再进入该旧任务表,后续从 V4 工作台来源通知接口展示。 | +| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和 V3 S10/S99 以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。V4 S10/S99 不再进入该旧任务表,应从 V4 工作台来源通知接口展示。 | +| `GET /api/reservation/workbench-items` | 查询 V4 工作台统一列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;返回 V4 业务订单任务和 S10/S99 来源通知混排摘要;支持 `hotel_id`、`item_type`、`keyword`、`page_num`、`page_size`;默认按 `source_received_at` 倒序;列表不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 | +| `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id`、`order_id`、`order_task_status`、`card_status`、`keyword`、`page_num`、`page_size`;前端用 `availability`、`card_counts` 和 `next_action_card_id` 控制入口。 | +| `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。 | | `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` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | @@ -88,6 +92,9 @@ | `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。前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 | + +V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100;`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。 酒店上下文注意:Reservation 列表、订单详情、任务列表和 Debug EML 上传的 `hotel_id` 第一版都是可选参数。对已收口的 Reservation / SourceMessage 只读接口,前端必须先登录并带 Bearer token;不传 `hotel_id` 时后端按当前登录用户默认酒店或对象所属酒店校验,传了当前选中酒店时后端会校验该酒店是否可访问。Debug EML 仍按调试入口规则受控,不属于本轮登录权限收口范围。 @@ -454,15 +461,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 CP4 已完成入站写入新模型:普通 V4 业务包会写入 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;但 V4 工作台、订单任务详情、来源通知详情和卡片确认接口还没开放,前端当前不要调用 CP2 草案路径。 +- 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 写接口仍未开放。 - 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[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。 - V4 包级契约错误只会保存在 AI transition 中,不会出现在普通任务列表;V4 event 级契约错误如果同批次存在其它业务任务,前端仍按任务详情里的 `adapter_contract_errors[]` 只读展示诊断信息。 -- M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表草案为 `GET /api/reservation/workbench-items`,业务订单任务草案为 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `GET /api/reservation/source-notifications/{notificationId}`,但当前还没有实现,不要提前接入草案路径。 -- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表。当前仍不代表 V4 工作台、订单任务详情、卡片确认或 S10/S99 ack API 已经可用,前端仍不要调用 CP2 草案路径。 +- M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表、业务订单任务列表 / 详情和 S10/S99 来源通知详情已实现。 +- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询。当前仍不代表 V4 卡片确认、复核或 S10/S99 ack 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 dff580a..c455931 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -443,6 +443,7 @@ V3 P0.1 不做以下事项: - V4 第一版识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,可映射 event 先复用现有订单 / 任务 / 任务卡链路,并保存 `catalog_code=M002V4`、`skill_id=booking-desk-event-v4`、`field_contract_version=20260718-v4` 和 V4 原始三元组 / 原始 event payload。 - M002 V4 CP4 已补充新模型写入:普通 V4 业务包会额外创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;只有契约错误、没有合法业务 event 的包不会创建 V4 订单任务。 - M002 V4 CP4 后,V4 `route_code=S10/S99` 写入 `workflow_reservation_v4_source_notification`,不再创建隐藏技术订单或旧任务;V3 S10/S99 和旧 S000/S999 仍保留历史兼容链路。 +- M002 V4 CP5 已开放 V4 查询接口:`GET /api/reservation/workbench-items`、`GET /api/reservation/order-tasks`、`GET /api/reservation/order-tasks/{orderTaskId}`、`GET /api/reservation/source-notifications/{notificationId}`。 - V4 包级契约错误在 `source_message.source_message_id` 可定位时只写 `adapter_contract_error` transition,不创建订单、任务或用户可处理卡;`source_message_id` 缺失或 SourceMessage 不存在时仍返回明确错误。 - V4 `PAYMENT.attachment_ids[]` 必须匹配 `source_message.attachments[].id`;V4 `UPDATE_BOOKING` 不接受 `rate_code` 或 `after.rate_code`;这类契约错误只落 `adapter_contract_error` transition,不创建用户可处理业务任务。 - 40 条 P0.1 路由枚举 / 稳定配置。 @@ -476,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 入站写入新模型已完成;V4 查询接口、卡片确认 / 复核和前端页面仍未实现。 +- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型和 CP5 查询接口已完成;V4 卡片确认 / 复核、S10/S99 ack 和前端页面仍未实现。 仍需后续 checkpoint 实现: - V4 表结构和 Repository 落地已完成第一版:新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,并提供 Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 version 乐观锁更新基础方法。 - V4 入站写入新模型已完成第一版:真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知。 -- V4 查询接口、卡片确认 / 复核 / 锁定、同订单阻塞和业务审计。 +- V4 卡片确认 / 复核 / 锁定、S10/S99 ack、同订单阻塞写侧校验和业务审计。 - 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 cf3aca5..75aef07 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 1.4 | -| 日期 | 2026-07-18 | -| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线和 CP4 入站写入新模型,V4 查询 / 写接口仍需后续 checkpoint | +| 文档版本 | 1.5 | +| 日期 | 2026-07-19 | +| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线、CP4 入站写入新模型和 CP5 查询接口,V4 写接口仍需后续 checkpoint | | 适用范围 | 0718 业务基线下,Agent → Adapter / MCP → 信息系统的业务回调字段 | | 不适用范围 | 数据库表设计、前端视觉细节、真实 PMS API、技术失败后台重试、旧 M002 V3 数据兼容 | @@ -16,7 +16,7 @@ 本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前后端已按本文完成 V4 入站解析基线:能识别 V4 包、校验关键契约、保存 AI transition / 任务卡原始 payload,并把可映射的六类 event 先接入现有订单任务链路。 -V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP4,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 前端查询接口和卡片写操作接口仍未实现。 +V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP5,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 工作台、订单任务列表 / 详情和来源通知详情查询接口已实现,卡片确认、复核和来源通知 ack 写接口仍未实现。 当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。 @@ -723,11 +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 已完成: + +- `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。 + 当前仍未完成: -- 普通 V4 业务包暂时仍保留旧 V3 任务状态、草稿和 OPERA 模拟骨架兼容,便于前端过渡;V4 新查询 / 写接口完成后再逐步废弃旧链路。 +- 普通 V4 业务包暂时仍保留旧 V3 任务状态、草稿和 OPERA 模拟骨架兼容,便于前端过渡;V4 写接口和前端页面完成后再逐步废弃旧链路。 +- V4 卡片确认、复核解阻和 S10/S99 来源通知 ack 写接口。 - 尚未接入真实 PMS / OPERA / OHIP。 -- 尚未改造前端 V4 页面模型;前端第一版只能通过现有任务详情字段和原始 payload 观察 V4 入站结果。 +- 尚未改造前端 V4 页面模型。 ## 26. 后端 CP2 设计文档状态 @@ -739,10 +748,10 @@ AI 回调包 - 每个 `message_events[]` event 创建一张业务卡,卡片按固定业务顺序展示。 - S10/S99 后续按来源通知模型实现,不再挂隐藏技术订单;只在任务列表 / 工作台展示,并进入纯通知详情页确认已读 / 已处理。 - `FIT + BOOKING_CODE` 不建立 ACTIVE 唯一约束;业务绑定查到多条时进入人工复核。 -- V4 前端工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`。 +- V4 前端工作台统一列表为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情为 `/api/reservation/source-notifications/{notificationId}`。 - V4 新数据不再保存后端草稿;用户只提交最终确认或复核解阻。 - 卡片确认后锁定,错误修正后续通过审计和未来纠错流程表达,不覆盖原确认。 - 技术异常只进入 AI transition / 技术运行记录,不进入用户可处理卡。 - CP3 已新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,继续复用 SourceMessage、AI batch、AI transition、Reservation Order 和业务审计表。 -CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。CP4 已把 V4 回调写入新模型。后续开发应继续补 V4 查询接口、卡片确认 / 复核、S10/S99 ack 和前端页面。 +CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。CP4 已把 V4 回调写入新模型。CP5 已开放查询接口。后续开发应继续补卡片确认 / 复核、S10/S99 ack 和前端页面。 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index c107c00..801c505 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.2 | -| 日期 | 2026-07-18 | -| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现 | +| 文档版本 | 0.3 | +| 日期 | 2026-07-19 | +| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | -| 不适用范围 | V4 前端查询接口、卡片确认 / 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | +| 不适用范围 | 卡片确认 / 复核接口、S10/S99 ack 接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | ## 1. 文档定位 @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP4,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前仍未开放 V4 前端查询或写操作接口。 +截至 CP5,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情和来源通知详情查询接口;V4 卡片确认、复核和来源通知 ack 写接口仍未开放。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -24,7 +24,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 | 主题 | CP1 当前实现 | V4 目标模型差距 | | --- | --- | --- | -| 入站识别 | 已识别 `route_code`、`source_message`、`order_contexts[]`、`message_events[]` | CP4 已把有合法 event 的 `order_ref` 建成订单任务聚合;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 | | 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task`,并已额外创建 V4 业务卡 | 旧任务链路仍作前端过渡兼容,后续 V4 查询和写接口完成后再逐步废弃 | @@ -417,7 +417,7 @@ CP3 Repository 已封装: - 来源通知状态的 version 乐观锁更新基础方法。 - event 卡必须传入正数一基 `source_event_index`;漏传会拒绝写入,避免被误当成非 event 卡。 -CP3 尚未实现卡片确认、复核解阻、订单归属确认、来源通知 ack 的业务 Service 和 API;这些仍属于后续 checkpoint。 +CP5 尚未实现卡片确认、复核解阻、订单归属确认、来源通知 ack 的业务 Service 和 API;这些仍属于后续 checkpoint。 Service 不直接访问 Mapper。 @@ -432,11 +432,11 @@ Service 不直接访问 Mapper。 当前 `ReservationAiTaskIntakeServiceImpl` 已在 V4 分支调用 `ReservationV4TaskIntakeService` 完成新模型写入;后续查询、确认和复核仍应继续拆到独立 V4 service,避免主入站类继续膨胀。 -## 12. 前端查询接口草案 +## 12. 前端查询接口 -以下只是接口草案,CP2 不实现。 +以下查询接口已在 CP5 实现。所有接口都属于 `FRONTEND_USER`,必须带 Bearer token,权限码为 `RESERVATION_TASK_READ`,并按酒店访问权校验。查询接口只返回安全摘要、展示 payload、确认 payload、复核结果和状态,不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 -V4 前端接口不继续扩展旧 `/api/reservation/tasks/**` 作为 V4 主模型入口。第一版草案中,工作台统一列表使用 `/api/reservation/workbench-items`,业务订单任务使用 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情和确认使用 `/api/reservation/source-notifications/**`。 +V4 前端接口不继续扩展旧 `/api/reservation/tasks/**` 作为 V4 主模型入口。第一版中,工作台统一列表使用 `/api/reservation/workbench-items`,业务订单任务使用 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情使用 `/api/reservation/source-notifications/**`。 ### 12.1 工作台统一列表 @@ -478,7 +478,7 @@ GET /api/reservation/order-tasks 权限:RESERVATION_TASK_READ ``` -查询参数草案: +查询参数: | 参数 | 中文说明 | | --- | --- | @@ -514,7 +514,7 @@ GET /api/reservation/order-tasks/{orderTaskId} 权限:RESERVATION_TASK_READ ``` -返回结构草案: +返回结构: ```json { @@ -538,7 +538,7 @@ GET /api/reservation/source-notifications/{notificationId} 权限:RESERVATION_TASK_READ ``` -返回结构草案: +返回结构: ```json { @@ -557,7 +557,7 @@ GET /api/reservation/source-notifications/{notificationId} ### 12.5 订单详情时间线 -建议后续扩展: +建议后续扩展,CP5 尚未实现: ```text GET /api/reservation/orders/{orderId}/order-tasks @@ -669,7 +669,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 查询接口 | 工作台统一列表、订单任务列表、详情、订单详情时间线和来源通知详情查询接口 | +| M002-V4-CP5 | V4 查询接口 | 已完成:工作台统一列表、订单任务列表、订单任务详情和来源通知详情查询接口;订单详情 V4 专属时间线后续再做 | | M002-V4-CP6 | V4 卡片确认和复核 | 不保存草稿,支持确认、复核、锁定、审计、阻塞规则和 S10/S99 来源通知确认 | | M002-V4-CP7 | 受控目录第一版 | Account、RoomType、RateCode、Department 固定目录或版本化快照校验 | | M002-V4-CP8 | V4 前端契约收口 | 字段、控件、availability、错误展示和旧任务入口切换 | diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index d1183bb..45da954 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -47,10 +47,10 @@ | `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 | -| `GET /api/reservation/workbench-items` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL 或 AI 原始 payload | -| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计 | -| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限 | -| `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限 | +| `GET /api/reservation/workbench-items` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload | +| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload | +| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回 `ai_payload_json` | +| `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 并发校验 | 必须写业务审计 | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardType.java index 47edd0f..5013da1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardType.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardType.java @@ -6,6 +6,8 @@ package cn.nianxx.thhotel.workflows.reservation.common.enums; public enum ReservationV4CardType { /** 来源邮件展示卡。 */ SOURCE_MESSAGE_DISPLAY, + /** S10/S99 来源通知展示卡。 */ + SOURCE_MESSAGE_NOTIFICATION, /** 订单级 Basic Information 卡。 */ BASIC_INFORMATION, /** 房型、房晚和房数等客房信息卡。 */ 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 new file mode 100644 index 0000000..7bd57a5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4ReadonlyReasonCode.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 查询接口只读原因码。只表达后端当前动作边界,不作为中文文案。 + */ +public enum ReservationV4ReadonlyReasonCode { + PROCESSABLE, + CARD_LOCKED, + SOURCE_MESSAGE_DISPLAY_READONLY, + PRIOR_CARD_NOT_CONFIRMED, + PRIOR_ORDER_TASK_NOT_COMPLETED, + COMMAND_API_PENDING, + ACK_API_PENDING +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4WorkbenchItemType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4WorkbenchItemType.java new file mode 100644 index 0000000..b387538 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4WorkbenchItemType.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 工作台条目类型。前端按该值决定跳转订单任务详情还是来源通知详情。 + */ +public enum ReservationV4WorkbenchItemType { + ORDER_TASK, + SOURCE_NOTIFICATION +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4OrderTaskQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4OrderTaskQueryRequest.java new file mode 100644 index 0000000..40e7567 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4OrderTaskQueryRequest.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +/** + * Reservation V4 业务订单任务列表查询条件。 + * + * @param hotelId 酒店 ID,可为空并由当前登录上下文解析 + * @param orderId 本地订单投影 ID,可为空 + * @param orderTaskStatus 订单任务状态,可为空 + * @param cardStatus 要求订单任务内存在的卡片状态,可为空 + * @param keyword 安全关键词,用于匹配订单定位值或来源邮件安全摘要 + * @param pageNum 页码,从 1 开始 + * @param pageSize 每页数量 + */ +public record ReservationV4OrderTaskQueryRequest( + String hotelId, + Long orderId, + String orderTaskStatus, + String cardStatus, + String keyword, + Integer pageNum, + Integer pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4SourceNotificationQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4SourceNotificationQueryRequest.java new file mode 100644 index 0000000..347f6b9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4SourceNotificationQueryRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +/** + * Reservation V4 S10/S99 来源通知列表查询条件。 + * + * @param hotelId 酒店 ID,可为空并由当前登录上下文解析 + * @param notificationStatus 通知状态,可为空 + * @param keyword 安全关键词,用于匹配路由码或来源邮件安全摘要 + * @param pageNum 页码,从 1 开始 + * @param pageSize 每页数量 + */ +public record ReservationV4SourceNotificationQueryRequest( + String hotelId, + String notificationStatus, + String keyword, + Integer pageNum, + Integer pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4WorkbenchQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4WorkbenchQueryRequest.java new file mode 100644 index 0000000..c0f6854 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4WorkbenchQueryRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +/** + * Reservation V4 工作台统一列表查询条件。第一版只开放安全摘要筛选,不读取邮件正文或 AI 原始 payload。 + * + * @param hotelId 酒店 ID,可为空并由当前登录上下文解析 + * @param itemType 工作台条目类型,支持 ORDER_TASK、SOURCE_NOTIFICATION,可为空表示全部 + * @param keyword 安全关键词,用于匹配订单定位值或来源邮件安全摘要 + * @param pageNum 页码,从 1 开始 + * @param pageSize 每页数量 + */ +public record ReservationV4WorkbenchQueryRequest( + String hotelId, + String itemType, + String keyword, + Integer pageNum, + Integer pageSize +) { +} 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 new file mode 100644 index 0000000..750b127 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4ActionAvailabilityResult.java @@ -0,0 +1,34 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 查询侧动作可用性。CP5 只读查询阶段不开放写接口,因此 confirmable / ackable 默认由后续 CP 打开。 + * + * @param blocked 是否被前置订单任务或卡片阻塞 + * @param readOnly 当前是否只读 + * @param editable 当前是否允许编辑 + * @param confirmable 当前是否允许最终确认 + * @param ackable 当前是否允许确认来源通知已处理 + * @param readonlyReasonCode 只读原因稳定码 + * @param blockedByOrderTaskId 阻塞当前条目的前置订单任务 ID + * @param blockedByCardId 阻塞当前卡片的前置卡片 ID + * @param blockedReason 阻塞原因说明 + */ +public record ReservationV4ActionAvailabilityResult( + boolean blocked, + @JsonProperty("read_only") + boolean readOnly, + boolean editable, + boolean confirmable, + boolean ackable, + @JsonProperty("readonly_reason_code") + String readonlyReasonCode, + @JsonProperty("blocked_by_order_task_id") + String blockedByOrderTaskId, + @JsonProperty("blocked_by_card_id") + String blockedByCardId, + @JsonProperty("blocked_reason") + String blockedReason +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CardCountsResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CardCountsResult.java new file mode 100644 index 0000000..029f91a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CardCountsResult.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 订单任务卡片数量摘要,用于工作台和列表展示待处理规模。 + * + * @param totalCount 卡片总数 + * @param readonlyCount 只读卡数量 + * @param pendingConfirmCount 待确认卡数量 + * @param reviewRequiredCount 待人工复核卡数量 + * @param confirmedCount 已确认卡数量 + */ +public record ReservationV4CardCountsResult( + @JsonProperty("total_count") + int totalCount, + @JsonProperty("readonly_count") + int readonlyCount, + @JsonProperty("pending_confirm_count") + int pendingConfirmCount, + @JsonProperty("review_required_count") + int reviewRequiredCount, + @JsonProperty("confirmed_count") + int confirmedCount +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskDetailResult.java new file mode 100644 index 0000000..f941310 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskDetailResult.java @@ -0,0 +1,38 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Reservation V4 订单任务详情结果。按来源邮件卡、Basic Information 卡和业务卡拆分,避免前端猜卡片结构。 + * + * @param orderTask 订单任务顶部摘要 + * @param sourceMessageSummary 来源邮件安全摘要 + * @param boundOrder 已绑定本地订单摘要,第一版可能为空 + * @param sourceMessageCard 来源邮件展示卡,只读 + * @param basicInformationCard Basic Information 订单级卡 + * @param businessCards 业务事件卡列表 + * @param cardCounts 卡片数量统计 + * @param adapterContractErrors 同批次契约错误诊断块,CP5 第一版为空列表 + * @param availability 订单任务整体可操作性 + */ +public record ReservationV4OrderTaskDetailResult( + @JsonProperty("order_task") + ReservationV4OrderTaskSummaryResult orderTask, + @JsonProperty("source_message_summary") + ReservationV4SourceMessageSummaryResult sourceMessageSummary, + @JsonProperty("bound_order") + ReservationOrderSummaryResult boundOrder, + @JsonProperty("source_message_card") + ReservationV4TaskCardResult sourceMessageCard, + @JsonProperty("basic_information_card") + ReservationV4TaskCardResult basicInformationCard, + @JsonProperty("business_cards") + List businessCards, + @JsonProperty("card_counts") + ReservationV4CardCountsResult cardCounts, + @JsonProperty("adapter_contract_errors") + List adapterContractErrors, + ReservationV4ActionAvailabilityResult availability +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskListItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskListItemResult.java new file mode 100644 index 0000000..1022cb9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskListItemResult.java @@ -0,0 +1,28 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 订单任务列表条目。 + * + * @param orderTask 订单任务摘要 + * @param sourceMessageSummary 来源邮件安全摘要 + * @param cardCounts 卡片数量摘要 + * @param nextActionCardId 下一张待处理卡片 ID + * @param displayStatus 前端展示状态 + * @param availability 可操作性摘要 + */ +public record ReservationV4OrderTaskListItemResult( + @JsonProperty("order_task") + ReservationV4OrderTaskSummaryResult orderTask, + @JsonProperty("source_message_summary") + ReservationV4SourceMessageSummaryResult sourceMessageSummary, + @JsonProperty("card_counts") + ReservationV4CardCountsResult cardCounts, + @JsonProperty("next_action_card_id") + String nextActionCardId, + @JsonProperty("display_status") + String displayStatus, + ReservationV4ActionAvailabilityResult availability +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskListResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskListResult.java new file mode 100644 index 0000000..72222a5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskListResult.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import java.util.List; + +/** + * Reservation V4 订单任务列表分页结果。 + * + * @param items 当前页订单任务摘要 + * @param page 分页摘要 + */ +public record ReservationV4OrderTaskListResult( + List items, + ReservationPaginationResult page +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskSummaryResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskSummaryResult.java new file mode 100644 index 0000000..078831b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4OrderTaskSummaryResult.java @@ -0,0 +1,62 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * Reservation V4 订单任务摘要。用于列表和详情顶部,不包含卡片原始 AI payload。 + * + * @param orderTaskId V4 订单任务 ID + * @param hotelId 酒店 ID + * @param sourceMessageId SourceMessage Inbox 内部 ID + * @param aiBatchId AI 入站批次 ID + * @param orderRef V4 回调包内订单引用 + * @param orderContextIndex order_contexts 数组序号 + * @param orderId 已绑定本地订单投影 ID + * @param targetBookingType 目标预订类型,例如 GROUP 或 FIT + * @param targetLocatorType 目标定位字段类型,例如 GROUP_CODE 或 CONFIRMATION_NUMBER + * @param targetLocatorValue 目标定位字段值 + * @param targetResolutionStatus 订单归属解析状态 + * @param orderTaskStatus 订单任务状态 + * @param displayOrderKey 前端优先展示的订单业务键 + * @param sourceReceivedAt 来源邮件接收 UTC 时间 + * @param version 订单任务乐观锁版本 + * @param createdAt 创建 UTC 时间 + * @param updatedAt 更新 UTC 时间 + */ +public record ReservationV4OrderTaskSummaryResult( + @JsonProperty("order_task_id") + String orderTaskId, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("ai_batch_id") + String aiBatchId, + @JsonProperty("order_ref") + String orderRef, + @JsonProperty("order_context_index") + Integer orderContextIndex, + @JsonProperty("order_id") + String orderId, + @JsonProperty("target_booking_type") + String targetBookingType, + @JsonProperty("target_locator_type") + String targetLocatorType, + @JsonProperty("target_locator_value") + String targetLocatorValue, + @JsonProperty("target_resolution_status") + String targetResolutionStatus, + @JsonProperty("order_task_status") + String orderTaskStatus, + @JsonProperty("display_order_key") + String displayOrderKey, + @JsonProperty("source_received_at") + OffsetDateTime sourceReceivedAt, + Long version, + @JsonProperty("created_at") + OffsetDateTime createdAt, + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceMessageSummaryResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceMessageSummaryResult.java new file mode 100644 index 0000000..14ac90a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceMessageSummaryResult.java @@ -0,0 +1,38 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * Reservation V4 查询接口中的来源邮件安全摘要。不得包含正文、HTML、附件 URL 或原始 payload。 + * + * @param sourceMessageId SourceMessage Inbox 内部 ID + * @param hotelId 酒店 ID + * @param externalMessageId 外部邮件消息 ID + * @param externalConversationId 外部邮件会话 ID + * @param subject 邮件主题安全摘要 + * @param senderSummary 发件人安全摘要 + * @param receivedAt 来源接收 UTC 时间 + * @param sourceSentAt 来源发送 UTC 时间 + * @param conversationMessageCount 同一外部会话下的邮件数量 + */ +public record ReservationV4SourceMessageSummaryResult( + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("external_message_id") + String externalMessageId, + @JsonProperty("external_conversation_id") + String externalConversationId, + String subject, + @JsonProperty("sender_summary") + String senderSummary, + @JsonProperty("received_at") + OffsetDateTime receivedAt, + @JsonProperty("source_sent_at") + OffsetDateTime sourceSentAt, + @JsonProperty("conversation_message_count") + Long conversationMessageCount +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationDetailResult.java new file mode 100644 index 0000000..2e19b0b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationDetailResult.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 S10/S99 来源通知详情。正文和附件 URL 仍必须通过 SourceMessage 原文 / 会话接口读取。 + * + * @param notification 来源通知摘要 + * @param sourceMessageCard 来源邮件通知卡 + * @param conversationSummary 来源邮件会话安全摘要 + * @param availability 来源通知可操作性 + */ +public record ReservationV4SourceNotificationDetailResult( + ReservationV4SourceNotificationSummaryResult notification, + @JsonProperty("source_message_card") + ReservationV4TaskCardResult sourceMessageCard, + @JsonProperty("conversation_summary") + ReservationV4SourceMessageSummaryResult conversationSummary, + ReservationV4ActionAvailabilityResult availability +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationSummaryResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationSummaryResult.java new file mode 100644 index 0000000..e314e4c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4SourceNotificationSummaryResult.java @@ -0,0 +1,50 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * Reservation V4 来源通知摘要。用于 S10/S99 通知详情和工作台。 + * + * @param notificationId 来源通知 ID + * @param hotelId 酒店 ID + * @param sourceMessageId SourceMessage Inbox 内部 ID + * @param aiBatchId AI 入站批次 ID + * @param aiTransitionId AI transition ID,第一版可能为空 + * @param routeCode S10 或 S99 路由码 + * @param notificationStatus 来源通知状态 + * @param sourceReceivedAt 来源邮件接收 UTC 时间 + * @param ackBy 确认人,未确认时为空 + * @param ackAt 确认 UTC 时间,未确认时为空 + * @param version 来源通知乐观锁版本 + * @param createdAt 创建 UTC 时间 + * @param updatedAt 更新 UTC 时间 + */ +public record ReservationV4SourceNotificationSummaryResult( + @JsonProperty("notification_id") + String notificationId, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("ai_batch_id") + String aiBatchId, + @JsonProperty("ai_transition_id") + String aiTransitionId, + @JsonProperty("route_code") + String routeCode, + @JsonProperty("notification_status") + String notificationStatus, + @JsonProperty("source_received_at") + OffsetDateTime sourceReceivedAt, + @JsonProperty("ack_by") + String ackBy, + @JsonProperty("ack_at") + OffsetDateTime ackAt, + Long version, + @JsonProperty("created_at") + OffsetDateTime createdAt, + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardResult.java new file mode 100644 index 0000000..48aadb2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4TaskCardResult.java @@ -0,0 +1,62 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.OffsetDateTime; + +/** + * Reservation V4 任务卡查询结果。只返回展示、确认和复核结果 JSON,不返回 ai_payload_json。 + * + * @param cardId V4 任务卡 ID + * @param cardType 卡片类型 + * @param eventType 业务事件类型,非 event 卡为空 + * @param sourceEventIndex 来源事件序号,非 event 卡固定为 0 + * @param cardSortOrder 卡片排序号 + * @param cardStatus 卡片状态 + * @param reviewStatus 复核状态 + * @param displayPayload 前端安全展示 JSON + * @param confirmedPayload 用户确认后的 JSON + * @param reviewResolution 复核解阻结果 JSON + * @param validationErrors 后端校验错误 JSON + * @param confirmedBy 确认人 + * @param confirmedAt 确认 UTC 时间 + * @param version 卡片乐观锁版本 + * @param createdAt 创建 UTC 时间 + * @param updatedAt 更新 UTC 时间 + * @param availability 卡片可操作性 + */ +public record ReservationV4TaskCardResult( + @JsonProperty("card_id") + String cardId, + @JsonProperty("card_type") + String cardType, + @JsonProperty("event_type") + String eventType, + @JsonProperty("source_event_index") + Integer sourceEventIndex, + @JsonProperty("card_sort_order") + Integer cardSortOrder, + @JsonProperty("card_status") + String cardStatus, + @JsonProperty("review_status") + String reviewStatus, + @JsonProperty("display_payload") + JsonNode displayPayload, + @JsonProperty("confirmed_payload") + JsonNode confirmedPayload, + @JsonProperty("review_resolution") + JsonNode reviewResolution, + @JsonProperty("validation_errors") + JsonNode validationErrors, + @JsonProperty("confirmed_by") + String confirmedBy, + @JsonProperty("confirmed_at") + OffsetDateTime confirmedAt, + Long version, + @JsonProperty("created_at") + OffsetDateTime createdAt, + @JsonProperty("updated_at") + OffsetDateTime updatedAt, + ReservationV4ActionAvailabilityResult availability +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchItemResult.java new file mode 100644 index 0000000..24e5219 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchItemResult.java @@ -0,0 +1,51 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * Reservation V4 工作台统一列表条目。可表示订单任务,也可表示 S10/S99 来源通知。 + * + * @param itemType 工作台条目类型:ORDER_TASK 或 SOURCE_NOTIFICATION + * @param targetId 跳转目标 ID,订单任务 ID 或来源通知 ID + * @param hotelId 酒店 ID + * @param sourceMessageId SourceMessage Inbox 内部 ID + * @param sourceMessageSummary 来源邮件安全摘要 + * @param sourceReceivedAt 列表排序用来源邮件接收 UTC 时间 + * @param displayOrderKey 业务订单展示键,来源通知为空 + * @param cardCounts 订单任务卡片数量,来源通知为空 + * @param nextActionCardId 下一张待处理卡片 ID,来源通知为空 + * @param notificationStatus 来源通知状态,仅 SOURCE_NOTIFICATION 返回 + * @param orderTaskStatus 订单任务状态,仅 ORDER_TASK 返回 + * @param displayStatus 前端展示状态 + * @param readonlyReasonCode 只读原因稳定码 + */ +public record ReservationV4WorkbenchItemResult( + @JsonProperty("item_type") + String itemType, + @JsonProperty("target_id") + String targetId, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("source_message_summary") + ReservationV4SourceMessageSummaryResult sourceMessageSummary, + @JsonProperty("source_received_at") + OffsetDateTime sourceReceivedAt, + @JsonProperty("display_order_key") + String displayOrderKey, + @JsonProperty("card_counts") + ReservationV4CardCountsResult cardCounts, + @JsonProperty("next_action_card_id") + String nextActionCardId, + @JsonProperty("notification_status") + String notificationStatus, + @JsonProperty("order_task_status") + String orderTaskStatus, + @JsonProperty("display_status") + String displayStatus, + @JsonProperty("readonly_reason_code") + String readonlyReasonCode +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchListResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchListResult.java new file mode 100644 index 0000000..49f78de --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4WorkbenchListResult.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import java.util.List; + +/** + * Reservation V4 工作台统一列表分页结果。 + * + * @param items 当前页条目 + * @param page 分页摘要 + */ +public record ReservationV4WorkbenchListResult( + List items, + ReservationPaginationResult page +) { +} 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 f2c4352..653c3bb 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 @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice(assignableTypes = { ReservationTaskController.class, ReservationFrontendQueryController.class, + ReservationV4QueryController.class, ReservationDemoDataController.class, ReservationInvoiceGenerationController.class, ReservationRoomingListGenerationController.class diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryController.java new file mode 100644 index 0000000..96f8885 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryController.java @@ -0,0 +1,102 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reservation V4 前端查询 Controller。提供订单任务、多卡和 S10/S99 来源通知的只读查询入口。 + */ +@RestController +@RequestMapping("/api/reservation") +public class ReservationV4QueryController { + + private final ReservationV4QueryService v4QueryService; + private final FrontendAuthorizationService authorizationService; + + /** + * 注入 V4 查询服务和前端鉴权服务,Controller 只处理 HTTP 参数和权限入口。 + */ + public ReservationV4QueryController( + ReservationV4QueryService v4QueryService, + FrontendAuthorizationService authorizationService) { + this.v4QueryService = v4QueryService; + this.authorizationService = authorizationService; + } + + /** + * 查询 V4 工作台统一列表,包含业务订单任务和 S10/S99 来源通知摘要。 + */ + @GetMapping(value = "/workbench-items", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4WorkbenchListResult listWorkbenchItems( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(name = "item_type", required = false) String itemType, + @RequestParam(required = false) String keyword, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return v4QueryService.queryWorkbenchItems(new ReservationV4WorkbenchQueryRequest( + hotelId, + itemType, + keyword, + pageNum, + pageSize)); + } + + /** + * 查询 V4 业务订单任务列表,不包含 S10/S99 来源通知。 + */ + @GetMapping(value = "/order-tasks", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4OrderTaskListResult listOrderTasks( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(name = "order_id", required = false) Long orderId, + @RequestParam(name = "order_task_status", required = false) String orderTaskStatus, + @RequestParam(name = "card_status", required = false) String cardStatus, + @RequestParam(required = false) String keyword, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return v4QueryService.queryOrderTasks(new ReservationV4OrderTaskQueryRequest( + hotelId, + orderId, + orderTaskStatus, + cardStatus, + keyword, + pageNum, + pageSize)); + } + + /** + * 查询 V4 订单任务详情,返回来源邮件展示卡、Basic Information 卡和业务卡数组。 + */ + @GetMapping(value = "/order-tasks/{orderTaskId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4OrderTaskDetailResult getOrderTaskDetail( + @PathVariable Long orderTaskId, + @RequestParam(name = "hotel_id", required = false) String hotelId) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return v4QueryService.getOrderTaskDetail(hotelId, orderTaskId); + } + + /** + * 查询 V4 S10/S99 来源通知详情,只返回安全邮件摘要和通知状态。 + */ + @GetMapping(value = "/source-notifications/{notificationId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4SourceNotificationDetailResult getSourceNotificationDetail( + @PathVariable Long notificationId, + @RequestParam(name = "hotel_id", required = false) String hotelId) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return v4QueryService.getSourceNotificationDetail(hotelId, notificationId); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java index 637ebff..b3f7932 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java @@ -2,11 +2,67 @@ package cn.nianxx.thhotel.workflows.reservation.mapper; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.util.List; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * Reservation V4 订单任务 Mapper。自带 CRUD 由 MyBatis-Plus 提供。 */ @Mapper public interface ReservationV4OrderTaskMapper extends BaseMapper { + + /** + * 分页查询 V4 前端订单任务摘要,支持按卡片状态和来源邮件安全摘要命中结果筛选。 + */ + @Select(""" + + """) + Page selectFrontendOrderTaskPage( + Page page, + @Param("hotelId") String hotelId, + @Param("orderId") Long orderId, + @Param("orderTaskStatus") String orderTaskStatus, + @Param("cardStatus") String cardStatus, + @Param("keyword") String keyword, + @Param("sourceMessageIds") List sourceMessageIds); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4SourceNotificationMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4SourceNotificationMapper.java index 4c8d660..502d944 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4SourceNotificationMapper.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4SourceNotificationMapper.java @@ -2,11 +2,49 @@ package cn.nianxx.thhotel.workflows.reservation.mapper; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.util.List; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * Reservation V4 来源通知 Mapper。自带 CRUD 由 MyBatis-Plus 提供。 */ @Mapper public interface ReservationV4SourceNotificationMapper extends BaseMapper { + + /** + * 分页查询 V4 来源通知摘要,支持状态、路由码和来源邮件安全摘要筛选。 + */ + @Select(""" + + """) + Page selectFrontendSourceNotificationPage( + Page page, + @Param("hotelId") String hotelId, + @Param("notificationStatus") String notificationStatus, + @Param("keyword") String keyword, + @Param("sourceMessageIds") List sourceMessageIds); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java index 26c487e..2b17878 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java @@ -1,10 +1,13 @@ package cn.nianxx.thhotel.workflows.reservation.repository; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; 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.request.ReservationV4SourceNotificationQueryRequest; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4SourceNotificationMapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -43,6 +46,19 @@ public class MybatisReservationV4SourceNotificationRepository implements Reserva return Optional.ofNullable(entity).map(this::toSnapshot); } + /** + * 按来源通知 ID 反查真实酒店归属,用于详情接口做对象级酒店隔离。 + */ + @Override + public Optional findSourceNotificationById(Long notificationId) { + ReservationV4SourceNotificationEntity entity = sourceNotificationMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationV4SourceNotificationEntity::getId, notificationId) + .isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toSnapshot); + } + /** * 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。 */ @@ -79,6 +95,27 @@ public class MybatisReservationV4SourceNotificationRepository implements Reserva .toList(); } + /** + * 分页查询 V4 来源通知摘要,返回安全查询所需快照。 + */ + @Override + public ReservationPageSnapshot querySourceNotifications( + ReservationV4SourceNotificationQueryRequest request, + List keywordSourceMessageIds, + int pageNum, + int pageSize) { + Page page = sourceNotificationMapper.selectFrontendSourceNotificationPage( + Page.of(pageNum, pageSize), + request.hotelId(), + request.notificationStatus(), + request.keyword(), + keywordSourceMessageIds); + List items = page.getRecords().stream() + .map(this::toSnapshot) + .toList(); + return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize); + } + /** * 幂等创建 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 19e3c7b..7ad1c94 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 @@ -2,13 +2,16 @@ package cn.nianxx.thhotel.workflows.reservation.repository; 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.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.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.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -68,6 +71,19 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot); } + /** + * 按订单任务 ID 反查 V4 订单任务真实酒店归属,用于详情接口做对象级酒店隔离。 + */ + @Override + public Optional findOrderTaskById(Long orderTaskId) { + ReservationV4OrderTaskEntity entity = orderTaskMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationV4OrderTaskEntity::getId, orderTaskId) + .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot); + } + /** * 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。 */ @@ -84,6 +100,50 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work .toList(); } + /** + * 分页查询 V4 订单任务摘要,返回安全查询所需快照。 + */ + @Override + public ReservationPageSnapshot queryOrderTasks( + ReservationV4OrderTaskQueryRequest request, + List keywordSourceMessageIds, + int pageNum, + int pageSize) { + Page page = orderTaskMapper.selectFrontendOrderTaskPage( + Page.of(pageNum, pageSize), + request.hotelId(), + request.orderId(), + request.orderTaskStatus(), + request.cardStatus(), + request.keyword(), + keywordSourceMessageIds); + List items = page.getRecords().stream() + .map(this::toOrderTaskSnapshot) + .toList(); + return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize); + } + + /** + * 按本地订单 ID 批量查询 V4 订单任务,用于实时派生同订单前置阻塞。 + */ + @Override + public List findOrderTasksByOrderIds(String hotelId, List orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + return List.of(); + } + return orderTaskMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationV4OrderTaskEntity::getHotelId, hotelId) + .in(ReservationV4OrderTaskEntity::getOrderId, orderIds) + .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt) + .orderByAsc(ReservationV4OrderTaskEntity::getOrderId) + .orderByAsc(ReservationV4OrderTaskEntity::getSourceReceivedAt) + .orderByAsc(ReservationV4OrderTaskEntity::getCreatedAt) + .orderByAsc(ReservationV4OrderTaskEntity::getId)) + .stream() + .map(this::toOrderTaskSnapshot) + .toList(); + } + /** * 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。 */ @@ -180,6 +240,27 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work .toList(); } + /** + * 批量查询多个订单任务下的全部 V4 任务卡,用于列表接口一次性统计卡片状态。 + */ + @Override + public List findTaskCardsByOrderTaskIds(String hotelId, List orderTaskIds) { + if (orderTaskIds == null || orderTaskIds.isEmpty()) { + return List.of(); + } + return taskCardMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationV4TaskCardEntity::getHotelId, hotelId) + .in(ReservationV4TaskCardEntity::getV4OrderTaskId, orderTaskIds) + .isNull(ReservationV4TaskCardEntity::getLogicDeletedAt) + .orderByAsc(ReservationV4TaskCardEntity::getV4OrderTaskId) + .orderByAsc(ReservationV4TaskCardEntity::getCardSortOrder) + .orderByAsc(ReservationV4TaskCardEntity::getSourceEventIndex) + .orderByAsc(ReservationV4TaskCardEntity::getId)) + .stream() + .map(this::toTaskCardSnapshot) + .toList(); + } + /** * 按 version 乐观锁更新 V4 任务卡状态。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java index 7f3f90d..a903d7c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java @@ -1,7 +1,9 @@ package cn.nianxx.thhotel.workflows.reservation.repository; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; 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.request.ReservationV4SourceNotificationQueryRequest; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -18,6 +20,11 @@ public interface ReservationV4SourceNotificationRepository { String hotelId, Long notificationId); + /** + * 按来源通知 ID 反查真实酒店归属,用于详情接口做对象级酒店隔离。 + */ + Optional findSourceNotificationById(Long notificationId); + /** * 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。 */ @@ -33,6 +40,15 @@ public interface ReservationV4SourceNotificationRepository { String hotelId, Long sourceMessageId); + /** + * 分页查询 V4 来源通知摘要,返回安全查询所需快照。 + */ + ReservationPageSnapshot querySourceNotifications( + ReservationV4SourceNotificationQueryRequest request, + List keywordSourceMessageIds, + int pageNum, + int pageSize); + /** * 幂等创建 S10/S99 来源通知;并发重复插入时返回已存在记录。 */ 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 4929ea7..7aadc2f 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 @@ -2,8 +2,10 @@ package cn.nianxx.thhotel.workflows.reservation.repository; 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.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.request.ReservationV4OrderTaskQueryRequest; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -26,11 +28,30 @@ public interface ReservationV4WorkflowRepository { */ Optional findOrderTaskById(String hotelId, Long orderTaskId); + /** + * 按订单任务 ID 反查 V4 订单任务真实酒店归属,用于详情接口做对象级酒店隔离。 + */ + Optional findOrderTaskById(Long orderTaskId); + /** * 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。 */ List findOrderTasksBySourceMessageId(String hotelId, Long sourceMessageId); + /** + * 分页查询 V4 订单任务摘要,返回安全查询所需快照。 + */ + ReservationPageSnapshot queryOrderTasks( + ReservationV4OrderTaskQueryRequest request, + List keywordSourceMessageIds, + int pageNum, + int pageSize); + + /** + * 按本地订单 ID 批量查询 V4 订单任务,用于实时派生同订单前置阻塞。 + */ + List findOrderTasksByOrderIds(String hotelId, List orderIds); + /** * 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。 */ @@ -51,6 +72,11 @@ public interface ReservationV4WorkflowRepository { */ List findTaskCardsByOrderTaskId(String hotelId, Long orderTaskId); + /** + * 批量查询多个订单任务下的全部 V4 任务卡,用于列表接口一次性统计卡片状态。 + */ + List findTaskCardsByOrderTaskIds(String hotelId, List orderTaskIds); + /** * 按 version 乐观锁更新 V4 任务卡状态。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4QueryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4QueryService.java new file mode 100644 index 0000000..4d81ea8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4QueryService.java @@ -0,0 +1,34 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; + +/** + * Reservation V4 前端查询服务。只返回安全展示数据,不返回邮件正文、附件 URL 或 AI 原始 payload。 + */ +public interface ReservationV4QueryService { + + /** + * 查询 V4 工作台统一列表,同时包含业务订单任务和 S10/S99 来源通知。 + */ + ReservationV4WorkbenchListResult queryWorkbenchItems(ReservationV4WorkbenchQueryRequest request); + + /** + * 查询 V4 业务订单任务列表,不包含 S10/S99 来源通知。 + */ + ReservationV4OrderTaskListResult queryOrderTasks(ReservationV4OrderTaskQueryRequest request); + + /** + * 查询 V4 订单任务详情,并按来源邮件卡、Basic Information 卡和业务卡拆分返回。 + */ + ReservationV4OrderTaskDetailResult getOrderTaskDetail(String hotelId, Long orderTaskId); + + /** + * 查询 V4 S10/S99 来源通知详情,不返回业务订单任务结构。 + */ + ReservationV4SourceNotificationDetailResult getSourceNotificationDetail(String hotelId, Long notificationId); +} 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 new file mode 100644 index 0000000..89d0b9e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java @@ -0,0 +1,817 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; +import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; +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.enums.ReservationV4ReadonlyReasonCode; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4WorkbenchItemType; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderSummaryResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4ActionAvailabilityResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CardCountsResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListItemResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskListResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskSummaryResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceMessageSummaryResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; +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.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reservation V4 查询服务实现。负责把新模型快照转换为前端安全可展示结构。 + */ +@Service +public class ReservationV4QueryServiceImpl implements ReservationV4QueryService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_NUM = 100; + private static final int MAX_PAGE_SIZE = 100; + private static final int MAX_MIXED_WORKBENCH_FETCH_SIZE = 10_000; + private static final String DISPLAY_STATUS_BLOCKED = "BLOCKED"; + private static final String DISPLAY_STATUS_OPEN = "OPEN"; + + private final ReservationV4WorkflowRepository workflowRepository; + private final ReservationV4SourceNotificationRepository sourceNotificationRepository; + private final SourceMessageQueryService sourceMessageQueryService; + private final HotelContextService hotelContextService; + private final ObjectMapper objectMapper; + + /** + * 注入 V4 持久化边界、来源邮件安全摘要服务和酒店上下文服务。 + */ + public ReservationV4QueryServiceImpl( + ReservationV4WorkflowRepository workflowRepository, + ReservationV4SourceNotificationRepository sourceNotificationRepository, + SourceMessageQueryService sourceMessageQueryService, + HotelContextService hotelContextService, + ObjectMapper objectMapper) { + this.workflowRepository = workflowRepository; + this.sourceNotificationRepository = sourceNotificationRepository; + this.sourceMessageQueryService = sourceMessageQueryService; + this.hotelContextService = hotelContextService; + this.objectMapper = objectMapper; + } + + /** + * 查询 V4 工作台统一列表,按来源邮件接收时间倒序混排订单任务和来源通知。 + */ + @Override + @Transactional(readOnly = true) + public ReservationV4WorkbenchListResult queryWorkbenchItems(ReservationV4WorkbenchQueryRequest request) { + ReservationV4WorkbenchQueryRequest normalized = normalizeWorkbenchRequest(request); + int pageNum = normalizePageNum(normalized.pageNum()); + int pageSize = normalizePageSize(normalized.pageSize()); + String itemType = normalized.itemType(); + if (ReservationV4WorkbenchItemType.ORDER_TASK.name().equals(itemType)) { + ReservationV4OrderTaskListResult orderTasks = queryOrderTasks(new ReservationV4OrderTaskQueryRequest( + normalized.hotelId(), null, null, null, normalized.keyword(), pageNum, pageSize)); + List items = orderTasks.items().stream() + .map(this::toWorkbenchOrderTaskItem) + .toList(); + return new ReservationV4WorkbenchListResult(items, orderTasks.page()); + } + if (ReservationV4WorkbenchItemType.SOURCE_NOTIFICATION.name().equals(itemType)) { + ReservationPageSnapshot notificationPage = + querySourceNotificationPage(normalized.hotelId(), null, normalized.keyword(), pageNum, pageSize); + Map sourceSummaries = sourceSummaries( + normalized.hotelId(), + notificationPage.items().stream().map(ReservationV4SourceNotificationSnapshot::sourceMessageId).toList()); + List items = notificationPage.items().stream() + .map(notification -> toWorkbenchSourceNotificationItem( + notification, + sourceSummaries.get(notification.sourceMessageId()))) + .toList(); + return new ReservationV4WorkbenchListResult( + items, + new ReservationPaginationResult(notificationPage.pageNum(), notificationPage.pageSize(), notificationPage.total())); + } + int fetchSize = mixedWorkbenchFetchSize(pageNum, pageSize); + ReservationV4OrderTaskListResult orderTasks = queryOrderTasks(new ReservationV4OrderTaskQueryRequest( + normalized.hotelId(), null, null, null, normalized.keyword(), 1, fetchSize)); + ReservationPageSnapshot notificationPage = + querySourceNotificationPage(normalized.hotelId(), null, normalized.keyword(), 1, fetchSize); + Map notificationSourceSummaries = sourceSummaries( + normalized.hotelId(), + notificationPage.items().stream().map(ReservationV4SourceNotificationSnapshot::sourceMessageId).toList()); + List merged = new ArrayList<>(); + orderTasks.items().stream().map(this::toWorkbenchOrderTaskItem).forEach(merged::add); + notificationPage.items().stream() + .map(notification -> toWorkbenchSourceNotificationItem( + notification, + notificationSourceSummaries.get(notification.sourceMessageId()))) + .forEach(merged::add); + merged.sort(workbenchItemComparator()); + long offset = (long) (pageNum - 1) * pageSize; + int fromIndex = offset >= merged.size() ? merged.size() : Math.toIntExact(offset); + int toIndex = Math.min(fromIndex + pageSize, merged.size()); + return new ReservationV4WorkbenchListResult( + merged.subList(fromIndex, toIndex), + new ReservationPaginationResult(pageNum, pageSize, orderTasks.page().total() + notificationPage.total())); + } + + /** + * 查询 V4 业务订单任务列表,不包含 S10/S99 来源通知。 + */ + @Override + @Transactional(readOnly = true) + public ReservationV4OrderTaskListResult queryOrderTasks(ReservationV4OrderTaskQueryRequest request) { + ReservationV4OrderTaskQueryRequest normalized = normalizeOrderTaskRequest(request); + int pageNum = normalizePageNum(normalized.pageNum()); + int pageSize = normalizePageSize(normalized.pageSize()); + List keywordSourceMessageIds = findSourceMessageIdsByKeyword(normalized.hotelId(), normalized.keyword()); + ReservationPageSnapshot page = workflowRepository.queryOrderTasks( + normalized, + keywordSourceMessageIds, + pageNum, + pageSize); + Map> queueContext = findQueueContext(normalized.hotelId(), page.items()); + Map sourceSummaries = sourceSummaries( + normalized.hotelId(), + page.items().stream().map(ReservationV4OrderTaskSnapshot::sourceMessageId).toList()); + Map> taskCards = taskCardsByOrderTaskIds( + normalized.hotelId(), + page.items()); + List items = page.items().stream() + .map(orderTask -> toOrderTaskListItem( + orderTask, + sourceSummaries.get(orderTask.sourceMessageId()), + queueContext, + taskCards.getOrDefault(orderTask.id(), List.of()))) + .toList(); + return new ReservationV4OrderTaskListResult( + items, + new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + /** + * 查询 V4 订单任务详情,并按来源邮件卡、Basic Information 卡和业务卡拆分。 + */ + @Override + @Transactional(readOnly = true) + public ReservationV4OrderTaskDetailResult getOrderTaskDetail(String hotelId, Long orderTaskId) { + ReservationV4OrderTaskSnapshot orderTask = requireOrderTask(orderTaskId, hotelId); + Map> queueContext = findQueueContext( + orderTask.hotelId(), + List.of(orderTask)); + ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext); + List cards = workflowRepository.findTaskCardsByOrderTaskId( + orderTask.hotelId(), + orderTask.id()); + ReservationV4TaskCardSnapshot basicCard = findFirstCard(cards, ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null); + ReservationV4TaskCardResult sourceMessageCard = findFirstCard(cards, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name()) + .map(card -> toCardResult(card, orderAvailability, basicCard)) + .orElse(null); + ReservationV4TaskCardResult basicInformationCard = Optional.ofNullable(basicCard) + .map(card -> toCardResult(card, orderAvailability, basicCard)) + .orElse(null); + List businessCards = cards.stream() + .filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) + .filter(card -> !ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) + .map(card -> toCardResult(card, orderAvailability, basicCard)) + .toList(); + ReservationV4SourceMessageSummaryResult sourceSummary = sourceSummaries( + orderTask.hotelId(), + List.of(orderTask.sourceMessageId())).get(orderTask.sourceMessageId()); + return new ReservationV4OrderTaskDetailResult( + toOrderTaskSummary(orderTask), + sourceSummary, + (ReservationOrderSummaryResult) null, + sourceMessageCard, + basicInformationCard, + businessCards, + cardCounts(cards), + List.of(), + orderAvailability); + } + + /** + * 查询 V4 S10/S99 来源通知详情,只返回安全邮件摘要和通知状态。 + */ + @Override + @Transactional(readOnly = true) + public ReservationV4SourceNotificationDetailResult getSourceNotificationDetail(String hotelId, Long notificationId) { + ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId, hotelId); + ReservationV4SourceMessageSummaryResult sourceSummary = sourceSummaries( + notification.hotelId(), + List.of(notification.sourceMessageId())).get(notification.sourceMessageId()); + ReservationV4ActionAvailabilityResult availability = sourceNotificationAvailability(notification); + return new ReservationV4SourceNotificationDetailResult( + toSourceNotificationSummary(notification), + toSourceNotificationCard(notification, availability), + sourceSummary, + availability); + } + + private ReservationPageSnapshot querySourceNotificationPage( + String hotelId, + String notificationStatus, + String keyword, + int pageNum, + int pageSize) { + List keywordSourceMessageIds = findSourceMessageIdsByKeyword(hotelId, keyword); + return sourceNotificationRepository.querySourceNotifications( + new ReservationV4SourceNotificationQueryRequest(hotelId, notificationStatus, keyword, pageNum, pageSize), + keywordSourceMessageIds, + pageNum, + pageSize); + } + + private ReservationV4OrderTaskListItemResult toOrderTaskListItem( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4SourceMessageSummaryResult sourceSummary, + Map> queueContext, + List cards) { + ReservationV4ActionAvailabilityResult availability = orderTaskAvailability(orderTask, queueContext); + return new ReservationV4OrderTaskListItemResult( + toOrderTaskSummary(orderTask), + sourceSummary, + cardCounts(cards), + nextActionCardId(cards), + displayStatus(orderTask, availability), + availability); + } + + private ReservationV4WorkbenchItemResult toWorkbenchOrderTaskItem(ReservationV4OrderTaskListItemResult item) { + ReservationV4OrderTaskSummaryResult orderTask = item.orderTask(); + return new ReservationV4WorkbenchItemResult( + ReservationV4WorkbenchItemType.ORDER_TASK.name(), + orderTask.orderTaskId(), + orderTask.hotelId(), + orderTask.sourceMessageId(), + item.sourceMessageSummary(), + orderTask.sourceReceivedAt(), + orderTask.displayOrderKey(), + item.cardCounts(), + item.nextActionCardId(), + null, + orderTask.orderTaskStatus(), + item.displayStatus(), + item.availability().readonlyReasonCode()); + } + + private ReservationV4WorkbenchItemResult toWorkbenchSourceNotificationItem( + ReservationV4SourceNotificationSnapshot notification, + ReservationV4SourceMessageSummaryResult sourceSummary) { + ReservationV4ActionAvailabilityResult availability = sourceNotificationAvailability(notification); + return new ReservationV4WorkbenchItemResult( + ReservationV4WorkbenchItemType.SOURCE_NOTIFICATION.name(), + notification.id().toString(), + notification.hotelId(), + notification.sourceMessageId().toString(), + sourceSummary, + UtcTimeFormatter.toUtcOffsetDateTime(notification.sourceReceivedAt()), + null, + null, + null, + notification.notificationStatus(), + null, + notification.notificationStatus(), + availability.readonlyReasonCode()); + } + + private ReservationV4TaskCardResult toCardResult( + ReservationV4TaskCardSnapshot card, + ReservationV4ActionAvailabilityResult orderAvailability, + ReservationV4TaskCardSnapshot basicCard) { + return new ReservationV4TaskCardResult( + card.id().toString(), + card.cardType(), + card.eventType(), + card.sourceEventIndex(), + card.cardSortOrder(), + card.cardStatus(), + card.reviewStatus(), + parseJson(card.displayPayloadJson()), + parseJson(card.confirmedPayloadJson()), + parseJson(card.reviewResolutionJson()), + parseJson(card.validationErrorsJson()), + card.confirmedBy(), + UtcTimeFormatter.toUtcOffsetDateTime(card.confirmedAt()), + card.version(), + UtcTimeFormatter.toUtcOffsetDateTime(card.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(card.updatedAt()), + cardAvailability(card, orderAvailability, basicCard)); + } + + private ReservationV4TaskCardResult toSourceNotificationCard( + ReservationV4SourceNotificationSnapshot notification, + ReservationV4ActionAvailabilityResult availability) { + ObjectNode displayPayload = objectMapper.createObjectNode(); + displayPayload.put("card_type", ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name()); + displayPayload.put("route_code", notification.routeCode()); + displayPayload.put("notification_status", notification.notificationStatus()); + displayPayload.put("source_message_id", notification.sourceMessageId().toString()); + return new ReservationV4TaskCardResult( + notification.id().toString(), + ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name(), + null, + 0, + 10, + notification.notificationStatus(), + null, + displayPayload, + NullNode.getInstance(), + NullNode.getInstance(), + NullNode.getInstance(), + notification.ackBy(), + UtcTimeFormatter.toUtcOffsetDateTime(notification.ackAt()), + notification.version(), + UtcTimeFormatter.toUtcOffsetDateTime(notification.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(notification.updatedAt()), + availability); + } + + private ReservationV4CardCountsResult cardCounts(List cards) { + List safeCards = cards == null ? List.of() : cards; + return new ReservationV4CardCountsResult( + safeCards.size(), + countCards(safeCards, ReservationV4CardStatus.READONLY.name()), + countCards(safeCards, ReservationV4CardStatus.PENDING_CONFIRM.name()), + countCards(safeCards, ReservationV4CardStatus.REVIEW_REQUIRED.name()), + countCards(safeCards, ReservationV4CardStatus.CONFIRMED.name())); + } + + private int countCards(List cards, String status) { + return Math.toIntExact(cards.stream() + .filter(card -> status.equals(card.cardStatus())) + .count()); + } + + private String nextActionCardId(List cards) { + return cards.stream() + .filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) + .filter(card -> ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus()) + || ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) + .map(card -> card.id().toString()) + .findFirst() + .orElse(null); + } + + private ReservationV4ActionAvailabilityResult orderTaskAvailability( + ReservationV4OrderTaskSnapshot orderTask, + Map> queueContext) { + ReservationV4OrderTaskSnapshot blocker = findPriorBlockingOrderTask(orderTask, queueContext); + if (blocker != null) { + return new ReservationV4ActionAvailabilityResult( + true, + true, + false, + false, + false, + ReservationV4ReadonlyReasonCode.PRIOR_ORDER_TASK_NOT_COMPLETED.name(), + blocker.id().toString(), + null, + "同一本地订单下存在更早未完成的 V4 订单任务。"); + } + if (ReservationV4OrderTaskStatus.COMPLETED.name().equals(orderTask.orderTaskStatus())) { + return new ReservationV4ActionAvailabilityResult( + false, + true, + false, + false, + false, + ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), + null, + null, + null); + } + return new ReservationV4ActionAvailabilityResult( + false, + true, + false, + false, + false, + ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(), + null, + null, + "V4 卡片确认和复核写接口将在后续 checkpoint 开放。"); + } + + private ReservationV4ActionAvailabilityResult cardAvailability( + ReservationV4TaskCardSnapshot card, + ReservationV4ActionAvailabilityResult orderAvailability, + ReservationV4TaskCardSnapshot basicCard) { + if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) { + return readOnlyAvailability(ReservationV4ReadonlyReasonCode.SOURCE_MESSAGE_DISPLAY_READONLY.name(), null, null); + } + if (orderAvailability.blocked()) { + return new ReservationV4ActionAvailabilityResult( + true, + true, + false, + false, + false, + orderAvailability.readonlyReasonCode(), + orderAvailability.blockedByOrderTaskId(), + null, + orderAvailability.blockedReason()); + } + if (isBusinessCardBlockedByBasic(card, basicCard)) { + return new ReservationV4ActionAvailabilityResult( + true, + true, + false, + false, + false, + ReservationV4ReadonlyReasonCode.PRIOR_CARD_NOT_CONFIRMED.name(), + null, + basicCard.id().toString(), + "Basic Information 卡未确认前,业务卡只能查看。"); + } + 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())) { + return new ReservationV4ActionAvailabilityResult( + false, + true, + false, + false, + false, + ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(), + null, + null, + "V4 卡片确认和复核写接口将在后续 checkpoint 开放。"); + } + return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); + } + + private ReservationV4ActionAvailabilityResult sourceNotificationAvailability( + ReservationV4SourceNotificationSnapshot notification) { + if (ReservationV4NotificationStatus.ACKED.name().equals(notification.notificationStatus())) { + return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); + } + return new ReservationV4ActionAvailabilityResult( + false, + true, + false, + false, + false, + ReservationV4ReadonlyReasonCode.ACK_API_PENDING.name(), + null, + null, + "V4 来源通知确认写接口将在后续 checkpoint 开放。"); + } + + private ReservationV4ActionAvailabilityResult readOnlyAvailability( + String reasonCode, + String blockedByCardId, + String blockedReason) { + return new ReservationV4ActionAvailabilityResult( + blockedByCardId != null, + true, + false, + false, + false, + reasonCode, + null, + blockedByCardId, + blockedReason); + } + + private boolean isBusinessCardBlockedByBasic( + ReservationV4TaskCardSnapshot card, + ReservationV4TaskCardSnapshot basicCard) { + if (basicCard == null) { + return false; + } + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + return false; + } + return !ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus()); + } + + private ReservationV4OrderTaskSnapshot findPriorBlockingOrderTask( + ReservationV4OrderTaskSnapshot orderTask, + Map> queueContext) { + if (orderTask.orderId() == null) { + return null; + } + List sameOrderTasks = queueContext.getOrDefault(orderTask.orderId(), List.of()); + for (ReservationV4OrderTaskSnapshot candidate : sameOrderTasks) { + if (Objects.equals(candidate.id(), orderTask.id())) { + return null; + } + if (!ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) { + return candidate; + } + } + return null; + } + + private Map> findQueueContext( + String hotelId, + List orderTasks) { + List orderIds = orderTasks.stream() + .map(ReservationV4OrderTaskSnapshot::orderId) + .filter(Objects::nonNull) + .distinct() + .toList(); + List contextTasks = workflowRepository.findOrderTasksByOrderIds(hotelId, orderIds); + Map> grouped = new LinkedHashMap<>(); + for (ReservationV4OrderTaskSnapshot task : contextTasks) { + grouped.computeIfAbsent(task.orderId(), ignored -> new ArrayList<>()).add(task); + } + return grouped; + } + + private Map> taskCardsByOrderTaskIds( + String hotelId, + List orderTasks) { + List orderTaskIds = orderTasks.stream() + .map(ReservationV4OrderTaskSnapshot::id) + .filter(Objects::nonNull) + .distinct() + .toList(); + List cards = workflowRepository.findTaskCardsByOrderTaskIds(hotelId, orderTaskIds); + Map> grouped = new LinkedHashMap<>(); + for (ReservationV4TaskCardSnapshot card : cards) { + grouped.computeIfAbsent(card.v4OrderTaskId(), ignored -> new ArrayList<>()).add(card); + } + return grouped; + } + + private Map sourceSummaries(String hotelId, List sourceMessageIds) { + if (sourceMessageIds == null || sourceMessageIds.isEmpty()) { + return Map.of(); + } + List uniqueIds = sourceMessageIds.stream().filter(Objects::nonNull).distinct().toList(); + List summaries = sourceMessageQueryService.getSummariesByIds(uniqueIds); + List conversationIds = summaries.stream() + .map(SourceMessageSummaryResponse::externalConversationId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map conversationCounts = sourceMessageQueryService.countByExternalConversationIds( + hotelId, + conversationIds); + Map result = new LinkedHashMap<>(); + for (SourceMessageSummaryResponse summary : summaries) { + Long sourceMessageId = Long.valueOf(summary.id()); + result.put(sourceMessageId, new ReservationV4SourceMessageSummaryResult( + summary.id(), + summary.hotelId(), + summary.externalMessageId(), + summary.externalConversationId(), + summary.subject(), + summary.senderSummary(), + summary.receivedAt(), + summary.sourceSentAt(), + conversationCounts.getOrDefault(summary.externalConversationId(), 1L))); + } + for (Long sourceMessageId : uniqueIds) { + result.putIfAbsent(sourceMessageId, new ReservationV4SourceMessageSummaryResult( + sourceMessageId.toString(), + hotelId, + null, + null, + null, + null, + null, + null, + null)); + } + return result; + } + + private List findSourceMessageIdsByKeyword(String hotelId, String keyword) { + if (!hasText(keyword)) { + return List.of(); + } + return sourceMessageQueryService.findIdsBySafeKeyword(hotelId, keyword); + } + + private ReservationV4OrderTaskSnapshot requireOrderTask(Long orderTaskId, String requestedHotelId) { + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrderTaskById(orderTaskId) + .orElseThrow(() -> notFound("V4_ORDER_TASK_NOT_FOUND", "V4 订单任务不存在。")); + requireActualHotelAccess(orderTask.hotelId(), requestedHotelId); + return orderTask; + } + + private ReservationV4SourceNotificationSnapshot requireSourceNotification(Long notificationId, String requestedHotelId) { + ReservationV4SourceNotificationSnapshot notification = sourceNotificationRepository.findSourceNotificationById(notificationId) + .orElseThrow(() -> notFound("V4_SOURCE_NOTIFICATION_NOT_FOUND", "V4 来源通知不存在。")); + requireActualHotelAccess(notification.hotelId(), requestedHotelId); + return notification; + } + + private void requireActualHotelAccess(String actualHotelId, String requestedHotelId) { + try { + String normalizedRequestedHotelId = trimToNull(requestedHotelId); + String hotelIdToCheck = normalizedRequestedHotelId == null ? actualHotelId : normalizedRequestedHotelId; + String resolvedHotelId = hotelContextService.requireAccessibleHotel(hotelIdToCheck); + if (!Objects.equals(actualHotelId, resolvedHotelId)) { + throw new ReservationTaskWorkflowException( + HttpStatus.FORBIDDEN, + "HOTEL_ACCESS_DENIED", + "当前用户无权访问该酒店数据。"); + } + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + + private ReservationV4OrderTaskSummaryResult toOrderTaskSummary(ReservationV4OrderTaskSnapshot orderTask) { + return new ReservationV4OrderTaskSummaryResult( + orderTask.id().toString(), + orderTask.hotelId(), + orderTask.sourceMessageId().toString(), + orderTask.aiBatchId().toString(), + orderTask.orderRef(), + orderTask.orderContextIndex(), + orderTask.orderId() == null ? null : orderTask.orderId().toString(), + orderTask.targetBookingType(), + orderTask.targetLocatorType(), + orderTask.targetLocatorValue(), + orderTask.targetResolutionStatus(), + orderTask.orderTaskStatus(), + displayOrderKey(orderTask), + UtcTimeFormatter.toUtcOffsetDateTime(orderTask.sourceReceivedAt()), + orderTask.version(), + UtcTimeFormatter.toUtcOffsetDateTime(orderTask.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(orderTask.updatedAt())); + } + + private ReservationV4SourceNotificationSummaryResult toSourceNotificationSummary( + ReservationV4SourceNotificationSnapshot notification) { + return new ReservationV4SourceNotificationSummaryResult( + notification.id().toString(), + notification.hotelId(), + notification.sourceMessageId().toString(), + notification.aiBatchId().toString(), + notification.aiTransitionId() == null ? null : notification.aiTransitionId().toString(), + notification.routeCode(), + notification.notificationStatus(), + UtcTimeFormatter.toUtcOffsetDateTime(notification.sourceReceivedAt()), + notification.ackBy(), + UtcTimeFormatter.toUtcOffsetDateTime(notification.ackAt()), + notification.version(), + UtcTimeFormatter.toUtcOffsetDateTime(notification.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(notification.updatedAt())); + } + + private String displayOrderKey(ReservationV4OrderTaskSnapshot orderTask) { + if (hasText(orderTask.targetLocatorValue())) { + return orderTask.targetLocatorValue(); + } + return orderTask.orderRef(); + } + + private String displayStatus( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4ActionAvailabilityResult availability) { + if (availability.blocked()) { + return DISPLAY_STATUS_BLOCKED; + } + return hasText(orderTask.orderTaskStatus()) ? orderTask.orderTaskStatus() : DISPLAY_STATUS_OPEN; + } + + private Optional findFirstCard(List cards, String cardType) { + return cards.stream() + .filter(card -> cardType.equals(card.cardType())) + .findFirst(); + } + + private JsonNode parseJson(String json) { + if (!hasText(json)) { + return NullNode.getInstance(); + } + try { + return objectMapper.readTree(json); + } catch (Exception exception) { + return NullNode.getInstance(); + } + } + + private Comparator workbenchItemComparator() { + return Comparator.comparing(ReservationV4WorkbenchItemResult::sourceReceivedAt, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(ReservationV4WorkbenchItemResult::targetId, Comparator.reverseOrder()); + } + + private ReservationV4WorkbenchQueryRequest normalizeWorkbenchRequest(ReservationV4WorkbenchQueryRequest request) { + if (request == null) { + return new ReservationV4WorkbenchQueryRequest(normalizeHotelId(null), null, null, DEFAULT_PAGE_NUM, DEFAULT_PAGE_SIZE); + } + return new ReservationV4WorkbenchQueryRequest( + normalizeHotelId(request.hotelId()), + normalizeWorkbenchItemType(request.itemType()), + trimToNull(request.keyword()), + request.pageNum(), + request.pageSize()); + } + + private ReservationV4OrderTaskQueryRequest normalizeOrderTaskRequest(ReservationV4OrderTaskQueryRequest request) { + if (request == null) { + return new ReservationV4OrderTaskQueryRequest( + normalizeHotelId(null), null, null, null, null, DEFAULT_PAGE_NUM, DEFAULT_PAGE_SIZE); + } + return new ReservationV4OrderTaskQueryRequest( + normalizeHotelId(request.hotelId()), + request.orderId(), + trimToNull(request.orderTaskStatus()), + trimToNull(request.cardStatus()), + trimToNull(request.keyword()), + request.pageNum(), + request.pageSize()); + } + + private String normalizeHotelId(String hotelId) { + try { + return hotelContextService.resolveCurrentHotelId(hotelId); + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + + private int normalizePageNum(Integer pageNum) { + if (pageNum == null || pageNum < 1) { + return DEFAULT_PAGE_NUM; + } + return Math.min(pageNum, MAX_PAGE_NUM); + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + private int mixedWorkbenchFetchSize(int pageNum, int pageSize) { + long required = (long) pageNum * pageSize; + return Math.toIntExact(Math.min(required, MAX_MIXED_WORKBENCH_FETCH_SIZE)); + } + + private String normalizeWorkbenchItemType(String itemType) { + String normalized = trimToNull(itemType); + if (normalized == null) { + return null; + } + if (ReservationV4WorkbenchItemType.ORDER_TASK.name().equals(normalized) + || ReservationV4WorkbenchItemType.SOURCE_NOTIFICATION.name().equals(normalized)) { + return normalized; + } + throw new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "V4_WORKBENCH_ITEM_TYPE_INVALID", + "V4 工作台条目类型不支持。"); + } + + private ReservationTaskWorkflowException notFound(String errorCode, String message) { + return new ReservationTaskWorkflowException(HttpStatus.NOT_FOUND, 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/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java new file mode 100644 index 0000000..7caed9f --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java @@ -0,0 +1,415 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.matchesPattern; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:reservation_v4_query_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=v4-query-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 ReservationV4QueryControllerTest { + + private static final String HOTEL_ID = "HOTEL-TEST"; + private static final String OTHER_HOTEL_ID = "HOTEL-OTHER"; + private static final String UTC_INSTANT_PATTERN = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"; + + @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; + + private String adminToken; + private String noPermissionToken; + + @BeforeEach + void ensureNoPermissionUser() { + PlatformUserEntity user = identityRepository.findUserByUsername("v4-query-no-permission") + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername("v4-query-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); + } + + /** + * 获取 V4 查询管理员 token,测试通过真实登录链路覆盖前端鉴权。 + */ + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "v4-query-admin", "Admin@123456"); + } + return adminToken; + } + + /** + * 获取没有 RESERVATION_TASK_READ 权限的普通用户 token。 + */ + private String noPermissionToken() throws Exception { + if (noPermissionToken == null) { + noPermissionToken = loginToken(mockMvc, "v4-query-no-permission", "NoPerm@123456"); + } + return noPermissionToken; + } + + @Test + void shouldReturnV4WorkbenchItemsWithOrderTaskAndSourceNotification() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-workbench-order-001", + Instant.parse("2026-07-18T01:00:00Z")); + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + "mail-v4-query-workbench-notification-001", + "S10", + Instant.parse("2026-07-18T02:00:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items") + .param("hotel_id", HOTEL_ID) + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].item_type").value("SOURCE_NOTIFICATION")) + .andExpect(jsonPath("$.items[0].target_id").value(notification.id().toString())) + .andExpect(jsonPath("$.items[0].notification_status").value("ACK_REQUIRED")) + .andExpect(jsonPath("$.items[0].display_status").value("ACK_REQUIRED")) + .andExpect(jsonPath("$.items[0].source_message_summary.subject").value("V4 Query Notification")) + .andExpect(jsonPath("$.items[1].item_type").value("ORDER_TASK")) + .andExpect(jsonPath("$.items[1].target_id").value(orderTask.id().toString())) + .andExpect(jsonPath("$.items[1].display_order_key").value("GRP-V4-QUERY-001")) + .andExpect(jsonPath("$.items[1].card_counts.total_count").value(3)) + .andExpect(jsonPath("$.items[1].card_counts.pending_confirm_count").value(2)) + .andExpect(jsonPath("$.items[1].next_action_card_id").exists()) + .andExpect(jsonPath("$.items[1].source_message_summary.received_at") + .value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.page.page_num").value(1)) + .andExpect(jsonPath("$.page.page_size").value(20)) + .andExpect(jsonPath("$.page.total").value(2)) + .andExpect(content().string(not(containsString("ai_payload_json")))) + .andExpect(content().string(not(containsString("Sensitive raw notification body")))); + } + + @Test + void shouldReturnV4OrderTaskDetailWithSafeCards() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-detail-001", + Instant.parse("2026-07-18T03:00:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_task.order_task_id").value(orderTask.id().toString())) + .andExpect(jsonPath("$.order_task.order_ref").value("order-1")) + .andExpect(jsonPath("$.order_task.target_locator_value").value("GRP-V4-QUERY-001")) + .andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_DISPLAY")) + .andExpect(jsonPath("$.basic_information_card.card_type").value("BASIC_INFORMATION")) + .andExpect(jsonPath("$.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("$.source_message_summary.subject").value("V4 Query Business")) + .andExpect(content().string(not(containsString("https://private.example.test")))); + } + + @Test + void shouldReturnV4OrderTaskListWithCardStatusFilter() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-list-001", + Instant.parse("2026-07-18T03:30:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks") + .param("hotel_id", HOTEL_ID) + .param("card_status", "PENDING_CONFIRM") + .param("keyword", "mail-v4-query-list-001") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items.length()").value(1)) + .andExpect(jsonPath("$.items[0].order_task.order_task_id").value(orderTask.id().toString())) + .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("$.page.total").value(1)); + } + + @Test + void shouldReturnV4SourceNotificationDetailWithoutRawPayload() throws Exception { + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + "mail-v4-query-notification-detail-001", + "S99", + Instant.parse("2026-07-18T04:00:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}", + notification.id()).param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notification.notification_id").value(notification.id().toString())) + .andExpect(jsonPath("$.notification.route_code").value("S99")) + .andExpect(jsonPath("$.notification.notification_status").value("ACK_REQUIRED")) + .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("$.raw_payload_json").doesNotExist()) + .andExpect(content().string(not(containsString("Sensitive raw notification body")))); + } + + @Test + void shouldRejectV4WorkbenchItemsWithoutLogin() throws Exception { + mockMvc.perform(get("/api/reservation/workbench-items") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); + } + + @Test + void shouldRejectV4QueriesWhenPermissionMissing() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-no-permission-001", + Instant.parse("2026-07-18T05:00:00Z")); + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + "mail-v4-query-no-permission-notification-001", + "S10", + Instant.parse("2026-07-18T05:10:00Z")); + + performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/workbench-items") + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/order-tasks") + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/source-notifications/{notificationId}", + notification.id()).param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + } + + @Test + void shouldRejectV4QueriesWhenHotelAccessDenied() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-cross-hotel-001", + Instant.parse("2026-07-18T06:00:00Z")); + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + "mail-v4-query-cross-hotel-notification-001", + "S99", + Instant.parse("2026-07-18T06:10:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items") + .param("hotel_id", OTHER_HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks") + .param("hotel_id", OTHER_HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", OTHER_HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + performAuthorized(mockMvc, adminToken(), get("/api/reservation/source-notifications/{notificationId}", + notification.id()).param("hotel_id", OTHER_HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + } + + @Test + void shouldRejectUnsupportedWorkbenchItemType() throws Exception { + performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items") + .param("hotel_id", HOTEL_ID) + .param("item_type", "UNKNOWN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_WORKBENCH_ITEM_TYPE_INVALID")); + } + + @Test + void shouldCapHugeWorkbenchPageSafely() throws Exception { + performAuthorized(mockMvc, adminToken(), get("/api/reservation/workbench-items") + .param("hotel_id", HOTEL_ID) + .param("page_num", String.valueOf(Integer.MAX_VALUE)) + .param("page_size", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.page_num").value(100)) + .andExpect(jsonPath("$.page.page_size").value(100)); + } + + private ReservationV4OrderTaskSnapshot seedOrderTask(String externalMessageId, Instant receivedAt) { + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Business", receivedAt); + LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( + HOTEL_ID, + source.inboxId(), + 990000000000000001L, + "order-1", + 1, + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-QUERY-001", + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4OrderTaskStatus.OPEN.name(), + LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), + now)); + insertCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10, + ReservationV4CardStatus.READONLY.name(), null, """ + {"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Business"}} + """); + insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, + ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ + {"card_type":"BASIC_INFORMATION","account_code":"QBD_TRAVEL"} + """); + insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, + ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ + {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]} + """); + return orderTask; + } + + private ReservationV4SourceNotificationSnapshot seedSourceNotification( + String externalMessageId, + String routeCode, + Instant receivedAt) { + SourceMessageCaptureResult source = captureSourceMessage( + externalMessageId, + "V4 Query Notification", + receivedAt); + return sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft( + HOTEL_ID, + source.inboxId(), + 990000000000000101L + Math.abs(routeCode.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 void insertCard( + ReservationV4OrderTaskSnapshot orderTask, + String cardType, + String eventType, + Integer sourceEventIndex, + Integer sortOrder, + String cardStatus, + String reviewStatus, + String displayPayloadJson) { + workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( + HOTEL_ID, + orderTask.id(), + orderTask.sourceMessageId(), + eventType == null ? null : 990000000000000201L + 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 externalMessageId, + String subject, + Instant receivedAt) { + return captureService.capture(new CaptureSourceMessageCommand( + HOTEL_ID, + "AGENTBUS", + "EMAIL", + externalMessageId, + "thread-" + externalMessageId, + "frame-" + externalMessageId, + "session-v4-query", + receivedAt, + null, + "guest@example.test", + subject, + "Please handle V4 query message.", + "Please handle V4 query message.", + "{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}", + "agentbus-outlook-v1", + List.of() + )); + } +}