补齐订单详情V4任务时间线
This commit is contained in:
@@ -4,15 +4,15 @@
|
||||
| --- | --- |
|
||||
| 最近更新 | 2026-07-19 |
|
||||
| 当前分支 | `feature/huangting` |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻和目录校验并行 |
|
||||
| 当前重点 | M002 V4 CP8 已开放固定种子目录校验、V4 卡片 fields[] 字段白名单、确认写入白名单收口和嵌套目录校验;后续准备做 V4 前端页面或真实目录 / PMS lookup |
|
||||
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验和订单详情时间线补齐并行 |
|
||||
| 当前重点 | M002 V4 已在旧订单详情接口补齐 `v4_order_tasks[]` 订单任务时间线,保留旧 `tasks[]` 兼容;后续准备做 V4 前端页面或真实目录 / PMS lookup |
|
||||
|
||||
## 1. 当前 Checkpoint
|
||||
|
||||
- 名称:`M002-V4-CP8-controlled-directory-and-card-fields-whitelist-v1`
|
||||
- 状态:Done,已实现 V4 Account 固定目录校验、Market / Source 派生、Room Type / Rate Code 第一版固定种子校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验。
|
||||
- 目标:让 V4 入站、确认和复核写操作具备第一版目录边界,并让前端从 `GET /api/reservation/order-tasks/{orderTaskId}` 的卡片 `fields[]` 获取可展示 / 可编辑字段。
|
||||
- 边界:CP8 不实现真实 PMS 目录、Rate Code 配置中心、通用 lookup API、前端页面、普通任务任意切换订单、真实 OPERA / OHIP 和历史数据迁移。
|
||||
- 名称:`M002-V4-order-detail-timeline-completion-v1`
|
||||
- 状态:Done,已在 `GET /api/reservation/orders/{orderId}` 响应中新增 V4 `v4_order_tasks[]` 订单任务时间线,保留旧 `tasks[]` 兼容,并按订单酒店过滤 V4 数据。
|
||||
- 目标:让订单详情页在旧 V2/V3 任务时间线之外,可以看到同订单下 V4 order task 摘要、卡片数量、来源邮件安全摘要和最新活动时间。
|
||||
- 边界:本轮不做前端页面、不做真实 OPERA / OHIP、不做普通任务切换订单、不废弃旧 V2/V3 接口、不接真实目录 lookup。
|
||||
|
||||
## 2. 当前优先级
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。
|
||||
- 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。
|
||||
- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。
|
||||
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成固定种子目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验。V4 前端页面仍未完成代码实现。
|
||||
- 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[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验。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
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会做 Account / Room Type / Rate Code 第一版目录校验,嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
|
||||
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 |
|
||||
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 |
|
||||
| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 |
|
||||
| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要,此时旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都为空数组;旧 `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`。 |
|
||||
@@ -91,7 +91,7 @@
|
||||
| --- | --- | --- |
|
||||
| `GET /api/reservation/orders` | 新增订单列表接口。 | `order_status` 不传时默认查询全部订单状态;`page_num` 从 1 开始;`page_size` 后端有最大值保护;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;`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[]` 每条任务的来源邮件会话摘要字段。 | `include_tasks=false` 可只取订单摘要;时间线顺序由后端按订单队列返回,前端不要自行按创建时间重排。`include_source_summary` 第一版不作为前端裁剪字段的强约束,前端暂不要依赖它减少返回字段。 |
|
||||
| `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/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 解析失败。 |
|
||||
@@ -140,6 +140,17 @@ POST /api/auth/logout
|
||||
| `external_conversation_id` | 外部邮件会话 ID。 | 仅用于展示或调试,不作为当前会话详情接口路径参数。 |
|
||||
| `conversation_message_count` | 同一外部会话下的邮件数量。 | 用于提示用户打开的是整段会话,不是单封邮件。 |
|
||||
|
||||
订单详情新增的 V4 `v4_order_tasks[]` 每项只返回订单任务安全摘要:
|
||||
|
||||
| 字段 | 说明 | 前端使用方式 |
|
||||
| --- | --- | --- |
|
||||
| `order_task_id` | V4 订单任务 ID,字符串。 | 点击 V4 时间线项时跳转 `/api/reservation/order-tasks/{orderTaskId}` 对应的前端详情页。 |
|
||||
| `order_ref` | SuperAgent V4 回调包内订单引用。 | 用于区分同一邮件里的多个订单上下文,不等同 PMS 永久订单号。 |
|
||||
| `order_task_status` | V4 订单任务状态,当前为 `OPEN` / `COMPLETED`。 | 展示处理状态,不要用于替代卡片级 `availability`。 |
|
||||
| `card_counts` | V4 卡片数量摘要。 | 展示待确认、待复核和已确认规模。 |
|
||||
| `source_message_summary` | 来源邮件安全摘要。 | 不包含正文、HTML、附件 URL 或 AI 原始 payload;需要看原文时继续调用邮件会话详情接口。 |
|
||||
| `source_received_at` / `created_at` / `updated_at` / `latest_activity_at` | UTC 时间点。 | `latest_activity_at` 是 V4 订单任务及其卡片更新时间的最大值,可用于展示最近动作时间。 |
|
||||
|
||||
### 5.4 邮件会话详情接入注意
|
||||
|
||||
- `GET /api/source-messages/{sourceMessageId}/conversation` 只接收路径参数 `sourceMessageId`;第一版不接收 `hotelId`、`includeBody`、`includeRelated`;请求必须带 `Authorization: Bearer <access_token>`,且当前用户需要同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | 订单列表接口 `GET /api/reservation/orders` | 订单列表页、首页工作台 | 已完成第一版 |
|
||||
| P0 | 任务列表接口 `GET /api/reservation/tasks` | 任务列表菜单、订单详情任务入口 | 已完成第一版,已补来源邮件会话字段和 `order_status` 筛选 |
|
||||
| P0 | 订单详情接口 `GET /api/reservation/orders/{orderId}` | 订单详情页 | 已完成第一版,已补 `tasks[]` 每个任务的来源邮件会话字段 |
|
||||
| P0 | 订单详情接口 `GET /api/reservation/orders/{orderId}` | 订单详情页 | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段和 V4 `v4_order_tasks[]` 时间线 |
|
||||
| P0 | 任务详情读取接口 `GET /api/reservation/tasks/{taskId}` | 任务详情页动态渲染 | 已完成第一版,已补来源邮件字段和 3.0 字段元数据 |
|
||||
| P0 | 任务详情操作接口 | 任务详情保存、确认、OPERA、审计 | 已完成;前端可直接接入 |
|
||||
| P0 | 邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation` | 邮件会话详情页 | 已完成第一版 |
|
||||
@@ -29,7 +29,7 @@
|
||||
| 接口 / 能力 | 当前后端状态 | 前端是否可直接接入 | 仍需后端处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 |
|
||||
| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补 `tasks[]` 来源邮件会话字段 | 可以 | 暂无。 |
|
||||
| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段和 V4 `v4_order_tasks[]` 时间线 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都返回空数组。 |
|
||||
| `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`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 |
|
||||
@@ -163,9 +163,9 @@ GET /api/reservation/tasks
|
||||
GET /api/reservation/orders/{orderId}
|
||||
```
|
||||
|
||||
当前状态:后端已按 P0 最小诉求实现第一版。接口返回订单摘要和同订单任务时间线;`include_tasks=false` 时只返回订单摘要。任务时间线已补齐每个任务的来源邮件会话摘要。
|
||||
当前状态:后端已按 P0 最小诉求实现第一版。接口返回订单摘要、旧 V2/V3 同订单任务时间线和 V4 订单任务时间线;`include_tasks=false` 时只返回订单摘要,旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都为空数组。旧任务时间线已补齐每个任务的来源邮件会话摘要。
|
||||
|
||||
订单详情低保真已确认沿用“订单摘要 + 同订单任务队列 + 当前任务卡 + OPERA 模拟操作 + 审计时间线”。同订单任务队列里的每个任务都需要自己的“查看邮件会话”入口,因为不同任务可能来自不同邮件或不同邮件会话。因此建议补齐 `tasks[]` 中每个任务的来源邮件会话字段。
|
||||
订单详情低保真已确认沿用“订单摘要 + 同订单任务队列 + 当前任务卡 + OPERA 模拟操作 + 审计时间线”。同订单任务队列里的每个任务都需要自己的“查看邮件会话”入口,因为不同任务可能来自不同邮件或不同邮件会话。因此 `tasks[]` 中每个任务已补齐来源邮件会话字段。V4 新模型下,同一订单的多卡订单任务通过 `v4_order_tasks[]` 单独返回,前端点击后进入 V4 订单任务详情。
|
||||
|
||||
建议入参:
|
||||
|
||||
@@ -217,6 +217,35 @@ GET /api/reservation/orders/{orderId}
|
||||
"created_at": "2026-07-08T03:00:00Z"
|
||||
}
|
||||
],
|
||||
"v4_order_tasks": [
|
||||
{
|
||||
"order_task_id": "40001",
|
||||
"order_ref": "order-1",
|
||||
"order_task_status": "OPEN",
|
||||
"card_counts": {
|
||||
"total_count": 3,
|
||||
"readonly_count": 1,
|
||||
"pending_confirm_count": 1,
|
||||
"review_required_count": 1,
|
||||
"confirmed_count": 0
|
||||
},
|
||||
"source_message_summary": {
|
||||
"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
|
||||
},
|
||||
"source_received_at": "2026-07-08T04:00:00Z",
|
||||
"created_at": "2026-07-08T04:00:10Z",
|
||||
"updated_at": "2026-07-08T04:05:00Z",
|
||||
"latest_activity_at": "2026-07-08T04:05:00Z"
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
@@ -232,6 +261,13 @@ GET /api/reservation/orders/{orderId}
|
||||
| `external_conversation_id` | 来源消息所属邮件会话 ID。 |
|
||||
| `conversation_message_count` | 会话内邮件数量。 |
|
||||
| `result_type` / `ai_task_type` / `route_code` / `system_process_category` | V3 路由展示字段,和任务列表字段语义一致。 |
|
||||
| `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[].source_message_summary` | V4 来源邮件安全摘要,不包含正文、HTML、附件 URL 或 AI 原始 payload。 |
|
||||
| `v4_order_tasks[].latest_activity_at` | V4 订单任务自身 `updated_at` 与其下卡片 `updated_at` 的最大 UTC 时间。 |
|
||||
|
||||
## 5. 订单列表接口
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
| --- | --- |
|
||||
| 文档版本 | 0.4 |
|
||||
| 日期 | 2026-07-19 |
|
||||
| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口已实现;CP6 卡片确认和 S10/S99 ack 已实现 |
|
||||
| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口和订单详情 V4 时间线已实现;CP6 卡片确认和 S10/S99 ack 已实现 |
|
||||
| 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 |
|
||||
| 不适用范围 | V4 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 |
|
||||
|
||||
@@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路
|
||||
|
||||
本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。
|
||||
|
||||
截至 CP8,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口,以及 Account / Room Type / Rate Code 固定种子目录第一版校验和卡片 `fields[]` 白名单。
|
||||
截至 CP8,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口,以及 Account / Room Type / Rate Code 固定种子目录第一版校验和卡片 `fields[]` 白名单。
|
||||
|
||||
后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。
|
||||
|
||||
@@ -30,7 +30,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路
|
||||
| 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task`,并已额外创建 V4 业务卡 | 旧任务链路仍作前端过渡兼容,后续 V4 查询和写接口完成后再逐步废弃 |
|
||||
| 技术错误 | 已落 `adapter_contract_error` transition | 已符合目标方向:不创建用户可处理卡 |
|
||||
| 草稿 / READY / OPERA | 仍复用 V3 草稿、READY 和 OPERA 模拟骨架 | V4 新数据确认口径是不保存草稿、确认后锁定、当前不生成 OPERA |
|
||||
| 前端查询 | 复用旧任务列表和任务详情 | 需要新订单任务详情接口返回邮件卡、Basic Information 卡和业务卡数组 |
|
||||
| 前端查询 | 复用旧任务列表和任务详情 | CP5 已开放 V4 工作台、订单任务列表 / 详情、来源通知详情和订单详情 V4 时间线 |
|
||||
|
||||
## 3. 领域词汇
|
||||
|
||||
@@ -587,15 +587,34 @@ GET /api/reservation/source-notifications/{notificationId}
|
||||
|
||||
### 12.5 订单详情时间线
|
||||
|
||||
建议后续扩展,CP5 尚未实现:
|
||||
已在旧订单详情接口内兼容扩展:
|
||||
|
||||
```text
|
||||
GET /api/reservation/orders/{orderId}/order-tasks
|
||||
GET /api/reservation/orders/{orderId}
|
||||
分类:FRONTEND_USER
|
||||
权限:RESERVATION_ORDER_READ
|
||||
```
|
||||
|
||||
用于订单详情页展示 V4 订单任务时间线。旧 `GET /api/reservation/orders/{orderId}` 可以在过渡期继续返回 V3 `tasks[]`。
|
||||
用于订单详情页展示 V4 订单任务时间线,同时保留旧 `tasks[]`。新增字段为 `v4_order_tasks[]`,`include_tasks=false` 时 `tasks[]` 与 `v4_order_tasks[]` 都返回空数组。
|
||||
|
||||
`v4_order_tasks[]` 每项返回:
|
||||
|
||||
- `order_task_id`
|
||||
- `order_ref`
|
||||
- `order_task_status`
|
||||
- `card_counts`
|
||||
- `source_message_summary`
|
||||
- `source_received_at`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `latest_activity_at`
|
||||
|
||||
说明:
|
||||
|
||||
- 排序沿用 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` 的最大值。
|
||||
- 接口权限仍使用 `RESERVATION_ORDER_READ`,并按订单实际所属酒店校验访问权;V4 任务读取时继续以该订单酒店过滤,避免跨酒店脏数据泄露。
|
||||
|
||||
## 13. 前端写操作接口
|
||||
|
||||
@@ -716,7 +735,7 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入
|
||||
| --- | --- | --- |
|
||||
| M002-V4-CP3 | V4 表结构和基础 Repository | 新增 V4 order task / card / source notification 表、Entity、Mapper、Repository、测试 |
|
||||
| M002-V4-CP4 | V4 入站落新模型 | 已完成:SuperAgent V4 回调创建订单任务、Basic Information 卡、业务卡、邮件展示卡和 S10/S99 来源通知 |
|
||||
| M002-V4-CP5 | V4 查询接口 | 已完成:工作台统一列表、订单任务列表、订单任务详情和来源通知详情查询接口;订单详情 V4 专属时间线后续再做 |
|
||||
| M002-V4-CP5 | V4 查询接口 | 已完成:工作台统一列表、订单任务列表、订单任务详情、来源通知详情查询接口,以及旧订单详情响应内的 `v4_order_tasks[]` V4 订单任务时间线 |
|
||||
| M002-V4-CP6 | V4 卡片确认和 S10/S99 ack | 已完成:不保存草稿,支持普通卡片确认、确认后锁定、Basic Information 前置约束、同订单前置任务写侧阻塞、业务审计、version 并发校验和 S10/S99 来源通知确认 |
|
||||
| M002-V4-CP7 | V4 复核解阻与订单归属确认 | 已完成:支持 `REVIEW_REQUIRED` 卡字段修正、复核说明、复核场景订单归属确认、version 并发校验、直接 `CONFIRMED`、审计和 availability.reviewable |
|
||||
| M002-V4-CP8 | 受控目录第一版 | 已完成第一版:Account 固定目录校验、Market / Source 派生、RoomType / RateCode 固定种子校验、V4 任务卡 `fields[]` 字段白名单 |
|
||||
|
||||
@@ -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` 并校验酒店访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权 | 只读查询默认不写业务审计 |
|
||||
| `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权 | 只读查询默认不写业务审计 |
|
||||
| `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/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` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 |
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 前端订单详情响应,包含订单摘要、任务时间线和非阻塞警告。
|
||||
* 前端订单详情响应,包含订单摘要、旧任务时间线、V4 订单任务时间线和非阻塞警告。
|
||||
*
|
||||
* @param order 订单摘要
|
||||
* @param tasks 同订单任务时间线
|
||||
* @param tasks 同订单旧任务时间线
|
||||
* @param v4OrderTasks 同订单 V4 订单任务时间线
|
||||
* @param warnings 当前无法提供的扩展信息或非阻塞提醒
|
||||
*/
|
||||
public record ReservationOrderDetailResult(
|
||||
ReservationOrderSummaryResult order,
|
||||
List<ReservationOrderTaskTimelineItemResult> tasks,
|
||||
@JsonProperty("v4_order_tasks")
|
||||
List<ReservationOrderV4TaskTimelineItemResult> v4OrderTasks,
|
||||
List<String> warnings
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 订单详情页 V4 订单任务时间线单项。用于旧订单详情页补充展示 V4 多卡任务摘要。
|
||||
*
|
||||
* @param orderTaskId V4 订单任务 ID
|
||||
* @param orderRef V4 回调包内订单引用
|
||||
* @param orderTaskStatus V4 订单任务状态
|
||||
* @param cardCounts V4 卡片数量摘要
|
||||
* @param sourceMessageSummary 来源邮件安全摘要
|
||||
* @param sourceReceivedAt 来源邮件接收 UTC 时间
|
||||
* @param createdAt V4 订单任务创建 UTC 时间
|
||||
* @param updatedAt V4 订单任务更新 UTC 时间
|
||||
* @param latestActivityAt V4 订单任务及其卡片的最新活动 UTC 时间
|
||||
*/
|
||||
public record ReservationOrderV4TaskTimelineItemResult(
|
||||
@JsonProperty("order_task_id")
|
||||
String orderTaskId,
|
||||
@JsonProperty("order_ref")
|
||||
String orderRef,
|
||||
@JsonProperty("order_task_status")
|
||||
String orderTaskStatus,
|
||||
@JsonProperty("card_counts")
|
||||
ReservationV4CardCountsResult cardCounts,
|
||||
@JsonProperty("source_message_summary")
|
||||
ReservationV4SourceMessageSummaryResult sourceMessageSummary,
|
||||
@JsonProperty("source_received_at")
|
||||
OffsetDateTime sourceReceivedAt,
|
||||
@JsonProperty("created_at")
|
||||
OffsetDateTime createdAt,
|
||||
@JsonProperty("updated_at")
|
||||
OffsetDateTime updatedAt,
|
||||
@JsonProperty("latest_activity_at")
|
||||
OffsetDateTime latestActivityAt
|
||||
) {
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrde
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
|
||||
@@ -19,12 +22,17 @@ 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.ReservationOrderV4TaskTimelineItemResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchItemResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CardCountsResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceMessageSummaryResult;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -49,6 +57,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
private static final String REASON_READ_ONLY = "READ_ONLY";
|
||||
|
||||
private final ReservationAiWorkflowRepository workflowRepository;
|
||||
private final ReservationV4WorkflowRepository v4WorkflowRepository;
|
||||
private final SourceMessageQueryService sourceMessageQueryService;
|
||||
private final ReservationTaskAvailabilityResolver availabilityResolver;
|
||||
private final HotelContextService hotelContextService;
|
||||
@@ -58,10 +67,12 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
*/
|
||||
public ReservationFrontendQueryServiceImpl(
|
||||
ReservationAiWorkflowRepository workflowRepository,
|
||||
ReservationV4WorkflowRepository v4WorkflowRepository,
|
||||
SourceMessageQueryService sourceMessageQueryService,
|
||||
ReservationTaskAvailabilityResolver availabilityResolver,
|
||||
HotelContextService hotelContextService) {
|
||||
this.workflowRepository = workflowRepository;
|
||||
this.v4WorkflowRepository = v4WorkflowRepository;
|
||||
this.sourceMessageQueryService = sourceMessageQueryService;
|
||||
this.availabilityResolver = availabilityResolver;
|
||||
this.hotelContextService = hotelContextService;
|
||||
@@ -172,7 +183,10 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
availabilityOrReadOnly(task, availabilityByTaskId),
|
||||
sourceContextsById.get(task.sourceMessageId())))
|
||||
.toList();
|
||||
return new ReservationOrderDetailResult(toOrderSummary(order), tasks, List.of());
|
||||
List<ReservationOrderV4TaskTimelineItemResult> v4OrderTasks = Boolean.FALSE.equals(includeTasks)
|
||||
? List.of()
|
||||
: findV4OrderTaskTimeline(orderHotelId, order.id());
|
||||
return new ReservationOrderDetailResult(toOrderSummary(order), tasks, v4OrderTasks, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,6 +318,104 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单详情页 V4 订单任务时间线,复用当前订单酒店作为对象级隔离边界。
|
||||
*/
|
||||
private List<ReservationOrderV4TaskTimelineItemResult> findV4OrderTaskTimeline(String hotelId, Long orderId) {
|
||||
List<ReservationV4OrderTaskSnapshot> orderTasks = v4WorkflowRepository.findOrderTasksByOrderIds(
|
||||
hotelId,
|
||||
List.of(orderId));
|
||||
if (orderTasks.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<Long, List<ReservationV4TaskCardSnapshot>> cardsByOrderTaskId = findV4TaskCardsByOrderTaskId(
|
||||
hotelId,
|
||||
orderTasks);
|
||||
Map<Long, ReservationV4SourceMessageSummaryResult> sourceSummariesById = findV4SourceSummariesById(
|
||||
hotelId,
|
||||
orderTasks.stream().map(ReservationV4OrderTaskSnapshot::sourceMessageId).toList());
|
||||
return orderTasks.stream()
|
||||
.map(orderTask -> toV4TimelineItem(
|
||||
orderTask,
|
||||
cardsByOrderTaskId.getOrDefault(orderTask.id(), List.of()),
|
||||
sourceSummariesById.get(orderTask.sourceMessageId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取 V4 订单任务卡片并按订单任务 ID 分组,用于订单详情页统计卡片状态。
|
||||
*/
|
||||
private Map<Long, List<ReservationV4TaskCardSnapshot>> findV4TaskCardsByOrderTaskId(
|
||||
String hotelId,
|
||||
List<ReservationV4OrderTaskSnapshot> orderTasks) {
|
||||
List<Long> orderTaskIds = orderTasks.stream()
|
||||
.map(ReservationV4OrderTaskSnapshot::id)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<ReservationV4TaskCardSnapshot> cards = v4WorkflowRepository.findTaskCardsByOrderTaskIds(
|
||||
hotelId,
|
||||
orderTaskIds);
|
||||
Map<Long, List<ReservationV4TaskCardSnapshot>> result = new LinkedHashMap<>();
|
||||
for (ReservationV4TaskCardSnapshot card : cards) {
|
||||
result.computeIfAbsent(card.v4OrderTaskId(), ignored -> new java.util.ArrayList<>()).add(card);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取 V4 来源邮件安全摘要;若数据缺失或酒店不匹配,只返回带 ID 的空摘要避免泄露。
|
||||
*/
|
||||
private Map<Long, ReservationV4SourceMessageSummaryResult> findV4SourceSummariesById(
|
||||
String hotelId,
|
||||
List<Long> sourceMessageIds) {
|
||||
if (sourceMessageIds == null || sourceMessageIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> uniqueIds = sourceMessageIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<SourceMessageSummaryResponse> summaries = sourceMessageQueryService.getSummariesByIds(uniqueIds);
|
||||
List<SourceMessageSummaryResponse> safeSummaries = summaries.stream()
|
||||
.filter(summary -> Objects.equals(hotelId, summary.hotelId()))
|
||||
.toList();
|
||||
Map<String, Long> conversationCounts = sourceMessageQueryService.countByExternalConversationIds(
|
||||
hotelId,
|
||||
safeSummaries.stream()
|
||||
.map(SourceMessageSummaryResponse::externalConversationId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList());
|
||||
Map<Long, ReservationV4SourceMessageSummaryResult> result = new LinkedHashMap<>();
|
||||
for (SourceMessageSummaryResponse summary : safeSummaries) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按来源消息安全摘要关键词查找 SourceMessage ID,避免任务列表直接读取来源消息正文。
|
||||
*/
|
||||
@@ -380,6 +492,63 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(task.createdAt()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 V4 订单任务快照为订单详情页时间线安全摘要。
|
||||
*/
|
||||
private ReservationOrderV4TaskTimelineItemResult toV4TimelineItem(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
List<ReservationV4TaskCardSnapshot> cards,
|
||||
ReservationV4SourceMessageSummaryResult sourceSummary) {
|
||||
return new ReservationOrderV4TaskTimelineItemResult(
|
||||
orderTask.id().toString(),
|
||||
orderTask.orderRef(),
|
||||
orderTask.orderTaskStatus(),
|
||||
v4CardCounts(cards),
|
||||
sourceSummary,
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(orderTask.sourceReceivedAt()),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(orderTask.createdAt()),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(orderTask.updatedAt()),
|
||||
UtcTimeFormatter.toUtcOffsetDateTime(latestV4ActivityAt(orderTask, cards)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计 V4 订单任务下各状态卡片数量,供订单详情时间线轻量展示。
|
||||
*/
|
||||
private ReservationV4CardCountsResult v4CardCounts(List<ReservationV4TaskCardSnapshot> cards) {
|
||||
List<ReservationV4TaskCardSnapshot> safeCards = cards == null ? List.of() : cards;
|
||||
return new ReservationV4CardCountsResult(
|
||||
safeCards.size(),
|
||||
countV4Cards(safeCards, ReservationV4CardStatus.READONLY.name()),
|
||||
countV4Cards(safeCards, ReservationV4CardStatus.PENDING_CONFIRM.name()),
|
||||
countV4Cards(safeCards, ReservationV4CardStatus.REVIEW_REQUIRED.name()),
|
||||
countV4Cards(safeCards, ReservationV4CardStatus.CONFIRMED.name()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定 V4 卡片状态数量。
|
||||
*/
|
||||
private int countV4Cards(List<ReservationV4TaskCardSnapshot> cards, String cardStatus) {
|
||||
return Math.toIntExact(cards.stream()
|
||||
.filter(card -> cardStatus.equals(card.cardStatus()))
|
||||
.count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生 V4 订单任务最新活动时间,卡片确认或复核后可驱动订单详情时间线更新。
|
||||
*/
|
||||
private LocalDateTime latestV4ActivityAt(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
List<ReservationV4TaskCardSnapshot> cards) {
|
||||
LocalDateTime latest = orderTask.updatedAt() == null ? orderTask.createdAt() : orderTask.updatedAt();
|
||||
for (ReservationV4TaskCardSnapshot card : cards == null ? List.<ReservationV4TaskCardSnapshot>of() : cards) {
|
||||
LocalDateTime cardActivityAt = card.updatedAt() == null ? card.createdAt() : card.updatedAt();
|
||||
if (cardActivityAt != null && (latest == null || cardActivityAt.isAfter(latest))) {
|
||||
latest = cardActivityAt;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换订单快照为前端订单列表单行结果。
|
||||
*/
|
||||
|
||||
@@ -24,14 +24,23 @@ import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
|
||||
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.ReservationV4TaskCardDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
|
||||
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.ReservationV4OrderTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -73,6 +82,9 @@ class ReservationFrontendQueryControllerTest {
|
||||
@MockitoSpyBean
|
||||
private ReservationAiWorkflowRepository workflowRepository;
|
||||
|
||||
@Autowired
|
||||
private ReservationV4WorkflowRepository v4WorkflowRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@@ -409,6 +421,7 @@ class ReservationFrontendQueryControllerTest {
|
||||
.andExpect(jsonPath("$.tasks[1].task_status").value("PENDING_CONFIRM"))
|
||||
.andExpect(jsonPath("$.tasks[1].can_process").value(true))
|
||||
.andExpect(jsonPath("$.tasks[1].readonly_reason_code").value("PROCESSABLE"))
|
||||
.andExpect(jsonPath("$.v4_order_tasks.length()").value(0))
|
||||
.andExpect(jsonPath("$.warnings.length()").value(0));
|
||||
verify(workflowRepository, never()).findQueueTasksBefore(anyString(), anyLong(), any());
|
||||
|
||||
@@ -417,7 +430,119 @@ class ReservationFrontendQueryControllerTest {
|
||||
.param("include_tasks", "false"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.order.order_id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.tasks.length()").value(0));
|
||||
.andExpect(jsonPath("$.tasks.length()").value(0))
|
||||
.andExpect(jsonPath("$.v4_order_tasks.length()").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnOrderDetailWithV4OrderTaskTimeline() throws Exception {
|
||||
SourceMessageCaptureResult laterSource = captureSourceMessage(
|
||||
"mail-frontend-order-detail-v4-later-001",
|
||||
"Frontend V4 Later",
|
||||
Instant.parse("2026-07-08T09:00:00Z"));
|
||||
SourceMessageCaptureResult earlierSource = captureSourceMessage(
|
||||
"mail-frontend-order-detail-v4-earlier-001",
|
||||
"Frontend V4 Earlier",
|
||||
Instant.parse("2026-07-08T07:00:00Z"));
|
||||
Long orderId = 930000000000003001L;
|
||||
insertActiveGroupOrder(orderId, earlierSource.inboxId(), "GRP-FRONTEND-V4-DETAIL-001");
|
||||
ReservationV4OrderTaskSnapshot laterOrderTask = insertV4OrderTask(
|
||||
930000000000003101L,
|
||||
laterSource,
|
||||
orderId,
|
||||
"order-later",
|
||||
"GRP-FRONTEND-V4-LATER-001",
|
||||
Instant.parse("2026-07-08T09:00:00Z"),
|
||||
HOTEL_ID);
|
||||
ReservationV4OrderTaskSnapshot earlierOrderTask = insertV4OrderTask(
|
||||
930000000000003102L,
|
||||
earlierSource,
|
||||
orderId,
|
||||
"order-earlier",
|
||||
"GRP-FRONTEND-V4-EARLIER-001",
|
||||
Instant.parse("2026-07-08T07:00:00Z"),
|
||||
HOTEL_ID);
|
||||
insertV4TaskCard(earlierOrderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
|
||||
ReservationV4CardStatus.READONLY.name(), null, """
|
||||
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"Frontend V4 Earlier"}}
|
||||
""");
|
||||
insertV4TaskCard(earlierOrderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
|
||||
ReservationV4CardStatus.CONFIRMED.name(), null, """
|
||||
{"card_type":"BASIC_INFORMATION","order_ref":"order-earlier"}
|
||||
""");
|
||||
insertV4TaskCard(earlierOrderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", """
|
||||
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","missing_fields":["room_items"]}
|
||||
""");
|
||||
insertV4TaskCard(laterOrderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
|
||||
ReservationV4CardStatus.READONLY.name(), null, "{}");
|
||||
insertV4TaskCard(laterOrderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, "{}");
|
||||
updateV4OrderTaskUpdatedAt(earlierOrderTask.id(), Instant.parse("2026-07-08T07:10:00Z"));
|
||||
updateV4TaskCardUpdatedAt(earlierOrderTask.id(), 30, Instant.parse("2026-07-08T07:45:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId)
|
||||
.param("hotel_id", HOTEL_ID)
|
||||
.param("include_tasks", "true"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.tasks.length()").value(0))
|
||||
.andExpect(jsonPath("$.v4_order_tasks.length()").value(2))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].order_task_id").value(earlierOrderTask.id().toString()))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].order_ref").value("order-earlier"))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].order_task_status").value("OPEN"))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].card_counts.total_count").value(3))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].card_counts.readonly_count").value(1))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].card_counts.review_required_count").value(1))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].card_counts.confirmed_count").value(1))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].source_message_summary.source_message_id")
|
||||
.value(earlierSource.inboxId().toString()))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].source_message_summary.subject").value("Frontend V4 Earlier"))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].source_received_at").value("2026-07-08T07:00:00Z"))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].created_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].updated_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].latest_activity_at").value("2026-07-08T07:45:00Z"))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[1].order_task_id").value(laterOrderTask.id().toString()))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[1].source_received_at").value("2026-07-08T09:00:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHideV4OrderTaskTimelineWhenIncludeTasksFalseAndIgnoreCrossHotelRows() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
"mail-frontend-order-detail-v4-compatible-001",
|
||||
"Frontend V4 Compatible",
|
||||
Instant.parse("2026-07-08T10:00:00Z"));
|
||||
Long orderId = 930000000000003201L;
|
||||
insertActiveGroupOrder(orderId, source.inboxId(), "GRP-FRONTEND-V4-COMPATIBLE-001");
|
||||
insertV4OrderTask(
|
||||
930000000000003202L,
|
||||
source,
|
||||
orderId,
|
||||
"order-visible",
|
||||
"GRP-FRONTEND-V4-COMPATIBLE-001",
|
||||
Instant.parse("2026-07-08T10:00:00Z"),
|
||||
HOTEL_ID);
|
||||
insertV4OrderTask(
|
||||
930000000000003203L,
|
||||
source,
|
||||
orderId,
|
||||
"order-cross-hotel",
|
||||
"GRP-FRONTEND-V4-CROSS-001",
|
||||
Instant.parse("2026-07-08T10:05:00Z"),
|
||||
"HOTEL-OTHER");
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId)
|
||||
.param("hotel_id", HOTEL_ID)
|
||||
.param("include_tasks", "false"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.tasks.length()").value(0))
|
||||
.andExpect(jsonPath("$.v4_order_tasks.length()").value(0));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId)
|
||||
.param("hotel_id", HOTEL_ID)
|
||||
.param("include_tasks", "true"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.v4_order_tasks.length()").value(1))
|
||||
.andExpect(jsonPath("$.v4_order_tasks[0].order_ref").value("order-visible"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -528,6 +653,74 @@ class ReservationFrontendQueryControllerTest {
|
||||
""", orderVisibility, orderId);
|
||||
}
|
||||
|
||||
private void updateV4OrderTaskUpdatedAt(Long orderTaskId, Instant updatedAt) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE workflow_reservation_v4_order_task
|
||||
SET updated_at = ?
|
||||
WHERE id = ?
|
||||
""", Timestamp.valueOf(LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC)), orderTaskId);
|
||||
}
|
||||
|
||||
private void updateV4TaskCardUpdatedAt(Long orderTaskId, Integer cardSortOrder, Instant updatedAt) {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE workflow_reservation_v4_task_card
|
||||
SET updated_at = ?
|
||||
WHERE v4_order_task_id = ?
|
||||
AND card_sort_order = ?
|
||||
""", Timestamp.valueOf(LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC)), orderTaskId, cardSortOrder);
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskSnapshot insertV4OrderTask(
|
||||
Long aiBatchId,
|
||||
SourceMessageCaptureResult source,
|
||||
Long orderId,
|
||||
String orderRef,
|
||||
String targetLocatorValue,
|
||||
Instant sourceReceivedAt,
|
||||
String hotelId) {
|
||||
LocalDateTime now = LocalDateTime.ofInstant(sourceReceivedAt.plusSeconds(10), ZoneOffset.UTC);
|
||||
return v4WorkflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
|
||||
hotelId,
|
||||
source.inboxId(),
|
||||
aiBatchId,
|
||||
orderRef,
|
||||
1,
|
||||
orderId,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
targetLocatorValue,
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
ReservationV4OrderTaskStatus.OPEN.name(),
|
||||
LocalDateTime.ofInstant(sourceReceivedAt, ZoneOffset.UTC),
|
||||
now));
|
||||
}
|
||||
|
||||
private void insertV4TaskCard(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
String cardType,
|
||||
String eventType,
|
||||
Integer sourceEventIndex,
|
||||
Integer cardSortOrder,
|
||||
String cardStatus,
|
||||
String reviewStatus,
|
||||
String displayPayloadJson) {
|
||||
v4WorkflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
|
||||
orderTask.hotelId(),
|
||||
orderTask.id(),
|
||||
orderTask.sourceMessageId(),
|
||||
eventType == null ? null : orderTask.aiBatchId() + cardSortOrder,
|
||||
cardType,
|
||||
eventType,
|
||||
sourceEventIndex,
|
||||
cardSortOrder,
|
||||
cardStatus,
|
||||
reviewStatus,
|
||||
"{}",
|
||||
displayPayloadJson,
|
||||
null,
|
||||
orderTask.createdAt()));
|
||||
}
|
||||
|
||||
private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
|
||||
insertGroupOrder(orderId, sourceMessageId, groupCode, "ACTIVE");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user