From 8f893991dd752e7fc3ba5d46100c11687e781fbf Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 20 Jul 2026 14:38:09 +0700 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=AE=A2=E5=8D=95=E8=AF=A6?= =?UTF-8?q?=E6=83=85V4=E6=80=BB=E8=A7=88=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 16 +- docs/project/README.md | 4 +- .../backend-to-frontend-notes.md | 9 +- .../frontend-to-backend-api-requests.md | 107 ++++++- ...002-v4-order-task-card-domain-model-cp2.md | 14 +- .../security-access-control-boundary.md | 2 +- .../result/ReservationOrderDetailResult.java | 9 + .../ReservationOrderV4NextActionResult.java | 26 ++ .../ReservationOrderV4OverviewResult.java | 49 +++ .../ReservationOrderV4RoomSummaryResult.java | 17 ++ ...tionOrderV4TaskCardTimelineItemResult.java | 42 +++ ...ervationOrderV4TaskTimelineItemResult.java | 3 + .../ReservationFrontendQueryServiceImpl.java | 282 +++++++++++++++++- ...eservationFrontendQueryControllerTest.java | 119 ++++++++ 14 files changed, 659 insertions(+), 40 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4NextActionResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4OverviewResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4RoomSummaryResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskCardTimelineItemResult.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7288b25..d90a0e7 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-20 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口和 V4 业务审计查询 | -| 当前重点 | M002 V4 已完成订单列表 V4 继续处理入口字段和前端消费,`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;前端“继续处理”已按 V4 优先、旧任务回退跳转,订单列表待处理数量已改用 `open_work_item_count` 展示;V4 订单任务和 S10/S99 来源通知已补齐业务审计查询接口,前端可展示卡片确认、复核解阻和 ack 审计时间线;后续可做测试机联调、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口和 V4 业务审计查询 | +| 当前重点 | M002 V4 已完成订单列表 V4 继续处理入口字段和前端消费,`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;`GET /api/reservation/orders/{orderId}` 已补齐订单详情 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`,支撑订单总览页;V4 订单任务和 S10/S99 来源通知已补齐业务审计查询接口;后续可做前端订单详情 V4 化、测试机联调、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-business-audit-query` -- 状态:Done,V4 订单任务和来源通知已补齐业务审计查询接口:`GET /api/reservation/order-tasks/{orderTaskId}/audits` 返回卡片确认 / 复核解阻审计流水,`GET /api/reservation/source-notifications/{notificationId}/audits` 返回 S10/S99 ack 审计流水。两个接口均强制 Bearer 登录、`RESERVATION_AUDIT_READ` 和对象所属酒店访问权,并对返回快照做敏感字段脱敏。 -- 目标:让前端可在 V4 订单任务详情和 S10/S99 来源通知详情展示审计时间线。 -- 边界:本轮不新增审计表、不调整 V4 写操作、不接真实 OPERA / OHIP、不处理旧 V2/V3 审计展示重构。 +- 名称:`M002-V4-order-detail-overview-backend` +- 状态:Done,订单详情接口已补齐 V4 订单总览数据:`GET /api/reservation/orders/{orderId}` 返回 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`。`order_overview` 只从已确认 V4 卡片派生,未确认 AI 建议不作为订单事实;`next_v4_action` 复用订单列表下一步处理口径,前端仍跳 V4 订单任务详情处理。 +- 目标:支撑前端把订单详情页从旧任务处理页改成“订单总览 + 当前确认快照 + V4 任务时间线 + 下一步入口”。 +- 边界:本轮不改前端、不新增表、不改 V4 写操作、不接真实 OPERA / OHIP、不废弃旧 V2/V3 详情接口。 ## 2. 当前优先级 @@ -35,12 +35,12 @@ - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 - M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel,按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSON;Debug EML 和 AgentBus dispatch 在各自 include 开关与总开关同时启用时,会在调用 SuperAgent 前追加 `attachment_extractions[]`。AgentBus 生产链路默认关闭,测试机验证后再评估开启。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先、旧任务回退跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口。真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先、旧任务回退跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`。真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 当前通过数据库目录读取和派生;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,可优先进入测试机 V4 订单列表、V4 详情、V4 审计时间线前后端联调,或推进真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。 +- 后续如继续做 M002 V4,可优先让前端改造订单详情 V4 总览页并进行测试机联调,或推进真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 - M011 后续如需运营查询或长期追踪,可进入 CP4:设计 Excel 解析批次 / 行级持久化表;当前 CP3 只增强 SuperAgent 输入,不直接落订单、任务或长期解析历史。 diff --git a/docs/project/README.md b/docs/project/README.md index 338cef6..7320652 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -46,7 +46,7 @@ | `requirements/M002-order-task-workflow-v3.md` | 当前有效 | M002 订单任务主流程 V3,基于 2026-07-11 P0 冻结基线和 2026-07-12 P0.1 Parent Group 修订,记录 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed 边界。 | | `requirements/M002-task-field-control-contract-v1.md` | 当前有效 | M002 任务卡字段控件契约 V1,记录任务详情 `fields[]` 控件元数据、人工复核控件复用和前后端开发边界。 | | `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10/S99 和校验口径;后端已完成 V4 入站解析、持久化、查询、确认、复核和当前酒店数据库目录校验。 | -| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3-CP8 表结构、入站写入、查询、确认、复核和目录校验已落地状态;CP11 已完成 DB 目录与 lookup API,后续仍需前端页面、目录管理后台和真实 PMS 同步。 | +| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3-CP8 表结构、入站写入、查询、确认、复核和目录校验已落地状态;CP11 已完成 DB 目录与 lookup API,CP12 已完成前端 lookup 接入,CP13 已完成目录管理后台 CP1,CP14 已完成订单列表 V4 继续处理入口,CP15 已完成 V4 业务审计查询,CP15.1 已完成订单详情 V4 总览后端补齐;后续仍需前端订单详情 V4 化、测试机联调和真实 PMS / OPERA / OHIP 同步。 | | `requirements/M002-v4-real-catalog-lookup-api-design.md` | 当前有效 | M002 V4 真实目录与 Lookup API 设计及 CP11 / CP13 CP1 实现记录,记录 Account、Market、Source、Room Type、Rate Code 从固定种子导入数据库、前端 lookup API、目录管理后端接口、权限、缓存后置、PMS / OPERA / OHIP 同步后置和失败兜底。 | | `requirements/M002-v4-test-machine-smoke-checklist.md` | 当前有效 | M002 V4 测试机冒烟清单,覆盖登录、酒店权限、V4 工作台、订单任务详情、lookup、确认、复核解阻、S10/S99 ack、订单详情 V4 时间线和目录管理 CP1 排查点。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | @@ -94,6 +94,6 @@ - 接口暴露、权限、酒店隔离和审计边界以 `security-access-control-boundary.md` 为总检查清单;具体 SuperAgent / MCP / AgentBus 请求响应契约仍以 `integrations/` 下对应文档为准。 - AI-NSES 的通用标准以 `../import/reusable/ai-native-software-engineering-standard.md` 为复用来源;本项目采用方式以 `ai-native-adoption.md` 为准。 - M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 -- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,CP5 已落地工作台、订单任务和来源通知查询接口,CP6/CP7 已落地卡片确认、S10/S99 ack 和复核解阻,CP11 已落地 DB 目录与 lookup API;后续仍需前端页面模型、目录管理后台和真实 PMS 同步。 +- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,CP5 已落地工作台、订单任务和来源通知查询接口,CP6/CP7 已落地卡片确认、S10/S99 ack 和复核解阻,CP11 已落地 DB 目录与 lookup API,CP12 已落地前端 lookup 接入,CP13 已落地目录管理后台 CP1,CP14 已落地订单列表 V4 继续处理入口,CP15 已落地 V4 业务审计查询,CP15.1 已落地订单详情 V4 总览后端补齐;后续仍需前端订单详情 V4 化、测试机联调和真实 PMS / OPERA / OHIP 同步。 - 前端展示 / 编辑字段以 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 6ed768e..91cfca7 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -76,7 +76,7 @@ | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 | | `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` / `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 业务审计展示数据 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`;前端可在 V4 订单任务详情和 S10/S99 来源通知详情的“审计时间线”中调用。响应沿用旧审计行结构:`audit_id`、`actor_type`、`actor_id`、`action`、`reason`、`before_snapshot`、`after_snapshot`、`occurred_at`。快照已由后端脱敏,前端仍不要把未知 URL-like 字符串当附件或正文直渲。 | -| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要,此时旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都为空数组;旧 `tasks[]` 按后端队列顺序返回,前端不要自行按创建时间重排;V4 `v4_order_tasks[]` 按同订单 V4 订单任务来源时间正序返回;隐藏技术订单详情不可作为普通订单页打开。 | +| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取轻量摘要,此时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都为空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`;旧 `tasks[]` 按后端队列顺序返回,前端不要自行按创建时间重排;V4 `v4_order_tasks[]` 按同订单 V4 订单任务来源时间正序返回;隐藏技术订单详情不可作为普通订单页打开。 | | `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` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 最终确认任务 | 后端会做第一版字段校验,通过后进入 `READY`。 | @@ -106,7 +106,7 @@ | --- | --- | --- | | `GET /api/reservation/orders` | 补齐订单列表 V4 继续处理入口字段,并新增统一 open count 字段;前端展示已接入。 | `order_status` 不传时默认查询全部订单状态;`page_num` 从 1 开始;`page_size` 后端有最大值保护;`open_work_item_count` 是订单列表展示用统一待处理数量,开发阶段不考虑旧数据,第一版直接等于 `v4_open_order_task_count`;前端展示待处理数量时只读该字段,不自行计算旧任务数和 V4 数,也不使用旧 `open_task_count` 作为展示数量;旧 `open_task_count` 和 `next_processable_task_id` 继续保留用于 V2/V3 兼容与排查。V4 新增 `next_v4_order_task_id`、`next_v4_action_card_id`、`next_v4_action_type`、`next_v4_action_status`、`v4_open_order_task_count`;前端“继续处理”已按优先级实现:存在 `next_v4_order_task_id` 时跳 `/reservation/order-tasks/{next_v4_order_task_id}`,否则回退旧 `/reservation/tasks/{next_processable_task_id}`;两者都没有时展示无待处理状态。 | | `GET /api/reservation/tasks` | 补齐来源邮件会话摘要字段,并新增 `order_status` 查询参数。 | `order_status` 按任务所属订单状态过滤,支持 `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`;列表仍然只返回安全摘要,不返回正文、HTML、附件 URL 或 AI 原始 payload;点击邮件入口时使用 `source_message_id` 调会话详情。 | -| `GET /api/reservation/orders/{orderId}` | 补齐旧 `tasks[]` 来源邮件会话摘要字段,并新增 V4 `v4_order_tasks[]` 订单任务时间线。 | `include_tasks=false` 可只取订单摘要,此时 `tasks[]` 和 `v4_order_tasks[]` 都为空;旧 `tasks[]` 顺序由后端按订单队列返回,V4 `v4_order_tasks[]` 按 `source_received_at`、`source_message_id`、`order_context_index`、`created_at`、数字 ID 正序返回;前端不要自行重排。`include_source_summary` 第一版不作为前端裁剪字段的强约束,前端暂不要依赖它减少返回字段。 | +| `GET /api/reservation/orders/{orderId}` | 补齐旧 `tasks[]` 来源邮件会话摘要字段,并新增 V4 总览和 `v4_order_tasks[]` 订单任务时间线。 | `include_tasks=false` 可只取轻量摘要,此时 `tasks[]`、`v4_order_tasks[]` 和 `related_source_messages[]` 都为空,`order_overview` 为空快照,`next_v4_action.action_type=NONE`;旧 `tasks[]` 顺序由后端按订单队列返回,V4 `v4_order_tasks[]` 按 `source_received_at`、`source_message_id`、`order_context_index`、`created_at`、数字 ID 正序返回;前端不要自行重排。`include_source_summary` 第一版不作为前端裁剪字段的强约束,前端暂不要依赖它减少返回字段。 | | `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 解析失败。 | @@ -157,6 +157,10 @@ POST /api/auth/logout | `external_conversation_id` | 外部邮件会话 ID。 | 仅用于展示或调试,不作为当前会话详情接口路径参数。 | | `conversation_message_count` | 同一外部会话下的邮件数量。 | 用于提示用户打开的是整段会话,不是单封邮件。 | +订单详情已经补齐 V4 订单页总览字段:`order_overview`、`next_v4_action`、`related_source_messages[]` 和增强后的 `v4_order_tasks[].cards[]`。订单详情页应定位为订单视角总览,不要在订单详情页直接编辑、确认或复核任务卡;点击 `next_v4_action.order_task_id` 或时间线 `order_task_id` 后进入 `/reservation/order-tasks/{orderTaskId}` 对应的 V4 订单任务详情页处理。 + +`order_overview` 只从已确认 V4 卡片派生:Basic Information 未确认时 Account / Market / Source 为空;Room Information 未确认时日期、Rate Code、房型房量为空。前端不要把未确认卡片 display payload 反推成订单事实。 + 订单详情新增的 V4 `v4_order_tasks[]` 每项只返回订单任务安全摘要: | 字段 | 说明 | 前端使用方式 | @@ -165,6 +169,7 @@ POST /api/auth/logout | `order_ref` | SuperAgent V4 回调包内订单引用。 | 用于区分同一邮件里的多个订单上下文,不等同 PMS 永久订单号。 | | `order_task_status` | V4 订单任务状态,当前为 `OPEN` / `COMPLETED`。 | 展示处理状态,不要用于替代卡片级 `availability`。 | | `card_counts` | V4 卡片数量摘要。 | 展示待确认、待复核和已确认规模。 | +| `cards[]` | V4 任务卡安全摘要,只包含卡片 ID、类型、状态、复核状态、确认人、确认时间和最近活动时间。 | 用于订单页时间线和状态 chips;业务字段仍去 V4 订单任务详情读取。 | | `source_message_summary` | 来源邮件安全摘要。 | 不包含正文、HTML、附件 URL 或 AI 原始 payload;需要看原文时继续调用邮件会话详情接口。 | | `source_received_at` / `created_at` / `updated_at` / `latest_activity_at` | UTC 时间点。 | `latest_activity_at` 是 V4 订单任务及其卡片更新时间的最大值,可用于展示最近动作时间。 | diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index d2c315e..32c6519 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -30,7 +30,7 @@ | 接口 / 能力 | 当前后端状态 | 前端是否可直接接入 | 仍需后端处理 | | --- | --- | --- | --- | | `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 | -| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段和 V4 `v4_order_tasks[]` 时间线 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都返回空数组。 | +| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段、V4 总览和 V4 `v4_order_tasks[]` 时间线 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。 | | `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 当前 Controller 不接收 `hotel_id`;如后续多酒店隔离需要前端显式传酒店上下文,请后端补可选入参或确认按 taskId 全局唯一即可。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 | | `POST /api/reservation/tasks/{taskId}/confirm` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 | @@ -47,7 +47,7 @@ | `POST /api/system/reservation/demo-data` | 已完成 | 仅本地 / test 联调可用 | 默认关闭,必须后端配置访问口令;不能作为生产页面接口。 | | `POST /api/system/debug/eml-superagent-runs` | 已完成第一版 | 仅 dev/test Debug 页面可用 | 默认关闭,必须后端配置访问口令、阿里云 OSS 和 SuperAgent Open API;第一版只展示 SuperAgent 结果,不创建订单和任务;已能识别旧 S000/S999 和新结构化 S10/S99。 | | `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | -| `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 第一版不做独立接口;旧 S000/S999 已通过 `SOURCE_MESSAGE_ONLY` 任务展示,0711 P0 新 S10/S99 也继续复用任务列表 / 任务详情只读展示。 | +| `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 历史候选路径,当前不提供。旧 S000/S999 和 V3 S10/S99 兼容数据通过 `SOURCE_MESSAGE_ONLY` 任务展示;V4 S10/S99 新数据走 V4 工作台和 `/api/reservation/source-notifications/{notificationId}`,不要再请求本候选路径。 | | `GET /api/reservation/task-card-field-whitelist` | 未发现后端实现 | 不可以 | 若任务详情 `fields[]` 已补齐 3.0 元数据,可后置。 | | `GET /api/reservation/lookups/accounts` / `room-types` / `rate-codes` | M002 V4 CP11 已实现 | 可以 | 用于 V4 任务卡下拉 / 搜索选择;Bearer token + `RESERVATION_TASK_READ` + 酒店访问权;支持 `hotel_id`、`keyword`、`page_num`、`page_size`,第一版只返回 ACTIVE 目录。 | @@ -97,7 +97,7 @@ GET /api/reservation/tasks | --- | --- | --- | | `hotel_id` | 否 | 酒店 ID。单酒店阶段默认可为空,由后端按当前用户上下文或平台酒店表唯一 `ACTIVE` 酒店解析;显式传值时后端会校验访问权限。 | | `order_id` | 否 | 按订单过滤。 | -| `task_type` | 否 | 当前任务列表筛选只提供 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`、`SOURCE_MESSAGE_ONLY`;不再提供历史 `INFORMATIONAL_MESSAGE` 筛选项。0711 P0 结构化 S10/S99 复用 `SOURCE_MESSAGE_ONLY` 只读源邮件通知卡。 | +| `task_type` | 否 | 当前旧任务列表筛选只提供 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`、`SOURCE_MESSAGE_ONLY`;不再提供历史 `INFORMATIONAL_MESSAGE` 筛选项。旧 S000/S999 和 V3 S10/S99 兼容数据复用 `SOURCE_MESSAGE_ONLY` 只读源邮件通知卡;V4 S10/S99 新数据不进旧任务列表,走 V4 工作台和来源通知详情。 | | `task_status` | 否 | 任务状态过滤。 | | `task_subtype` | 否 | 任务卡 subtype 过滤。 | | `order_status` | 否 | 按任务所属订单状态过滤,支持 `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`;不传时保持当前行为。 | @@ -165,9 +165,9 @@ GET /api/reservation/tasks GET /api/reservation/orders/{orderId} ``` -当前状态:后端已按 P0 最小诉求实现第一版。接口返回订单摘要、旧 V2/V3 同订单任务时间线和 V4 订单任务时间线;`include_tasks=false` 时只返回订单摘要,旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都为空数组。旧任务时间线已补齐每个任务的来源邮件会话摘要。 +当前状态:后端已按 P0 最小诉求实现第一版,并在 M002 V4 CP15.1 补齐订单详情 V4 总览字段。接口返回订单摘要、V4 当前确认快照、V4 下一步处理入口、关联来源邮件摘要、旧 V2/V3 同订单任务时间线和 V4 订单任务时间线;`include_tasks=false` 时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。旧任务时间线已补齐每个任务的来源邮件会话摘要。 -订单详情低保真已确认沿用“订单摘要 + 同订单任务队列 + 当前任务卡 + OPERA 模拟操作 + 审计时间线”。同订单任务队列里的每个任务都需要自己的“查看邮件会话”入口,因为不同任务可能来自不同邮件或不同邮件会话。因此 `tasks[]` 中每个任务已补齐来源邮件会话字段。V4 新模型下,同一订单的多卡订单任务通过 `v4_order_tasks[]` 单独返回,前端点击后进入 V4 订单任务详情。 +订单详情页后续应定位为“订单总览 + 当前确认快照 + V4 任务时间线 + 下一步入口”,不是 V4 任务卡处理页。同订单任务队列里的每个任务都需要自己的“查看邮件会话”入口,因为不同任务可能来自不同邮件或不同邮件会话。因此 `tasks[]` 中每个任务已补齐来源邮件会话字段。V4 新模型下,同一订单的多卡订单任务通过 `v4_order_tasks[]` 单独返回,前端点击后进入 V4 订单任务详情。 建议入参: @@ -195,6 +195,45 @@ GET /api/reservation/orders/{orderId} "created_at": "2026-07-08T03:00:00Z", "updated_at": "2026-07-08T03:10:00Z" }, + "order_overview": { + "account_code": "QBD_TRAVEL", + "account_name": "Q.B.D. TRAVEL GROUP CO., LTD", + "market_code": "LEISURE", + "source_code": "TRAVEL_AGENT", + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "BAR", + "room_items": [ + { + "room_type_code": "RM1", + "room_count": 2 + } + ], + "trace_card_status": null, + "rooming_list_card_status": null, + "payment_card_status": "REVIEW_REQUIRED", + "latest_confirmed_at": "2026-07-08T04:20:00Z" + }, + "next_v4_action": { + "order_task_id": "40001", + "card_id": "41003", + "action_type": "REVIEW", + "action_status": "REVIEW_REQUIRED", + "open_order_task_count": 1 + }, + "related_source_messages": [ + { + "source_message_id": "30002", + "hotel_id": "HOTEL-TEST", + "external_message_id": "AAMk-example", + "external_conversation_id": "thread-20260708-002", + "subject": "Booking Update", + "sender_summary": "guest@example.com", + "received_at": "2026-07-08T04:00:00Z", + "source_sent_at": null, + "conversation_message_count": 2 + } + ], "tasks": [ { "task_id": "10001", @@ -231,6 +270,32 @@ GET /api/reservation/orders/{orderId} "review_required_count": 1, "confirmed_count": 0 }, + "cards": [ + { + "card_id": "41001", + "card_type": "SOURCE_MESSAGE_DISPLAY", + "event_type": null, + "source_event_index": 0, + "card_sort_order": 10, + "card_status": "READONLY", + "review_status": null, + "confirmed_by": null, + "confirmed_at": null, + "latest_activity_at": "2026-07-08T04:00:10Z" + }, + { + "card_id": "41003", + "card_type": "PAYMENT", + "event_type": "PAYMENT", + "source_event_index": 2, + "card_sort_order": 60, + "card_status": "REVIEW_REQUIRED", + "review_status": "PENDING", + "confirmed_by": null, + "confirmed_at": null, + "latest_activity_at": "2026-07-08T04:05:00Z" + } + ], "source_message_summary": { "source_message_id": "30002", "hotel_id": "HOTEL-TEST", @@ -263,11 +328,20 @@ GET /api/reservation/orders/{orderId} | `external_conversation_id` | 来源消息所属邮件会话 ID。 | | `conversation_message_count` | 会话内邮件数量。 | | `result_type` / `ai_task_type` / `route_code` / `system_process_category` | V3 路由展示字段,和任务列表字段语义一致。 | +| `order_overview` | V4 订单详情当前确认快照,只从已确认 V4 卡片派生;未确认 AI 建议不会进入这里。 | +| `order_overview.account_code` / `account_name` / `market_code` / `source_code` | 来自已确认 Basic Information 卡;为空表示 Basic Information 尚未确认或无可靠确认值。 | +| `order_overview.arrival_date` / `departure_date` / `rate_code` / `room_items[]` | 来自已确认 Room Information 卡;后出现的已确认卡会覆盖前面同字段。 | +| `order_overview.trace_card_status` / `rooming_list_card_status` / `payment_card_status` | 当前订单下对应业务卡最新状态,方便订单详情页展示是否还有待处理事项。 | +| `order_overview.latest_confirmed_at` | 当前订单 V4 卡片最近确认 UTC 时间。 | +| `next_v4_action` | 订单详情页下一步处理入口,口径与订单列表 V4 入口一致;前端点击后跳 `/reservation/order-tasks/{order_task_id}`。 | +| `next_v4_action.action_type` | `CONFIRM` / `REVIEW` / `NONE`。订单详情页不直接调用确认或复核接口,应进入 V4 订单任务详情页处理。 | +| `related_source_messages[]` | 当前订单 V4 订单任务关联来源邮件安全摘要去重列表;用于订单页邮件区,不包含正文、HTML 或附件 URL。 | | `v4_order_tasks[]` | V4 订单任务时间线数组。旧 `tasks[]` 继续保留;V4 时间线按 `source_received_at`、`source_message_id`、`order_context_index`、`created_at`、数字 ID 正序返回。 | | `v4_order_tasks[].order_task_id` | V4 订单任务 ID,字符串。 | | `v4_order_tasks[].order_ref` | V4 回调包内订单引用,不等同 PMS 永久订单号。 | | `v4_order_tasks[].order_task_status` | V4 订单任务状态,当前为 `OPEN` / `COMPLETED`。 | | `v4_order_tasks[].card_counts` | V4 任务卡数量摘要。 | +| `v4_order_tasks[].cards[]` | V4 订单任务下任务卡安全摘要,只返回状态、复核状态、确认人和确认时间,不返回业务 payload。 | | `v4_order_tasks[].source_message_summary` | V4 来源邮件安全摘要,不包含正文、HTML、附件 URL 或 AI 原始 payload。 | | `v4_order_tasks[].latest_activity_at` | V4 订单任务自身 `updated_at` 与其下卡片 `updated_at` 的最大 UTC 时间。 | @@ -410,7 +484,7 @@ POST /api/system/reservation/demo-data 仍建议后端确认: -- `GET /api/reservation/tasks` 是否会在结构化 S10/S99 行中稳定返回 `task_type=SOURCE_MESSAGE_ONLY`,或允许返回 `MESSAGE_NOTIFICATION` 并只依赖 `result_type/route_code/system_process_category`;前端当前两种都兼容。 +- `GET /api/reservation/tasks` 中旧 S000/S999 和 V3 S10/S99 兼容行是否稳定返回 `task_type=SOURCE_MESSAGE_ONLY`。V4 S10/S99 已不应从旧任务列表返回,应通过 V4 工作台和来源通知接口展示。 - `adapter_contract_error` / `unhandled_current_intent` 如果未来也作为独立列表行返回,请保持 `source_message_id` 可用,便于前端继续提供邮件会话入口。 - 同卡人工复核解阻成功后是否一定返回 `opera_operations[]`。当前文档写“两条 OPERA 模拟操作”,前端实现按实际返回刷新,不假设固定数量。 - Parent Group `manual_review.reason_code=target_object_unclear` 场景需要任务详情稳定透出 `context_used.parent_identity_candidates[]`。当前 `ReservationTaskDetailResult` 后端 DTO 仅透出 `manual_review`,前端已兼容顶层 `context_used.parent_identity_candidates[]` 或 `manual_review.context_used.parent_identity_candidates[]`,但若后端不透出 candidates,页面只能显示空态提示。 @@ -713,11 +787,11 @@ Content-Type: application/json - 如果后端已有更细的字段来源或适用场景元数据,可后续再扩展 `field_source`、`applicable_scenario`,不作为本轮 P0 阻塞项。 - 前端默认不需要为 `GET /api/reservation/orders`、`GET /api/reservation/tasks`、`GET /api/reservation/orders/{orderId}` 自动拼 `hotel_id`;如已接入酒店选择器,可以传当前选中酒店,后端会校验访问权限。当前 `GET /api/reservation/tasks/{taskId}` 以及任务写操作 Controller 不接收 `hotel_id`;第一版先按 ID 定位,后续多酒店隔离 / 权限方案统一补齐。 -## 9. S10/S99 源邮件只读通知卡与历史兼容 +## 9. S10/S99 源邮件只读通知与历史兼容 -当前状态:后端不提供独立 Message Notification 列表 / 详情接口。旧 SuperAgent 入口返回 `S000,source_message_id` 或 `S999,source_message_id` 时,后端会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务;0711 P0 结构化 `S10/S99` 也复用同一只读任务模型。前端已通过 `GET /api/reservation/tasks?task_type=SOURCE_MESSAGE_ONLY` 和 `GET /api/reservation/tasks/{taskId}` 展示;任务列表筛选只保留 `SOURCE_MESSAGE_ONLY`、`S10`、`S99`,不再提供 `S000`、`S999` 历史筛选项。 +当前状态:后端不提供历史候选的 `/api/reservation/message-notifications` 列表 / 详情接口。旧 SuperAgent 入口返回 `S000,source_message_id` 或 `S999,source_message_id` 时,后端会创建 `SOURCE_MESSAGE_ONLY` 只读特殊任务;V3 结构化 `S10/S99` 兼容路径也沿用该旧任务模型。V4 `route_code=S10/S99` 新数据已改为独立来源通知模型,通过 V4 工作台和 `/api/reservation/source-notifications/{notificationId}` 展示,并通过 `/api/reservation/source-notifications/{notificationId}/ack` 确认已读 / 已处理。 -0711 P0 新入口已迁移为结构化 `S10/S99`: +V3 入口兼容语义: - `S10`:`result_type=source_message_review_notification`,`route_code=S10`,表示未匹配当前支持的业务事件。 - `S99`:`result_type=source_message_review_notification`,`route_code=S99`,表示输入不足或无法形成业务素材包。 @@ -729,12 +803,13 @@ Content-Type: application/json 展示规则: - 旧 `task_type=SOURCE_MESSAGE_ONLY`、`task_subtype=S000/S999`:按只读源邮件通知卡展示。 -- 新 `route_code=S10/S99`:按只读源邮件通知卡展示。 -- 任务列表可见,订单列表不可见。 -- 任务详情只允许查看来源邮件、会话、附件和 SuperAgent 原始返回。 +- V3 `route_code=S10/S99` 兼容数据:按旧只读源邮件通知卡展示。 +- V4 `route_code=S10/S99` 新数据:按来源通知详情展示,可 ack,不通过旧任务详情处理。 +- 旧 `SOURCE_MESSAGE_ONLY` 任务列表可见,订单列表不可见;V4 来源通知在 V4 工作台可见,订单列表和订单详情不可见。 +- 旧任务详情只允许查看来源邮件、会话、附件和 SuperAgent 原始返回;V4 来源通知详情只允许查看来源邮件摘要、通知信息和 ack 状态。 - 任务详情通过 `source_message_only_result` 返回 `entry_result_code`、`entry_result_meaning`、`entry_result_description`、`entry_result_source_message_id`、`result_type`、`route_code`、`agent_assessment`、`notification`、S99 的入口 `manual_review` 和 `raw_answer`。 -- 不显示编辑、确认、人工转换、执行 OPERA 或重试 OPERA 按钮。 -- 不参与订单任务执行顺序阻塞。 +- 不显示编辑、人工转换、执行 OPERA 或重试 OPERA 按钮;V4 只显示 ack。 +- 不参与订单任务执行顺序阻塞,也不创建隐藏技术订单。 当前不建议新增路径: @@ -1112,9 +1187,9 @@ POST /api/reservation/tasks/{taskId}/order-binding - 订单列表、任务列表当前统一使用 `items + page` 分页结构;邮件会话详情不分页,返回同一会话全部邮件。 - 邮件会话详情接口已优先使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 - 任务详情 `fields[]` 已由后端直接透出 P0 需要的 3.0 元数据;独立字段白名单接口后置。 -- 独立 Message Notification 页面继续后置;旧 S000/S999 和新 S10/S99 都先在任务列表和任务详情展示。 +- 独立 Message Notification 页面继续后置;旧 S000/S999 和 V3 S10/S99 继续在旧任务列表和任务详情兼容展示,V4 S10/S99 走 V4 工作台和来源通知详情。 - 邮件会话全文读取的审计策略由后端内部处理;前端不保存原文读取 key。 -- `GET /api/reservation/tasks` 结构化 S10/S99 行的 `task_type` 返回值请后端最终确认:前端已兼容 `SOURCE_MESSAGE_ONLY` 和 `MESSAGE_NOTIFICATION`,但文档口径最好稳定一个。 +- `GET /api/reservation/tasks` 中旧 S000/S999 和 V3 S10/S99 兼容行的 `task_type` 返回值请后端最终确认;V4 S10/S99 不应再进入旧任务列表。 - `manual-review-resolutions` 成功响应中的 `opera_operations[]` 数量请后端最终确认;前端不写死两条,只按返回内容刷新展示。 - 系统管理菜单树增强接口已完成:`GET /api/admin/menus/tree`、`PUT /api/admin/menus/tree-order`。 - Manual Invoice 第一阶段的客户 / 联系人目录来源、模板初始文件、VAT 配置和生成记录是否必须落库,已在 M009 中列为开发前确认项。 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 f167ad9..d77014c 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 @@ -598,7 +598,16 @@ GET /api/reservation/orders/{orderId} 权限:RESERVATION_ORDER_READ ``` -用于订单详情页展示 V4 订单任务时间线,同时保留旧 `tasks[]`。新增字段为 `v4_order_tasks[]`,`include_tasks=false` 时 `tasks[]` 与 `v4_order_tasks[]` 都返回空数组。 +用于订单详情页展示 V4 订单总览和 V4 订单任务时间线,同时保留旧 `tasks[]`。M002 V4 CP15.1 已补齐 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`。`include_tasks=false` 时 `tasks[]`、`v4_order_tasks[]` 和 `related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。 + +`order_overview` 只从已确认 V4 卡片派生: + +- Basic Information 已确认后,返回 `account_code`、`account_name`、`market_code`、`source_code`; +- Room Information 已确认后,返回 `arrival_date`、`departure_date`、`rate_code`、`room_items[]`; +- Trace / Rooming List / Payment 返回对应最新卡片状态,方便订单详情展示待处理事项; +- 未确认 AI 建议不得进入 `order_overview`,避免把未处理内容展示成订单事实。 + +`next_v4_action` 使用和订单列表一致的下一步处理口径:Basic Information 优先;业务卡中 `REVIEW_REQUIRED` 优先于 `PENDING_CONFIRM`;无待处理卡时 `action_type=NONE`。订单详情页应使用该字段跳转 V4 订单任务详情页,不在订单详情页直接确认或复核。 `v4_order_tasks[]` 每项返回: @@ -606,6 +615,7 @@ GET /api/reservation/orders/{orderId} - `order_ref` - `order_task_status` - `card_counts` +- `cards[]` - `source_message_summary` - `source_received_at` - `created_at` @@ -617,6 +627,7 @@ GET /api/reservation/orders/{orderId} - 排序沿用 V4 Repository 的同订单顺序:`source_received_at`、`source_message_id`、`order_context_index`、`created_at`、数字 ID 正序。 - `source_message_summary` 只返回安全摘要,不返回邮件正文、HTML、附件 URL 或 AI 原始 payload;原文仍走 SourceMessage 会话接口。 - `latest_activity_at` 为 V4 订单任务自身 `updated_at` 与其下卡片 `updated_at` 的最大值。 +- `cards[]` 只返回卡片安全摘要,不返回业务字段 payload;订单详情页如需处理字段,必须跳转 V4 订单任务详情接口。 - 接口权限仍使用 `RESERVATION_ORDER_READ`,并按订单实际所属酒店校验访问权;V4 任务读取时继续以该订单酒店过滤,避免跨酒店脏数据泄露。 ### 12.6 订单列表 V4 继续处理入口 @@ -802,6 +813,7 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | M002-V4-CP13 | 目录管理后台 V1 | Account / Market / Source 管理,临时 Room Type / Rate Code 管理,目录维护权限和管理审计 | | M002-V4-CP14 | 订单列表 V4 继续处理入口 | 已完成:`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态和 open 数,前端可优先跳 V4 订单任务详情 | | M002-V4-CP15 | V4 业务审计查询 | 已完成:`GET /api/reservation/order-tasks/{orderTaskId}/audits` 和 `GET /api/reservation/source-notifications/{notificationId}/audits` 返回卡片确认、复核解阻和来源通知 ack 的脱敏审计流水 | +| M002-V4-CP15.1 | 订单详情 V4 化后端补齐 | 已完成:`GET /api/reservation/orders/{orderId}` 返回 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`,支撑订单总览页 | | M002-V4-CP16 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run、最后成功快照、失败重试和同步状态管理入口 | ## 17. 已确认设计决策 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index ab0ddf5..f002bb2 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -45,7 +45,7 @@ | --- | --- | --- | --- | --- | | `GET /api/reservation/tasks` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权;已返回 V4 继续处理入口和统一 open count 安全字段 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权;V4 入口字段只返回 order task / card ID、动作类型、状态和数量摘要;`open_work_item_count` 仅返回当前订单待处理数量摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload | -| `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权;已返回旧 `tasks[]` 和 V4 `v4_order_tasks[]` 安全摘要时间线 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权;V4 时间线读取按订单酒店过滤 | 只读查询默认不写业务审计;不得在 `v4_order_tasks[]` 返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload | +| `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权;已返回旧 `tasks[]`、V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[]` 安全摘要时间线 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权;V4 时间线读取按订单酒店过滤;`order_overview` 只能从已确认 V4 卡片派生,`next_v4_action` 只返回下一步处理 ID / 动作 / 状态 / 数量摘要,`related_source_messages[]` 只返回来源邮件安全摘要 | 只读查询默认不写业务审计;不得在 `order_overview` 或 `v4_order_tasks[].cards[]` 返回未确认 AI 建议、邮件正文、附件 URL、AI 原始 payload、display payload、confirmed payload 或来源通知原始 payload | | `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 | | `GET /api/reservation/workbench-items` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload;同来源时间下使用 `updated_at` / `created_at` / 数字 ID 稳定排序 | | `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload;`card_status` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderDetailResult.java index d51bfe2..14895fd 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderDetailResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderDetailResult.java @@ -7,12 +7,21 @@ import java.util.List; * 前端订单详情响应,包含订单摘要、旧任务时间线、V4 订单任务时间线和非阻塞警告。 * * @param order 订单摘要 + * @param orderOverview V4 当前订单快照摘要 + * @param nextV4Action V4 下一步处理入口 + * @param relatedSourceMessages 订单关联来源邮件摘要 * @param tasks 同订单旧任务时间线 * @param v4OrderTasks 同订单 V4 订单任务时间线 * @param warnings 当前无法提供的扩展信息或非阻塞提醒 */ public record ReservationOrderDetailResult( ReservationOrderSummaryResult order, + @JsonProperty("order_overview") + ReservationOrderV4OverviewResult orderOverview, + @JsonProperty("next_v4_action") + ReservationOrderV4NextActionResult nextV4Action, + @JsonProperty("related_source_messages") + List relatedSourceMessages, List tasks, @JsonProperty("v4_order_tasks") List v4OrderTasks, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4NextActionResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4NextActionResult.java new file mode 100644 index 0000000..339be09 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4NextActionResult.java @@ -0,0 +1,26 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 订单详情页 V4 下一步处理入口摘要。前端用它跳转订单任务详情,不在订单详情页直接处理卡片。 + * + * @param orderTaskId 下一步 V4 订单任务 ID + * @param cardId 下一步待处理任务卡 ID + * @param actionType 下一步动作类型,CONFIRM / REVIEW / NONE + * @param actionStatus 下一步卡片状态,PENDING_CONFIRM / REVIEW_REQUIRED + * @param openOrderTaskCount 当前订单下未完成 V4 订单任务数量 + */ +public record ReservationOrderV4NextActionResult( + @JsonProperty("order_task_id") + String orderTaskId, + @JsonProperty("card_id") + String cardId, + @JsonProperty("action_type") + String actionType, + @JsonProperty("action_status") + String actionStatus, + @JsonProperty("open_order_task_count") + Integer openOrderTaskCount +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4OverviewResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4OverviewResult.java new file mode 100644 index 0000000..662e34d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4OverviewResult.java @@ -0,0 +1,49 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * 订单详情页 V4 当前快照摘要。只从已确认 V4 卡片派生,不把未确认 AI 建议当作订单事实。 + * + * @param accountCode Account 目录 code + * @param accountName Account 展示名称 + * @param marketCode Market 目录 code + * @param sourceCode Source 目录 code + * @param arrivalDate 入住酒店本地日期 + * @param departureDate 离店酒店本地日期 + * @param rateCode Rate Code + * @param roomItems 已确认房型房量摘要 + * @param traceCardStatus Trace 卡片当前状态 + * @param roomingListCardStatus Rooming List 卡片当前状态 + * @param paymentCardStatus Payment 卡片当前状态 + * @param latestConfirmedAt 最近一次 V4 卡片确认 UTC 时间 + */ +public record ReservationOrderV4OverviewResult( + @JsonProperty("account_code") + String accountCode, + @JsonProperty("account_name") + String accountName, + @JsonProperty("market_code") + String marketCode, + @JsonProperty("source_code") + String sourceCode, + @JsonProperty("arrival_date") + String arrivalDate, + @JsonProperty("departure_date") + String departureDate, + @JsonProperty("rate_code") + String rateCode, + @JsonProperty("room_items") + List roomItems, + @JsonProperty("trace_card_status") + String traceCardStatus, + @JsonProperty("rooming_list_card_status") + String roomingListCardStatus, + @JsonProperty("payment_card_status") + String paymentCardStatus, + @JsonProperty("latest_confirmed_at") + OffsetDateTime latestConfirmedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4RoomSummaryResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4RoomSummaryResult.java new file mode 100644 index 0000000..33097ea --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4RoomSummaryResult.java @@ -0,0 +1,17 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 订单详情页 V4 房型房量摘要。 + * + * @param roomTypeCode 房型目录 code + * @param roomCount 房量 + */ +public record ReservationOrderV4RoomSummaryResult( + @JsonProperty("room_type_code") + String roomTypeCode, + @JsonProperty("room_count") + Integer roomCount +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskCardTimelineItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskCardTimelineItemResult.java new file mode 100644 index 0000000..698879f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskCardTimelineItemResult.java @@ -0,0 +1,42 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * 订单详情页 V4 时间线中的任务卡摘要。只返回状态和确认信息,不返回卡片业务字段。 + * + * @param cardId V4 任务卡 ID + * @param cardType 卡片类型 + * @param eventType 业务事件类型 + * @param sourceEventIndex 来源事件序号 + * @param cardSortOrder 卡片排序号 + * @param cardStatus 卡片状态 + * @param reviewStatus 复核状态 + * @param confirmedBy 确认人 + * @param confirmedAt 确认 UTC 时间 + * @param latestActivityAt 卡片最新活动 UTC 时间 + */ +public record ReservationOrderV4TaskCardTimelineItemResult( + @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("confirmed_by") + String confirmedBy, + @JsonProperty("confirmed_at") + OffsetDateTime confirmedAt, + @JsonProperty("latest_activity_at") + OffsetDateTime latestActivityAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskTimelineItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskTimelineItemResult.java index 4b1cb87..4caec6b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskTimelineItemResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderV4TaskTimelineItemResult.java @@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; import java.time.OffsetDateTime; +import java.util.List; /** * 订单详情页 V4 订单任务时间线单项。用于旧订单详情页补充展示 V4 多卡任务摘要。 @@ -10,6 +11,7 @@ import java.time.OffsetDateTime; * @param orderRef V4 回调包内订单引用 * @param orderTaskStatus V4 订单任务状态 * @param cardCounts V4 卡片数量摘要 + * @param cards V4 任务卡时间线摘要 * @param sourceMessageSummary 来源邮件安全摘要 * @param sourceReceivedAt 来源邮件接收 UTC 时间 * @param createdAt V4 订单任务创建 UTC 时间 @@ -25,6 +27,7 @@ public record ReservationOrderV4TaskTimelineItemResult( String orderTaskStatus, @JsonProperty("card_counts") ReservationV4CardCountsResult cardCounts, + List cards, @JsonProperty("source_message_summary") ReservationV4SourceMessageSummaryResult sourceMessageSummary, @JsonProperty("source_received_at") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java index eebe269..93a40fb 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java @@ -25,6 +25,10 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderLis import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderSummaryResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderTaskTimelineItemResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4NextActionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4OverviewResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4RoomSummaryResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4TaskCardTimelineItemResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4TaskTimelineItemResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult; @@ -35,6 +39,10 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4Source import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.util.LinkedHashMap; @@ -64,6 +72,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ private final SourceMessageQueryService sourceMessageQueryService; private final ReservationTaskAvailabilityResolver availabilityResolver; private final HotelContextService hotelContextService; + private final ObjectMapper objectMapper; /** * 注入持久化边界、SourceMessage 安全摘要服务和可处理状态解析器。 @@ -73,12 +82,14 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ ReservationV4WorkflowRepository v4WorkflowRepository, SourceMessageQueryService sourceMessageQueryService, ReservationTaskAvailabilityResolver availabilityResolver, - HotelContextService hotelContextService) { + HotelContextService hotelContextService, + ObjectMapper objectMapper) { this.workflowRepository = workflowRepository; this.v4WorkflowRepository = v4WorkflowRepository; this.sourceMessageQueryService = sourceMessageQueryService; this.availabilityResolver = availabilityResolver; this.hotelContextService = hotelContextService; + this.objectMapper = objectMapper; } /** @@ -190,10 +201,19 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ availabilityOrReadOnly(task, availabilityByTaskId), sourceContextsById.get(task.sourceMessageId()))) .toList(); - List v4OrderTasks = Boolean.FALSE.equals(includeTasks) - ? List.of() - : findV4OrderTaskTimeline(orderHotelId, order.id()); - return new ReservationOrderDetailResult(toOrderSummary(order), tasks, v4OrderTasks, List.of()); + V4OrderDetailContext v4Context = Boolean.FALSE.equals(includeTasks) + ? V4OrderDetailContext.empty() + : findV4OrderDetailContext(orderHotelId, order.id()); + List v4OrderTasks = toV4TimelineItems(v4Context); + V4OrderListNextAction v4NextAction = toV4OrderListNextAction(v4Context.orderTasks(), v4Context.cardsByOrderTaskId()); + return new ReservationOrderDetailResult( + toOrderSummary(order), + toV4OrderOverview(v4Context.orderTasks(), v4Context.cardsByOrderTaskId()), + toV4NextActionResult(v4NextAction), + relatedV4SourceMessages(v4Context), + tasks, + v4OrderTasks, + List.of()); } /** @@ -328,12 +348,12 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ /** * 查询订单详情页 V4 订单任务时间线,复用当前订单酒店作为对象级隔离边界。 */ - private List findV4OrderTaskTimeline(String hotelId, Long orderId) { + private V4OrderDetailContext findV4OrderDetailContext(String hotelId, Long orderId) { List orderTasks = v4WorkflowRepository.findOrderTasksByOrderIds( hotelId, List.of(orderId)); if (orderTasks.isEmpty()) { - return List.of(); + return V4OrderDetailContext.empty(); } Map> cardsByOrderTaskId = findV4TaskCardsByOrderTaskId( hotelId, @@ -341,11 +361,18 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ Map sourceSummariesById = findV4SourceSummariesById( hotelId, orderTasks.stream().map(ReservationV4OrderTaskSnapshot::sourceMessageId).toList()); - return orderTasks.stream() + return new V4OrderDetailContext(orderTasks, cardsByOrderTaskId, sourceSummariesById); + } + + /** + * 转换订单详情页 V4 时间线列表。 + */ + private List toV4TimelineItems(V4OrderDetailContext context) { + return context.orderTasks().stream() .map(orderTask -> toV4TimelineItem( orderTask, - cardsByOrderTaskId.getOrDefault(orderTask.id(), List.of()), - sourceSummariesById.get(orderTask.sourceMessageId()))) + context.cardsByOrderTaskId().getOrDefault(orderTask.id(), List.of()), + context.sourceSummariesById().get(orderTask.sourceMessageId()))) .toList(); } @@ -511,6 +538,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ orderTask.orderRef(), orderTask.orderTaskStatus(), v4CardCounts(cards), + toV4CardTimelineItems(cards), sourceSummary, UtcTimeFormatter.toUtcOffsetDateTime(orderTask.sourceReceivedAt()), UtcTimeFormatter.toUtcOffsetDateTime(orderTask.createdAt()), @@ -518,6 +546,209 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ UtcTimeFormatter.toUtcOffsetDateTime(latestV4ActivityAt(orderTask, cards))); } + /** + * 转换 V4 任务卡为订单详情时间线摘要,不返回业务字段 payload。 + */ + private List toV4CardTimelineItems( + List cards) { + return (cards == null ? List.of() : cards).stream() + .map(card -> new ReservationOrderV4TaskCardTimelineItemResult( + card.id().toString(), + card.cardType(), + card.eventType(), + card.sourceEventIndex(), + card.cardSortOrder(), + card.cardStatus(), + card.reviewStatus(), + card.confirmedBy(), + UtcTimeFormatter.toUtcOffsetDateTime(card.confirmedAt()), + UtcTimeFormatter.toUtcOffsetDateTime(card.updatedAt() == null + ? card.createdAt() + : card.updatedAt()))) + .toList(); + } + + /** + * 从已确认 V4 卡片中派生订单详情当前快照;未确认 AI 建议不进入订单事实展示。 + */ + private ReservationOrderV4OverviewResult toV4OrderOverview( + List orderTasks, + Map> cardsByOrderTaskId) { + V4OrderOverviewDraft draft = new V4OrderOverviewDraft(); + for (ReservationV4OrderTaskSnapshot orderTask : orderTasks == null + ? List.of() + : orderTasks) { + for (ReservationV4TaskCardSnapshot card : cardsByOrderTaskId.getOrDefault(orderTask.id(), List.of())) { + applyV4CardStatus(draft, card); + applyConfirmedV4CardSnapshot(draft, card); + } + } + return new ReservationOrderV4OverviewResult( + draft.accountCode, + draft.accountName, + draft.marketCode, + draft.sourceCode, + draft.arrivalDate, + draft.departureDate, + draft.rateCode, + draft.roomItems, + draft.traceCardStatus, + draft.roomingListCardStatus, + draft.paymentCardStatus, + UtcTimeFormatter.toUtcOffsetDateTime(draft.latestConfirmedAt)); + } + + /** + * 提取任务卡状态到订单详情总览,帮助前端展示订单下 Trace / Rooming List / Payment 是否仍待处理。 + */ + private void applyV4CardStatus(V4OrderOverviewDraft draft, ReservationV4TaskCardSnapshot card) { + if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) { + draft.traceCardStatus = card.cardStatus(); + } else if (ReservationV4CardType.ROOMING_LIST.name().equals(card.cardType())) { + draft.roomingListCardStatus = card.cardStatus(); + } else if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) { + draft.paymentCardStatus = card.cardStatus(); + } + } + + /** + * 从单张已确认 V4 卡片抽取订单总览字段,后出现的确认卡覆盖先前同字段。 + */ + private void applyConfirmedV4CardSnapshot(V4OrderOverviewDraft draft, ReservationV4TaskCardSnapshot card) { + if (!ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus()) + || trimToNull(card.confirmedPayloadJson()) == null) { + return; + } + JsonNode payload = readJsonOrEmpty(card.confirmedPayloadJson()); + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + JsonNode basicInformation = objectOrSelf(payload, "basic_information"); + draft.accountCode = coalesceText(basicInformation, "account_code", draft.accountCode); + draft.accountName = coalesceText(basicInformation, "account_name", draft.accountName); + draft.marketCode = coalesceText(basicInformation, "market_code", draft.marketCode); + draft.sourceCode = coalesceText(basicInformation, "source_code", draft.sourceCode); + } else if (ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())) { + JsonNode businessFields = businessFields(payload); + draft.arrivalDate = coalesceText(businessFields, "arrival_date", draft.arrivalDate); + draft.departureDate = coalesceText(businessFields, "departure_date", draft.departureDate); + draft.rateCode = coalesceText(businessFields, "rate_code", draft.rateCode); + if (businessFields.path("room_items").isArray()) { + draft.roomItems = roomSummaries(businessFields.path("room_items")); + } + } + if (card.confirmedAt() != null + && (draft.latestConfirmedAt == null || card.confirmedAt().isAfter(draft.latestConfirmedAt))) { + draft.latestConfirmedAt = card.confirmedAt(); + } + } + + /** + * 转换订单详情页 V4 下一步入口结果。 + */ + private ReservationOrderV4NextActionResult toV4NextActionResult(V4OrderListNextAction action) { + V4OrderListNextAction safeAction = action == null ? V4OrderListNextAction.none() : action; + return new ReservationOrderV4NextActionResult( + safeAction.orderTaskId(), + safeAction.cardId(), + safeAction.actionType(), + safeAction.actionStatus(), + safeAction.openOrderTaskCount()); + } + + /** + * 生成订单关联来源邮件列表,按 V4 订单任务时间线去重。 + */ + private List relatedV4SourceMessages(V4OrderDetailContext context) { + Map related = new LinkedHashMap<>(); + for (ReservationV4OrderTaskSnapshot orderTask : context.orderTasks()) { + ReservationV4SourceMessageSummaryResult summary = context.sourceSummariesById() + .get(orderTask.sourceMessageId()); + if (summary != null && summary.sourceMessageId() != null) { + related.putIfAbsent(summary.sourceMessageId(), summary); + } + } + return List.copyOf(related.values()); + } + + /** + * 读取业务字段主体;Update Booking 的 after 结构优先作为订单当前快照来源。 + */ + private JsonNode businessFields(JsonNode payload) { + JsonNode businessFields = objectOrSelf(payload, "business_fields"); + JsonNode after = businessFields.path("after"); + return after.isObject() ? after : businessFields; + } + + /** + * 读取对象字段;字段不存在或非对象时返回原节点,兼容测试和早期确认 payload。 + */ + private JsonNode objectOrSelf(JsonNode node, String fieldName) { + if (node == null || node.isMissingNode() || node.isNull()) { + return objectMapper.createObjectNode(); + } + JsonNode child = node.path(fieldName); + return child.isObject() ? child : node; + } + + /** + * JSON 字符串解析兜底,避免脏历史数据让订单详情整体失败。 + */ + private JsonNode readJsonOrEmpty(String json) { + if (trimToNull(json) == null) { + return objectMapper.createObjectNode(); + } + try { + return objectMapper.readTree(json); + } catch (JsonProcessingException ex) { + return objectMapper.createObjectNode(); + } + } + + /** + * 读取文本字段,空值保留既有快照值。 + */ + private String coalesceText(JsonNode node, String fieldName, String fallback) { + String value = trimToNull(node.path(fieldName).asText(null)); + return value == null ? fallback : value; + } + + /** + * 转换已确认 room_items[] 为订单页安全摘要。 + */ + private List roomSummaries(JsonNode roomItemsNode) { + List roomItems = new ArrayList<>(); + for (JsonNode roomItem : roomItemsNode) { + String roomTypeCode = coalesceText(roomItem, "room_type_code", null); + if (roomTypeCode == null) { + roomTypeCode = coalesceText(roomItem, "pms_room_type_code", null); + } + Integer roomCount = integerAt(roomItem, "room_count"); + if (roomCount == null) { + roomCount = integerAt(roomItem, "room_quantity"); + } + roomItems.add(new ReservationOrderV4RoomSummaryResult(roomTypeCode, roomCount)); + } + return List.copyOf(roomItems); + } + + /** + * 宽松读取整数字段,兼容数字和字符串数字。 + */ + private Integer integerAt(JsonNode node, String fieldName) { + JsonNode value = node.path(fieldName); + if (value.isInt() || value.isLong()) { + return value.asInt(); + } + String text = trimToNull(value.asText(null)); + if (text == null) { + return null; + } + try { + return Integer.valueOf(text); + } catch (NumberFormatException ex) { + return null; + } + } + /** * 统计 V4 订单任务下各状态卡片数量,供订单详情时间线轻量展示。 */ @@ -974,6 +1205,37 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ Long conversationMessageCount) { } + /** + * 订单详情页 V4 聚合上下文,避免同一个接口为时间线、总览和下一步入口重复查库。 + */ + private record V4OrderDetailContext( + List orderTasks, + Map> cardsByOrderTaskId, + Map sourceSummariesById) { + + private static V4OrderDetailContext empty() { + return new V4OrderDetailContext(List.of(), Map.of(), Map.of()); + } + } + + /** + * V4 订单总览派生草稿。仅在 Service 内部按时间线顺序覆盖字段,不向外暴露。 + */ + private static final class V4OrderOverviewDraft { + private String accountCode; + private String accountName; + private String marketCode; + private String sourceCode; + private String arrivalDate; + private String departureDate; + private String rateCode; + private List roomItems = List.of(); + private String traceCardStatus; + private String roomingListCardStatus; + private String paymentCardStatus; + private LocalDateTime latestConfirmedAt; + } + /** * 订单列表中一个本地订单对应的 V4 下一步处理摘要。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java index 947baa2..fa519b9 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -512,6 +512,111 @@ class ReservationFrontendQueryControllerTest { .andExpect(jsonPath("$.v4_order_tasks[1].source_received_at").value("2026-07-08T09:00:00Z")); } + @Test + void shouldReturnOrderDetailWithV4OverviewNextActionAndCardTimeline() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage( + "mail-frontend-order-detail-v4-overview-001", + "Frontend V4 Overview", + Instant.parse("2026-07-08T11:00:00Z")); + Long orderId = 930000000000005001L; + insertActiveGroupOrder(orderId, source.inboxId(), "GRP-FRONTEND-V4-OVERVIEW-001"); + ReservationV4OrderTaskSnapshot orderTask = insertV4OrderTask( + 930000000000005101L, + source, + orderId, + "order-overview", + "GRP-FRONTEND-V4-OVERVIEW-001", + Instant.parse("2026-07-08T11:00:00Z"), + HOTEL_ID); + insertV4TaskCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10, + ReservationV4CardStatus.READONLY.name(), null, "{}"); + ReservationV4TaskCardSnapshot basicCard = insertV4TaskCard( + orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, + ReservationV4CardStatus.CONFIRMED.name(), null, """ + {"basic_information":{"account_code":"QBD_TRAVEL"}} + """); + confirmV4TaskCard(basicCard.id(), """ + { + "basic_information": { + "account_code": "QBD_TRAVEL", + "account_name": "Q.B.D. TRAVEL GROUP CO., LTD", + "market_code": "LEISURE", + "source_code": "TRAVEL_AGENT" + } + } + """, "frontend-query-admin", Instant.parse("2026-07-08T11:10:00Z")); + ReservationV4TaskCardSnapshot roomCard = insertV4TaskCard( + orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, + ReservationV4CardStatus.CONFIRMED.name(), null, "{}"); + confirmV4TaskCard(roomCard.id(), """ + { + "business_fields": { + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "BAR", + "room_items": [ + {"room_type_code": "RM1", "room_count": 2}, + {"room_type_code": "RM2", "room_count": 1} + ] + } + } + """, "frontend-query-admin", Instant.parse("2026-07-08T11:20:00Z")); + insertV4TaskCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "UPDATE_BOOKING", 2, 40, + ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """ + { + "business_fields": { + "arrival_date": "2099-01-01", + "departure_date": "2099-01-02", + "rate_code": "SHOULD_NOT_BE_ORDER_FACT", + "room_items": [ + {"room_type_code": "SHOULD_NOT_APPEAR", "room_count": 99} + ] + } + } + """); + ReservationV4TaskCardSnapshot paymentCard = insertV4TaskCard( + orderTask, ReservationV4CardType.PAYMENT.name(), "PAYMENT", 3, 60, + ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", "{}"); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId) + .param("hotel_id", HOTEL_ID) + .param("include_tasks", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_overview.account_code").value("QBD_TRAVEL")) + .andExpect(jsonPath("$.order_overview.account_name").value("Q.B.D. TRAVEL GROUP CO., LTD")) + .andExpect(jsonPath("$.order_overview.market_code").value("LEISURE")) + .andExpect(jsonPath("$.order_overview.source_code").value("TRAVEL_AGENT")) + .andExpect(jsonPath("$.order_overview.arrival_date").value("2026-07-26")) + .andExpect(jsonPath("$.order_overview.departure_date").value("2026-07-29")) + .andExpect(jsonPath("$.order_overview.rate_code").value("BAR")) + .andExpect(jsonPath("$.order_overview.room_items.length()").value(2)) + .andExpect(jsonPath("$.order_overview.room_items[0].room_type_code").value("RM1")) + .andExpect(jsonPath("$.order_overview.room_items[0].room_count").value(2)) + .andExpect(jsonPath("$.order_overview.room_items[?(@.room_type_code=='SHOULD_NOT_APPEAR')]") + .isEmpty()) + .andExpect(jsonPath("$.order_overview.payment_card_status").value("REVIEW_REQUIRED")) + .andExpect(jsonPath("$.order_overview.latest_confirmed_at").value("2026-07-08T11:20:00Z")) + .andExpect(jsonPath("$.next_v4_action.order_task_id").value(orderTask.id().toString())) + .andExpect(jsonPath("$.next_v4_action.card_id").value(paymentCard.id().toString())) + .andExpect(jsonPath("$.next_v4_action.action_type").value("REVIEW")) + .andExpect(jsonPath("$.next_v4_action.action_status").value("REVIEW_REQUIRED")) + .andExpect(jsonPath("$.next_v4_action.open_order_task_count").value(1)) + .andExpect(jsonPath("$.related_source_messages.length()").value(1)) + .andExpect(jsonPath("$.related_source_messages[0].source_message_id").value(source.inboxId().toString())) + .andExpect(jsonPath("$.v4_order_tasks[0].cards.length()").value(5)) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].card_id").value(basicCard.id().toString())) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].card_type").value("BASIC_INFORMATION")) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].card_status").value("CONFIRMED")) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].confirmed_by").value("frontend-query-admin")) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].confirmed_at").value("2026-07-08T11:10:00Z")) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].ai_payload_json").doesNotExist()) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].display_payload_json").doesNotExist()) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[1].confirmed_payload_json").doesNotExist()) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[4].card_id").value(paymentCard.id().toString())) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[4].card_status").value("REVIEW_REQUIRED")) + .andExpect(jsonPath("$.v4_order_tasks[0].cards[4].review_status").value("PENDING")); + } + @Test void shouldHideV4OrderTaskTimelineWhenIncludeTasksFalseAndIgnoreCrossHotelRows() throws Exception { SourceMessageCaptureResult source = captureSourceMessage( @@ -853,6 +958,20 @@ class ReservationFrontendQueryControllerTest { """, Timestamp.valueOf(LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC)), orderTaskId, cardSortOrder); } + private void confirmV4TaskCard(Long cardId, String confirmedPayloadJson, String confirmedBy, Instant confirmedAt) { + jdbcTemplate.update(""" + UPDATE workflow_reservation_v4_task_card + SET confirmed_payload_json = ?, + confirmed_by = ?, + confirmed_at = ?, + updated_at = ? + WHERE id = ? + """, confirmedPayloadJson, confirmedBy, + Timestamp.valueOf(LocalDateTime.ofInstant(confirmedAt, ZoneOffset.UTC)), + Timestamp.valueOf(LocalDateTime.ofInstant(confirmedAt, ZoneOffset.UTC)), + cardId); + } + private ReservationV4OrderTaskSnapshot insertV4OrderTask( Long aiBatchId, SourceMessageCaptureResult source,