diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index ab5809a..63233df 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -34,8 +34,8 @@ - M002 V3 新入口采用结构化 `S10/S99`:`S10` 表示未匹配当前支持的业务事件,`S99` 表示输入不足或无法形成业务素材包;旧 `S000/S999` 继续按历史数据兼容展示。 - `S10/S99` 后端会创建只读源邮件通知卡,任务列表可见,订单列表不可见;当前代码中的旧 `SOURCE_MESSAGE_ONLY` 任务仍按同一只读语义展示。 - 源邮件只读通知卡不允许编辑、确认、人工转换订单、执行 OPERA 或重试 OPERA;不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 -- type-known manual review 后续应展示为原业务任务卡的复核模式,不应统一展示成 Fallback。只有业务类型或 subtype 本身未知时才进入 Fallback。 -- 复核场景下允许用户确认订单归属;这不等于开放普通任务任意切换订单。 +- type-known manual review 已支持同卡复核解阻第一版:应展示为原业务任务卡的复核模式,不应统一展示成 Fallback。只有业务类型或 subtype 本身未知时才进入 Fallback。 +- 复核场景下允许用户确认订单归属;当前第一版只允许确认当前任务所属订单,不等于开放普通任务任意切换订单。 - 历史 Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 - Fallback / manual_review 转为 New / Update / Cancel 时需要展示审计轨迹;登录权限底座已提供,具体业务审计 actor 迁移仍后置。 - 普通任务切换订单接口已确认后置,前端第一版不要把普通任务拖拽或切换订单做成可提交能力。 @@ -50,10 +50,11 @@ | `GET /api/reservation/orders` | 查询订单列表 | 默认返回全部订单状态;`open_task_count` 排除 `COMPLETED` 和 `FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 | | `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL;已返回来源邮件会话摘要字段,并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和新 S10/S99 都以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | -| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;源邮件只读通知卡字段列表和 OPERA 操作列表为空;结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息。 | +| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;源邮件只读通知卡字段列表和 OPERA 操作列表为空;结构化 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`。 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | Fallback 人工转换 | 只用于 manual_review / fallback,不用于普通任务切换订单。 | +| `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | type-known manual review 同卡复核解阻 | 只用于已知业务类型的 `result_type=manual_review` 任务;提交 `field_overrides[]` 和当前订单归属确认,通过后进入 `READY` 并生成两条 OPERA 模拟操作。 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 执行 OPERA 模拟操作 | 当前是模拟,不调用真实 OPERA。 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 重试失败 OPERA 模拟操作 | 重试会追加 attempt 历史,前端不要覆盖旧失败记录。 | | `GET /api/reservation/tasks/{taskId}/audits` | 查询任务审计流水 | 用于展示人工确认、转换、模拟操作等轨迹。 | @@ -146,6 +147,33 @@ POST /api/auth/logout - 任务列表、订单任务时间线和任务详情顶层已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。前端展示任务卡标题和标签时优先用这些稳定 code,不要只靠旧 `task_type` 判断。 - `adapter_contract_errors[]` 和 `unhandled_intents[]` 只在任务详情返回,表示同一 SuperAgent 入站批次中没有生成业务任务的诊断块;前端只读展示并提供来源邮件入口,不显示保存、确认、执行或重试按钮。 +### 5.5.1 Type-known manual review 同卡复核解阻接入注意 + +- `result_type=manual_review` 且 `system_task_type` 不是 `MANUAL_REVIEW` 时,前端应在原业务任务卡上展示复核模式,不要跳到 Fallback 转换页面。 +- 任务详情顶层返回 `review_status`。`PENDING` 表示等待用户补字段或确认订单归属;`RESOLVED` 表示同卡复核已解阻。 +- 任务详情顶层 `manual_review` 返回 SuperAgent 原始复核原因、缺失字段、阻塞点和建议动作,前端只读展示;不要把它当成可编辑表单直接提交。 +- 解阻接口使用 `POST /api/reservation/tasks/{taskId}/manual-review-resolutions`。请求体: + +```json +{ + "confirmed_order_id": "20001", + "reason": "确认 PMS 房型代码后解阻。", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "value": "RM3" + } + ] +} +``` + +- `field_pointer` 必须是 RFC 6901 JSON Pointer,并且只能指向当前任务卡可编辑字段;后端会映射到矩阵 `field_path`。非法或只读字段会返回 `TASK_REVIEW_POINTER_INVALID`。 +- `confirmed_order_id` 第一版必须等于当前任务的 `order_id`;如果前端需要选择其他订单,仍属于后续“复核场景订单归属选择”细化,不要复用普通任务切换订单能力。 +- 解阻成功后返回 `task_status=READY`、`review_status=RESOLVED`、`review_resolution.field_overrides[]`、`confirmed_payload` 和两条 `opera_operations[]`。前端应刷新任务详情并显示 OPERA 模拟操作入口。 +- 解阻过程不改写 `ai_payload_json`;用户修正值保存在 `review_resolution` 和 `confirmed_payload.field_values` 中。 +- `review_resolution.resolved_at` 是 UTC `Z` 时间点。 +- type-known manual review 不允许调用通用 `POST /api/reservation/tasks/{taskId}/confirm`;前端必须使用本节解阻接口,否则后端返回 `TASK_REVIEW_RESOLUTION_REQUIRED`。 + ### 5.6 前端联调演示数据 seed 接口 后端提供一个受控的 dev/test 演示数据入口,方便前端在空库或本地环境快速看到页面效果。 @@ -270,7 +298,7 @@ run_label: 可选调试标签 ## 7. 需要持续提醒的后置事项 - 普通任务切换订单接口继续后置。 -- M002 V3 的结构化 `S10/S99` 入站、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库,以及任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出已完成;type-known manual review 同卡解阻、复核场景订单归属确认仍需后续后端 checkpoint。 +- M002 V3 的结构化 `S10/S99` 入站、42 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版均已完成。 - 系统管理后台 V1 已完成;后续若要做用户搜索更多筛选、批量操作、密码策略增强、MFA、登录设备管理,应单独开需求。 - 现有 Reservation / SourceMessage 业务接口的强制登录、强制权限和业务审计 actor 全量迁移仍后置。 - 真实 OPERA / OHIP 接入继续后置。 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 8e0d9b3..c4a697c 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -17,6 +17,7 @@ | 联调 | 演示数据 seed 接口 `POST /api/system/reservation/demo-data` | 本地 / test 前端页面看效果 | 已完成;仅 dev/test 受控使用 | | 联调 | Debug EML 上传接口 `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 看 SuperAgent 结果 | 已完成第一版;仅 dev/test 受控使用 | | P1 | S10/S99 源邮件只读通知卡与旧 S000/S999 兼容 | 任务列表、任务详情来源邮件查看 | 已完成第一版:旧 S000/S999 兼容,新结构化 S10/S99 可入站并在任务列表 / 详情只读展示 | +| P1 | type-known manual review 同卡复核解阻 | 任务详情复核 | 已完成第一版:原业务任务卡复核、字段修正、订单归属确认、READY 流转 | | P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 | | 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 未完成;已确认后置 | @@ -35,6 +36,7 @@ | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 已完成第一版模拟操作 | 可以 | 暂无;真实 OPERA 写入另行确认。 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 已完成第一版模拟重试 | 可以 | 暂无;真实 OPERA 重试另行确认。 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | 已完成 | 可以 | 暂无。 | +| `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | 已完成第一版 | 可以 | 只用于 type-known manual review;第一版 `confirmed_order_id` 必须等于当前任务订单,不开放普通任务任意切换订单。 | | `GET /api/source-messages` | 已完成安全摘要列表 | 可以 | 不能替代邮件会话全文接口。 | | `GET /api/source-messages/{id}` | 已完成单条安全摘要 | 可以 | 不能替代邮件会话全文接口。 | | `GET /api/source-messages/{id}/original` | 已完成单封原文受控读取 | 谨慎接入 | 只能读单封邮件,不能返回同一 conversation 全量邮件。 | @@ -326,8 +328,8 @@ POST /api/system/reservation/demo-data | 42 路由元数据 | 任务列表筛选、订单任务时间线、任务详情标题、字段展示 | 已完成第一版 | 后端保存并返回 AI 原始 `result_type/ai_task_type/task_subtype`、`route_code` 和系统处理分类;前端不要只依赖系统主任务类型判断卡片。 | | `unhandled_current_intents[]` 展示块 | 任务详情 | 已完成第一版 | 后端保存并在任务详情 `unhandled_intents[]` 返回未覆盖业务意图,只用于展示和源邮件查看,不自动建业务任务卡。 | | `adapter_contract_error` | 任务详情、错误提示 | 已完成第一版 | 命中 P1/P2 未闭合或路由冲突时,任务详情 `adapter_contract_errors[]` 返回稳定错误 code 和原始片段,不转成 Fallback。 | -| type-known manual review 同卡解阻 | 任务详情复核 | 后置 | `manual_review` 不再全部等同 Fallback;已知业务卡型应返回原业务卡信息、`review_status`、`review_resolution` 和可编辑 pointer 字段。 | -| 复核场景订单归属确认 | 任务详情复核 | 后置 | 后端提供复核确认时的订单归属确认 / 选择能力;这不等于普通任务任意切换订单。 | +| type-known manual review 同卡解阻 | 任务详情复核 | 已完成第一版 | `manual_review` 不再全部等同 Fallback;已知业务卡型返回原业务卡信息、`review_status`、`review_resolution` 和可编辑 pointer 字段,解阻后进入 `READY`。 | +| 复核场景订单归属确认 | 任务详情复核 | 已完成第一版 | 后端提供复核确认时的订单归属确认;当前第一版只能确认当前任务所属订单,后续如要选择其他订单需另行细化。 | 前端注意:不要把访问口令写入前端仓库、浏览器环境变量或构建产物;该接口只能由本地联调人员手动调用或由受控测试脚本调用。 @@ -445,6 +447,7 @@ GET /api/reservation/tasks/{taskId} | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 执行 OPERA 模拟操作。 | 已完成第一版模拟。 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 重试失败的 OPERA 模拟操作。 | 已完成第一版模拟。 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | 将 Fallback / manual_review 转换为具体任务类型。 | 已完成。 | +| `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | type-known manual review 在原业务任务卡上提交字段修正和订单归属确认。 | 已完成第一版。 | 已完成的 `fields[]` 字段: @@ -487,6 +490,82 @@ GET /api/reservation/tasks/{taskId} | `fields[]` | `task_subtype` | 3.0 字段表中的任务 subtype / 业务动作。 | | `fields[]` | `default_value_source` | 3.0 字段表中的默认值 / 回显来源。 | +### 8.1 Type-known manual review 同卡复核解阻 + +当前状态:后端已完成第一版。`result_type=manual_review` 且 `system_task_type` 不是 `MANUAL_REVIEW` 时,前端在原业务任务卡上展示复核模式,不进入 Fallback 转换页面。 + +任务详情增量字段: + +| 字段 | 说明 | +| --- | --- | +| `review_status` | `PENDING` 表示等待复核,`RESOLVED` 表示已解阻。 | +| `review_resolution` | 已解阻后的复核结果;未解阻时为 `null`。 | +| `manual_review` | SuperAgent 原始复核说明、缺失字段、阻塞点、建议人工动作;只读展示。 | + +解阻接口: + +```text +POST /api/reservation/tasks/{taskId}/manual-review-resolutions +Content-Type: application/json +``` + +请求示例: + +```json +{ + "confirmed_order_id": "20001", + "reason": "确认 PMS 房型代码后解阻。", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "value": "RM3" + } + ] +} +``` + +返回示例: + +```json +{ + "task_id": "10001", + "order_id": "20001", + "task_status": "READY", + "review_status": "RESOLVED", + "review_resolution": { + "schema_version": "manual-review-resolution-v1", + "confirmed_order_id": "20001", + "resolved_at": "2026-07-11T00:00:00Z", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "field_path": "extracted_fields.pms_room_type_code", + "value": "RM3" + } + ] + }, + "confirmed_payload": { + "schema_version": "field_path-v1", + "field_values": { + "extracted_fields.pms_room_type_code": "RM3" + } + }, + "opera_operations": [ + {"operation_code": "SIMULATE_PRECHECK"}, + {"operation_code": "SIMULATE_WRITE"} + ] +} +``` + +前端注意: + +- `field_pointer` 必须是 RFC 6901 JSON Pointer,并且只能指向任务详情 `fields[]` 中当前可编辑字段;只读字段或未知字段会返回 `TASK_REVIEW_POINTER_INVALID`。 +- `confirmed_order_id` 第一版必须等于当前任务 `order_id`;普通任务任意切换订单继续后置。 +- type-known manual review 不能调用通用 `POST /api/reservation/tasks/{taskId}/confirm`;必须调用本节解阻接口,否则后端返回 `TASK_REVIEW_RESOLUTION_REQUIRED`。 +- `review_resolution.resolved_at` 是 UTC `Z` 时间点。 +- 解阻成功后刷新任务详情,按钮状态以新的 `task_status=READY` 和 `availability` 为准。 +- `confirmed_payload.field_values` 仍按矩阵 `field_path` 保存,不是 OPERA 最终参数。 + 建议返参增量示例: ```json diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 073e055..f2d5321 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 0.1 | | 日期 | 2026-07-11 | -| 状态 | 0711 P0 基线确认版;后端已完成 M002 V3 CP1-CP4 入站、路由持久化和列表 / 详情展示基线 | +| 状态 | 0711 P0 基线确认版;后端已完成 M002 V3 CP1-CP5 入站、路由持久化、列表 / 详情展示和同卡复核解阻第一版 | | 适用范围 | SourceMessage 之后的 SuperAgent 输出适配、任务路由、只读通知卡、人工复核同卡解阻、前后端协作边界 | | 主要读者 | 产品、后端、前端、测试、SuperAgent 对接方、后续协作 agent | @@ -252,7 +252,7 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: ```json { - "review_status": "pending", + "review_status": "PENDING", "review_resolution": null } ``` @@ -261,14 +261,16 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: ```json { - "review_status": "resolved", + "review_status": "RESOLVED", "review_resolution": { "field_overrides": [ { "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", + "field_path": "extracted_fields.room_items[].pms_room_type_code", "value": "SU1" } ], + "confirmed_order_id": "10001", "resolved_by": "", "resolved_at": "2026-07-11T00:00:00Z" } @@ -280,10 +282,44 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: - 不改写 `ai_payload_json`。 - 不创建第二张 linked normal task。 - `missing_fields[]` 必须是 RFC 6901 JSON Pointer。 -- Pointer 必须能映射到该业务卡已知可编辑字段,否则为 `adapter_contract_error`。 -- 订单归属确认可作为复核解阻的一部分保存,但不得打开普通任务随意切换订单能力。 +- 入站阶段 `missing_fields[]` 不完整或不是 RFC 6901 Pointer 时,按 `adapter_contract_error` fail closed,不创建业务任务。 +- 解阻接口提交的 Pointer 必须能映射到该业务卡当前可展示且可编辑字段,否则返回 `TASK_REVIEW_POINTER_INVALID`。 +- 订单归属确认可作为复核解阻的一部分保存;当前第一版只允许确认当前任务所属订单,不开放普通任务随意切换订单能力。 - 全部缺失字段、订单归属、目录值和依赖校验通过后,才进入 Preflight / READY。 +### 9.3 后端第一版接口 + +```text +POST /api/reservation/tasks/{taskId}/manual-review-resolutions +Content-Type: application/json +``` + +请求示例: + +```json +{ + "confirmed_order_id": "20001", + "reason": "确认 PMS 房型代码后解阻。", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "value": "RM3" + } + ] +} +``` + +响应要点: + +- `task_status=READY`。 +- `review_status=RESOLVED`。 +- `review_resolution.field_overrides[]` 同时返回 `field_pointer`、矩阵 `field_path` 和人工值。 +- `review_resolution.resolved_at` 使用带 `Z` 的 UTC 时间点。 +- `confirmed_payload.field_values` 使用矩阵 `field_path` 保存,不按 `write_path` 生成 OPERA 参数。 +- 自动生成第一版固定两条 OPERA 模拟操作。 +- 写入 `MANUAL_REVIEW_RESOLVE` 审计。 +- type-known manual review 不允许走通用 `POST /api/reservation/tasks/{taskId}/confirm`,否则会返回 `TASK_REVIEW_RESOLUTION_REQUIRED`。 + ## 10. P1/P2 未闭合范围处理 0711 导入包已经明确 P1/P2 未闭合。后端、前端、Adapter 都不得自行发明规则。 @@ -326,7 +362,7 @@ V3 建议拆成以下 checkpoint,避免一次性重构过大: | M002-V3-CP2 | 入站解析兼容 | 已完成:正式回调支持结构化 S10/S99 和 V3 业务根,保留旧 S000/S999 兼容 | | M002-V3-CP3 | 路由持久化 | 已完成第一版:已保存 AI 原始三元组、route_code、system_process_category、unhandled_current_intents 和 adapter_contract_error | | M002-V3-CP4 | 列表 / 详情展示 | 已完成第一版:任务列表、订单任务时间线和任务详情透出 V3 路由字段;任务详情支持 S10/S99 入口通知结构、unhandled intent 展示块和 adapter contract error 展示块 | -| M002-V3-CP5 | 同卡复核解阻 | 支持 review_status、field_overrides、复核场景订单归属确认和 READY 流转 | +| M002-V3-CP5 | 同卡复核解阻 | 已完成第一版:支持 review_status、review_resolution.field_overrides[]、复核场景订单归属确认、JSON Pointer 校验和 READY 流转 | | M002-V3-CP6 | P0 fixtures 回归 | 引入 0711 P0 fixtures / validator 作为后端适配测试参考,补充项目级测试 | ## 13. 明确不做 @@ -343,7 +379,7 @@ V3 P0 不做以下事项: ## 14. 当前代码现状提醒 -截至 M002 V3 CP4 落地后,当前后端已经实现: +截至 M002 V3 CP5 落地后,当前后端已经实现: - `S000/S999` 文本结果兼容处理。 - 结构化 `S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务。 @@ -354,13 +390,12 @@ V3 P0 不做以下事项: - 任务列表、订单任务时间线和任务详情顶层透出 `result_type`、`ai_task_type`、`task_subtype`、`route_code`、`system_process_category`。 - `SOURCE_MESSAGE_ONLY` 任务详情透出 `source_message_only_result.result_type`、`route_code`、`agent_assessment`、`notification`、`manual_review` 和 `raw_answer`。 - 业务任务详情按同一 AI 批次透出 `adapter_contract_errors[]` 与 `unhandled_intents[]` 只读展示块。 +- type-known manual review 创建在原业务任务卡上,任务详情返回 `review_status`、`review_resolution` 和 `manual_review`。 +- `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` 支持字段修正、当前订单归属确认、JSON Pointer 到可编辑字段校验、READY 流转、confirmed payload 写入、两条 OPERA 模拟操作创建和审计记录。 - 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。 - SuperAgent 查询上下文接口 1、2,以及邮件会话相关查询。 仍需后续 checkpoint 实现: -- type-known review 同卡解阻、`review_status` 和 `review_resolution.field_overrides[]`。 -- 复核场景订单归属确认。 -- `manual_review.missing_fields[]` 到任务卡可编辑字段白名单的完整映射校验。 - `source_message.source_message_id` 缺失时按 V3 typed `infrastructure_input_error` 结构响应。 - 真实 OPERA / OHIP、普通任务任意切换订单、P0 fixtures / validator 全量回归。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardDraft.java index ccee0e1..25b240b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardDraft.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardDraft.java @@ -11,6 +11,7 @@ public record ReservationTaskCardDraft( String taskCardType, String fieldContractVersion, String aiPayloadJson, + String reviewStatus, LocalDateTime now ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java index 8f12b71..93bbf69 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java @@ -15,6 +15,8 @@ public record ReservationTaskCardSnapshot( String draftPayloadJson, String confirmedPayloadJson, String confirmedBy, - LocalDateTime confirmedAt + LocalDateTime confirmedAt, + String reviewStatus, + String reviewResolutionJson ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationReviewStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationReviewStatus.java new file mode 100644 index 0000000..50a9279 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationReviewStatus.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation 同卡人工复核状态。仅用于 type-known manual_review 任务在原业务任务卡上解阻。 + */ +public enum ReservationReviewStatus { + /** 等待人工在原任务卡上确认缺失字段或订单归属。 */ + PENDING, + /** 人工复核已完成,任务可以进入 READY 后续流程。 */ + RESOLVED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewResolutionFieldOverrideRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewResolutionFieldOverrideRequest.java new file mode 100644 index 0000000..9db79bc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewResolutionFieldOverrideRequest.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 同卡人工复核字段修正请求。field_pointer 使用 RFC 6901 JSON Pointer。 + * + * @param fieldPointer 指向任务卡可编辑字段的 JSON Pointer + * @param value 人工修正后的字段值 + */ +public record ManualReviewResolutionFieldOverrideRequest( + @JsonProperty("field_pointer") + String fieldPointer, + Object value +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewResolutionRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewResolutionRequest.java new file mode 100644 index 0000000..0d9fab2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ManualReviewResolutionRequest.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * type-known manual_review 同卡复核解阻请求。第一版只允许确认当前订单归属,不做普通任务任意切换订单。 + * + * @param confirmedOrderId 用户确认的订单 ID,第一版必须等于当前任务所属订单 + * @param reason 人工复核原因或备注 + * @param fieldOverrides 用户提交的字段修正列表 + */ +public record ManualReviewResolutionRequest( + @JsonProperty("confirmed_order_id") + String confirmedOrderId, + String reason, + @JsonProperty("field_overrides") + List fieldOverrides +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionFieldOverrideResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionFieldOverrideResult.java new file mode 100644 index 0000000..c8cb630 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionFieldOverrideResult.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 同卡人工复核字段修正结果。返回 JSON Pointer 与矩阵 field_path 的对应关系。 + * + * @param fieldPointer 请求中的 JSON Pointer + * @param fieldPath 后端矩阵 field_path + * @param value 人工修正后的字段值 + */ +public record ManualReviewResolutionFieldOverrideResult( + @JsonProperty("field_pointer") + String fieldPointer, + @JsonProperty("field_path") + String fieldPath, + Object value +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionResult.java new file mode 100644 index 0000000..5cd10c7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionResult.java @@ -0,0 +1,33 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * type-known manual_review 同卡复核解阻结果。 + * + * @param taskId 任务 ID + * @param orderId 订单 ID + * @param taskStatus 解阻后的任务状态 + * @param reviewStatus 解阻后的复核状态 + * @param reviewResolution 复核解阻结果 JSON + * @param confirmedPayload 最终确认 payload + * @param operaOperations 复核后生成的 OPERA 模拟操作 + */ +public record ManualReviewResolutionResult( + @JsonProperty("task_id") + String taskId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("task_status") + String taskStatus, + @JsonProperty("review_status") + String reviewStatus, + @JsonProperty("review_resolution") + Object reviewResolution, + @JsonProperty("confirmed_payload") + Object confirmedPayload, + @JsonProperty("opera_operations") + List operaOperations +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java index 6fdfe44..27c3b7c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationSourceMessageOnlyResult.java @@ -3,7 +3,8 @@ package cn.nianxx.thhotel.workflows.reservation.common.result; import com.fasterxml.jackson.annotation.JsonProperty; /** - * S000/S999 特殊入口结果详情。仅用于 SOURCE_MESSAGE_ONLY 任务,不承载普通任务 AI 原始 payload。 + * 来源邮件只读入口结果详情。仅用于 SOURCE_MESSAGE_ONLY 任务,兼容旧 S000/S999 和新 S10/S99, + * 不承载普通任务 AI 原始 payload。 * * @param entryResultCode SuperAgent 入口结果码 * @param entryResultMeaning 入口结果语义 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java index 4b42590..e0f3256 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java @@ -26,6 +26,9 @@ import java.util.List; * @param fieldContractVersion 字段矩阵契约版本 * @param draftPayload 草稿 payload * @param confirmedPayload 最终确认 payload + * @param reviewStatus type-known manual_review 同卡复核状态 + * @param reviewResolution 同卡复核解阻结果 + * @param manualReview AI 原始 manual_review 白名单对象 * @param sourceMessageOnlyResult S000/S999 特殊入口结果,普通任务为空 * @param availability 当前可处理状态 * @param fields 按字段矩阵生成的字段列表 @@ -72,6 +75,12 @@ public record ReservationTaskDetailResult( Object draftPayload, @JsonProperty("confirmed_payload") Object confirmedPayload, + @JsonProperty("review_status") + String reviewStatus, + @JsonProperty("review_resolution") + Object reviewResolution, + @JsonProperty("manual_review") + Object manualReview, @JsonProperty("source_message_only_result") ReservationSourceMessageOnlyResult sourceMessageOnlyResult, ReservationTaskAvailabilityResult availability, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java index faa9949..73ab089 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java @@ -1,9 +1,11 @@ package cn.nianxx.thhotel.workflows.reservation.control; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; @@ -81,6 +83,19 @@ public class ReservationTaskController { return taskWorkflowService.confirmTask(taskId, request); } + /** + * 在原业务任务卡上完成 type-known manual_review 复核,提交字段修正并确认订单归属。 + */ + @PostMapping( + value = "/{taskId}/manual-review-resolutions", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ManualReviewResolutionResult resolveManualReview( + @PathVariable Long taskId, + @RequestBody(required = false) ManualReviewResolutionRequest request) { + return taskWorkflowService.resolveManualReview(taskId, request); + } + /** * 执行一条 OPERA 模拟操作;第一版只写入模拟 attempt,不调用真实 OPERA。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java index d634c64..da5f888 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java @@ -32,6 +32,10 @@ public class ReservationTaskCardEntity { private String confirmedBy; /** 任务卡最终确认 UTC 时间。 */ private LocalDateTime confirmedAt; + /** 同卡人工复核状态,仅 type-known manual_review 任务使用。 */ + private String reviewStatus; + /** 同卡人工复核解阻结果 JSON,不改写 AI 原始快照。 */ + private String reviewResolutionJson; /** 记录创建 UTC 时间。 */ private LocalDateTime createdAt; /** 记录更新 UTC 时间。 */ @@ -57,6 +61,10 @@ public class ReservationTaskCardEntity { public void setConfirmedBy(String confirmedBy) { this.confirmedBy = confirmedBy; } public LocalDateTime getConfirmedAt() { return confirmedAt; } public void setConfirmedAt(LocalDateTime confirmedAt) { this.confirmedAt = confirmedAt; } + public String getReviewStatus() { return reviewStatus; } + public void setReviewStatus(String reviewStatus) { this.reviewStatus = reviewStatus; } + public String getReviewResolutionJson() { return reviewResolutionJson; } + public void setReviewResolutionJson(String reviewResolutionJson) { this.reviewResolutionJson = reviewResolutionJson; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index ae256d2..f68d7fd 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -263,6 +263,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.setTaskCardType(draft.taskCardType()); entity.setFieldContractVersion(draft.fieldContractVersion()); entity.setAiPayloadJson(draft.aiPayloadJson()); + entity.setReviewStatus(draft.reviewStatus()); entity.setCreatedAt(draft.now()); entity.setUpdatedAt(draft.now()); taskCardMapper.insert(entity); @@ -671,6 +672,41 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork return updatedCardCount > 0; } + /** + * 保存同卡人工复核解阻结果和最终确认 payload,并将任务状态从 PENDING_CONFIRM 流转为 READY。 + */ + @Override + public boolean resolveManualReview( + String hotelId, + Long taskId, + String reviewStatus, + String reviewResolutionJson, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt) { + int updatedTaskCount = taskMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationTaskEntity::getTaskStatus, ReservationTaskStatus.READY.name()) + .set(ReservationTaskEntity::getConfirmedAt, confirmedAt) + .set(ReservationTaskEntity::getUpdatedAt, confirmedAt) + .setSql("version = version + 1") + .eq(ReservationTaskEntity::getHotelId, hotelId) + .eq(ReservationTaskEntity::getId, taskId) + .eq(ReservationTaskEntity::getTaskStatus, ReservationTaskStatus.PENDING_CONFIRM.name())); + if (updatedTaskCount == 0) { + return false; + } + int updatedCardCount = taskCardMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationTaskCardEntity::getReviewStatus, reviewStatus) + .set(ReservationTaskCardEntity::getReviewResolutionJson, reviewResolutionJson) + .set(ReservationTaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson) + .set(ReservationTaskCardEntity::getConfirmedBy, confirmedBy) + .set(ReservationTaskCardEntity::getConfirmedAt, confirmedAt) + .set(ReservationTaskCardEntity::getUpdatedAt, confirmedAt) + .eq(ReservationTaskCardEntity::getHotelId, hotelId) + .eq(ReservationTaskCardEntity::getTaskId, taskId)); + return updatedCardCount > 0; + } + /** * 插入 OPERA 模拟操作。 */ @@ -1106,7 +1142,9 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.getDraftPayloadJson(), entity.getConfirmedPayloadJson(), entity.getConfirmedBy(), - entity.getConfirmedAt()); + entity.getConfirmedAt(), + entity.getReviewStatus(), + entity.getReviewResolutionJson()); } /** diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java index a95873e..cd7a739 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -205,6 +205,18 @@ public interface ReservationAiWorkflowRepository { String confirmedBy, LocalDateTime confirmedAt); + /** + * 保存同卡人工复核解阻结果、最终确认 payload,并将任务状态从 PENDING_CONFIRM 流转为 READY。 + */ + boolean resolveManualReview( + String hotelId, + Long taskId, + String reviewStatus, + String reviewResolutionJson, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt); + /** * 新增 OPERA 模拟操作,并返回操作 ID。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java index b925a67..898d368 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java @@ -1,9 +1,11 @@ package cn.nianxx.thhotel.workflows.reservation.service; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; @@ -34,6 +36,11 @@ public interface ReservationTaskWorkflowService { */ ReservationTaskPayloadMutationResult confirmTask(Long taskId, ReservationTaskPayloadMutationRequest request); + /** + * 在原业务任务卡上完成 type-known manual_review 复核,并将任务解阻为 READY。 + */ + ManualReviewResolutionResult resolveManualReview(Long taskId, ManualReviewResolutionRequest request); + /** * 执行一条 OPERA 模拟操作;只允许前置模拟操作已成功的操作执行。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java index 242c287..71e13e1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -17,6 +17,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessK 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.ReservationReviewStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; @@ -488,7 +489,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta } /** - * 校验 V3 type-known manual_review 的九字段基础结构,完整字段解阻逻辑后续 checkpoint 再做。 + * 校验 V3 type-known manual_review 的九字段基础结构,后续同卡复核解阻会继续校验缺失字段。 */ private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview) { if (!manualReview.isObject()) { @@ -724,6 +725,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(), FIELD_CONTRACT_VERSION, itemPayloadJson, + null, now )); return new SuperAgentTaskResultItemResponse( @@ -901,6 +903,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta mapping.taskCardType().name(), FIELD_CONTRACT_VERSION, itemPayloadJson, + initialReviewStatus(resultType, mapping), now )); @@ -1158,6 +1161,18 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta }; } + /** + * 仅 type-known manual_review 业务任务需要在原任务卡上进入待复核状态,Fallback 转换任务继续走原人工转换流程。 + */ + private String initialReviewStatus(String resultType, TaskTypeMapping mapping) { + if (!AiResultType.MANUAL_REVIEW.code().equals(resultType)) { + return null; + } + return mapping.systemTaskType() == ReservationSystemTaskType.MANUAL_REVIEW + ? null + : ReservationReviewStatus.PENDING.name(); + } + /** * 解析 SuperAgent 入口阶段的纯文本结果。兼容旧 S000/S999,也允许调试期文本 S10/S99。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index b951809..f1475a6 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse; import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; @@ -13,19 +14,25 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapsh import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardFieldDefinition; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessKeySource; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOperaAttemptStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOperaOperationStatus; 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.ReservationReviewStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionFieldOverrideRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionFieldOverrideResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiTransitionDisplayResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationAttemptResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; @@ -65,6 +72,7 @@ import org.springframework.transaction.annotation.Transactional; public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkflowService { private static final String ACTION_MANUAL_REVIEW_CONVERT = "MANUAL_REVIEW_CONVERT"; + private static final String ACTION_MANUAL_REVIEW_RESOLVE = "MANUAL_REVIEW_RESOLVE"; private static final String ACTION_TASK_DRAFT_SAVE = "TASK_DRAFT_SAVE"; private static final String ACTION_TASK_CONFIRM = "TASK_CONFIRM"; private static final String ACTION_OPERA_OPERATION_EXECUTE = "OPERA_OPERATION_EXECUTE"; @@ -74,6 +82,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl private static final String PAYLOAD_SCHEMA_VERSION = "field_path-v1"; private static final String PAYLOAD_SOURCE_DRAFT = "TASK_DRAFT"; private static final String PAYLOAD_SOURCE_CONFIRMATION = "TASK_CONFIRMATION"; + private static final String PAYLOAD_SOURCE_MANUAL_REVIEW_RESOLUTION = "MANUAL_REVIEW_RESOLUTION"; private static final String OPERA_OPERATION_CODE_PRECHECK = "SIMULATE_PRECHECK"; private static final String OPERA_OPERATION_CODE_WRITE = "SIMULATE_WRITE"; private static final int MAX_QUEUE_ORDER_RETRY = 5; @@ -137,6 +146,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl taskCard.fieldContractVersion(), jsonPayloadToObject(taskCard.draftPayloadJson()), jsonPayloadToObject(taskCard.confirmedPayloadJson()), + reviewStatusForDetail(task, taskCard), + jsonPayloadToObject(taskCard.reviewResolutionJson()), + manualReviewForDetail(task, taskCard), sourceMessageOnlyResult, availability, fields, @@ -229,6 +241,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ReservationTaskSnapshot task = findTaskOrThrow(taskId); ReservationTaskCardSnapshot taskCard = findTaskCardOrThrow(task); ensureTaskEditable(task); + ensureGenericConfirmAllowed(task, taskCard); JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); Map submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues()); @@ -268,6 +281,68 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl operaOperations); } + /** + * 在原业务任务卡上完成 type-known manual_review 复核。字段修正写入确认 payload,不改写 AI 原始快照。 + */ + @Override + @Transactional + public ManualReviewResolutionResult resolveManualReview( + Long taskId, + ManualReviewResolutionRequest request) { + ReservationTaskSnapshot task = findTaskOrThrow(taskId); + ReservationTaskCardSnapshot taskCard = findTaskCardOrThrow(task); + ensureTypeKnownManualReviewTask(task, taskCard); + ensureTaskEditable(task); + validateConfirmedOrder(task, request == null ? null : request.confirmedOrderId()); + + JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); + Map draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson()); + List activeDefinitions = activeDefinitions(task, aiPayload, draftValues); + List fieldOverrides = + resolveFieldOverrides(reviewPointerDefinitions(task), request == null ? null : request.fieldOverrides()); + Map submittedValues = fieldOverrideValues(fieldOverrides); + Map editedValues = mergeFieldValues(draftValues, submittedValues); + activeDefinitions = activeDefinitions(task, aiPayload, editedValues); + List validationErrors = validateSubmittedFields(activeDefinitions, submittedValues); + validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues, + submittedValues.keySet(), false)); + validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues, + definitionFieldPaths(activeDefinitions), true, true)); + validationErrors.addAll(validateManualReviewMissingFieldsResolved(aiPayload, activeDefinitions, editedValues)); + validationErrors.addAll(validateDateRange(activeDefinitions, aiPayload, editedValues)); + if (!validationErrors.isEmpty()) { + throw validationError(validationErrors); + } + + LocalDateTime now = nowUtc(); + Map confirmedValues = buildConfirmedFieldValues(activeDefinitions, aiPayload, editedValues); + String confirmedPayloadJson = toPayloadJson(PAYLOAD_SOURCE_MANUAL_REVIEW_RESOLUTION, confirmedValues, now); + String reviewResolutionJson = toReviewResolutionJson(task, request, fieldOverrides, now); + boolean resolved = workflowRepository.resolveManualReview( + task.hotelId(), + task.id(), + ReservationReviewStatus.RESOLVED.name(), + reviewResolutionJson, + confirmedPayloadJson, + ACTOR_ID_LOCAL, + now); + if (!resolved) { + throw error(HttpStatus.CONFLICT, "TASK_REVIEW_RESOLVE_CONFLICT", "任务状态已变化,无法完成复核。"); + } + writeManualReviewResolveAudit(task, taskCard, reviewResolutionJson, request, now); + ReservationTaskSnapshot resolvedTask = findTaskOrThrow(taskId); + List operaOperations = + createDefaultOperaOperations(resolvedTask, confirmedPayloadJson, now); + return new ManualReviewResolutionResult( + resolvedTask.id().toString(), + resolvedTask.orderId().toString(), + resolvedTask.taskStatus(), + ReservationReviewStatus.RESOLVED.name(), + jsonPayloadToObject(reviewResolutionJson), + jsonPayloadToObject(confirmedPayloadJson), + operaOperations); + } + /** * 执行 OPERA 模拟操作。第一版不接真实 OPERA,只根据请求开关生成模拟结果和 attempt。 */ @@ -314,6 +389,13 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ReservationTaskSnapshot task, String confirmedPayloadJson, LocalDateTime now) { + List existingOperations = + workflowRepository.findOperaOperationsByTaskId(task.hotelId(), task.id()); + if (!existingOperations.isEmpty()) { + return existingOperations.stream() + .map(operation -> toOperaOperationResult(operation, List.of())) + .toList(); + } List drafts = List.of( new ReservationOperaOperationDraft( task.hotelId(), @@ -662,6 +744,283 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } } + /** + * 校验当前任务是已知业务类型的 manual_review,同卡解阻不处理 Fallback 人工转换任务。 + */ + private void ensureTypeKnownManualReviewTask( + ReservationTaskSnapshot task, + ReservationTaskCardSnapshot taskCard) { + if (!isTypeKnownManualReviewTask(task) && trimToNull(taskCard.reviewStatus()) == null) { + throw error(HttpStatus.BAD_REQUEST, "TASK_NOT_TYPE_KNOWN_MANUAL_REVIEW", "当前任务不是同卡人工复核任务。"); + } + } + + /** + * 通用确认接口不能处理 type-known manual_review,避免绕过复核解阻审计和 field_overrides 保存。 + */ + private void ensureGenericConfirmAllowed( + ReservationTaskSnapshot task, + ReservationTaskCardSnapshot taskCard) { + if (isTypeKnownManualReviewTask(task) || trimToNull(taskCard.reviewStatus()) != null) { + throw error( + HttpStatus.BAD_REQUEST, + "TASK_REVIEW_RESOLUTION_REQUIRED", + "type-known manual_review 任务必须通过复核解阻接口确认。"); + } + } + + /** + * 判断任务是否为已知业务类型的 manual_review,而非 Fallback 人工转换任务。 + */ + private boolean isTypeKnownManualReviewTask(ReservationTaskSnapshot task) { + return AiResultType.MANUAL_REVIEW.code().equals(task.resultType()) + && !ReservationSystemTaskType.MANUAL_REVIEW.name().equals(task.systemTaskType()); + } + + /** + * 校验复核确认的订单归属。第一版只允许确认当前订单,不提供普通任务任意切换订单能力。 + */ + private void validateConfirmedOrder(ReservationTaskSnapshot task, String rawConfirmedOrderId) { + String confirmedOrderIdText = trimToNull(rawConfirmedOrderId); + if (confirmedOrderIdText == null) { + throw error(HttpStatus.BAD_REQUEST, "REVIEW_ORDER_REQUIRED", "confirmed_order_id 必填。"); + } + Long confirmedOrderId = parseLong(confirmedOrderIdText, "REVIEW_ORDER_INVALID", "confirmed_order_id 格式无效。"); + if (!task.orderId().equals(confirmedOrderId)) { + throw new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "REVIEW_ORDER_ASSIGNMENT_MISMATCH", + "复核确认订单与当前任务订单不一致。", + List.of("confirmed_order_id: 第一版必须等于当前任务所属订单。")); + } + } + + /** + * 将复核字段 JSON Pointer 校验并映射到矩阵 field_path,只允许当前任务卡可编辑字段。 + */ + private List resolveFieldOverrides( + List activeDefinitions, + List requests) { + if (requests == null || requests.isEmpty()) { + return List.of(); + } + Map definitionByPath = new LinkedHashMap<>(); + activeDefinitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition)); + List results = new ArrayList<>(); + for (ManualReviewResolutionFieldOverrideRequest request : requests) { + String fieldPointer = trimToNull(request == null ? null : request.fieldPointer()); + ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer); + results.add(new ManualReviewResolutionFieldOverrideResult( + fieldPointer, + definition.fieldPath(), + request.value())); + } + return results; + } + + /** + * 复核 pointer 白名单使用当前卡型的可编辑字段全集;展示条件由合并后的 activeDefinitions 再兜底校验。 + */ + private List reviewPointerDefinitions(ReservationTaskSnapshot task) { + return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()) + .stream() + .filter(definition -> isYes(definition.visible())) + .filter(definition -> matchesTaskResultType(definition.resultType(), task)) + .filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType())) + .filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype())) + .toList(); + } + + /** + * 按 JSON Pointer 查找可编辑字段定义。 + */ + private ReservationTaskCardFieldDefinition findEditableDefinitionByPointer( + Map definitionByPath, + String fieldPointer) { + if (fieldPointer == null || !fieldPointer.startsWith("/")) { + throw reviewPointerError(fieldPointer); + } + for (String fieldPath : fieldPathCandidatesFromPointer(fieldPointer)) { + ReservationTaskCardFieldDefinition definition = definitionByPath.get(fieldPath); + if (definition != null && isYes(definition.editable())) { + return definition; + } + if (definition != null) { + throw reviewPointerError(fieldPointer); + } + } + throw reviewPointerError(fieldPointer); + } + + /** + * 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 的映射。 + */ + private List fieldPathCandidatesFromPointer(String fieldPointer) { + String[] rawTokens = fieldPointer.substring(1).split("/", -1); + List plainTokens = new ArrayList<>(); + List collapsedArrayTokens = new ArrayList<>(); + for (String rawToken : rawTokens) { + String token = unescapeJsonPointerToken(rawToken, fieldPointer); + if (token.isEmpty()) { + throw reviewPointerError(fieldPointer); + } + plainTokens.add(token); + if (isNumericToken(token)) { + if (collapsedArrayTokens.isEmpty()) { + throw reviewPointerError(fieldPointer); + } + int lastIndex = collapsedArrayTokens.size() - 1; + String previous = collapsedArrayTokens.get(lastIndex); + collapsedArrayTokens.set(lastIndex, previous.endsWith("[]") ? previous : previous + "[]"); + } else { + collapsedArrayTokens.add(token); + } + } + List candidates = new ArrayList<>(); + candidates.add(String.join(".", plainTokens)); + String collapsed = String.join(".", collapsedArrayTokens); + if (!collapsed.equals(candidates.get(0))) { + candidates.add(collapsed); + } + return candidates; + } + + /** + * 解析 JSON Pointer token,拒绝非法的 ~ 转义。 + */ + private String unescapeJsonPointerToken(String rawToken, String fieldPointer) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < rawToken.length(); i++) { + char current = rawToken.charAt(i); + if (current != '~') { + builder.append(current); + continue; + } + if (i + 1 >= rawToken.length()) { + throw reviewPointerError(fieldPointer); + } + char escaped = rawToken.charAt(++i); + if (escaped == '0') { + builder.append('~'); + } else if (escaped == '1') { + builder.append('/'); + } else { + throw reviewPointerError(fieldPointer); + } + } + return builder.toString(); + } + + /** + * 判断 JSON Pointer token 是否为数组下标。 + */ + private boolean isNumericToken(String token) { + if (token == null || token.isBlank()) { + return false; + } + for (int i = 0; i < token.length(); i++) { + if (!Character.isDigit(token.charAt(i))) { + return false; + } + } + return true; + } + + /** + * 将复核字段修正结果转换为 field_path -> value,用于复用现有字段矩阵校验。 + */ + private Map fieldOverrideValues( + List fieldOverrides) { + Map values = new LinkedHashMap<>(); + for (ManualReviewResolutionFieldOverrideResult fieldOverride : fieldOverrides) { + values.put(fieldOverride.fieldPath(), fieldOverride.value()); + } + return values; + } + + /** + * 校验 SuperAgent 明确标记的缺失字段已在复核解阻后有值,避免只靠矩阵必填遗漏业务阻塞点。 + */ + private List validateManualReviewMissingFieldsResolved( + JsonNode aiPayload, + List activeDefinitions, + Map editedValues) { + JsonNode missingFieldsNode = aiPayload.path("manual_review").path("missing_fields"); + if (!missingFieldsNode.isArray() || missingFieldsNode.isEmpty()) { + return List.of(); + } + Map definitionByPath = new LinkedHashMap<>(); + activeDefinitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition)); + List errors = new ArrayList<>(); + for (JsonNode missingFieldNode : missingFieldsNode) { + String fieldPointer = trimToNull(missingFieldNode.asText(null)); + ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer); + if (isEmptyValue(editedValues.get(definition.fieldPath()))) { + errors.add(fieldPointer + ": 复核缺失字段仍未补齐。"); + } + } + return errors; + } + + /** + * 构建同卡人工复核解阻结果 JSON,保存人工修正和订单归属确认,不写回 AI 原始 payload。 + */ + private String toReviewResolutionJson( + ReservationTaskSnapshot task, + ManualReviewResolutionRequest request, + List fieldOverrides, + LocalDateTime now) { + Map payload = new LinkedHashMap<>(); + payload.put("schema_version", "manual-review-resolution-v1"); + payload.put("resolved_at", UtcTimeFormatter.toUtcOffsetDateTime(now).toString()); + payload.put("resolved_by", ACTOR_ID_LOCAL); + payload.put("confirmed_order_id", task.orderId().toString()); + payload.put("reason", request == null ? null : trimToNull(request.reason())); + payload.put("field_overrides", fieldOverrides); + return toJson(payload); + } + + /** + * 写入同卡人工复核解阻审计。审计只保存复核摘要,完整字段值保存在任务卡确认 payload。 + */ + private void writeManualReviewResolveAudit( + ReservationTaskSnapshot task, + ReservationTaskCardSnapshot taskCard, + String reviewResolutionJson, + ManualReviewResolutionRequest request, + LocalDateTime now) { + Map beforeSnapshot = new LinkedHashMap<>(); + beforeSnapshot.put("review_status", taskCard.reviewStatus()); + beforeSnapshot.put("has_confirmed_payload", trimToNull(taskCard.confirmedPayloadJson()) != null); + Map afterSnapshot = new LinkedHashMap<>(); + afterSnapshot.put("review_status", ReservationReviewStatus.RESOLVED.name()); + afterSnapshot.put("resolution", jsonPayloadToObject(reviewResolutionJson)); + workflowRepository.insertAuditLog(new ReservationAuditLogDraft( + task.hotelId(), + task.orderId(), + task.id(), + null, + ACTOR_TYPE_USER, + ACTOR_ID_LOCAL, + ACTION_MANUAL_REVIEW_RESOLVE, + request == null ? null : trimToNull(request.reason()), + toJson(beforeSnapshot), + toJson(afterSnapshot), + now)); + } + + /** + * 构建 JSON Pointer 错误,details 保留原始 pointer 方便前端定位。 + */ + private ReservationTaskWorkflowException reviewPointerError(String fieldPointer) { + String pointer = fieldPointer == null ? "" : fieldPointer; + return new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "TASK_REVIEW_POINTER_INVALID", + "复核字段指针无效。", + List.of(pointer + ": 必须指向当前任务卡允许编辑字段。")); + } + /** * 标准化请求中的 field_values。空请求按空草稿处理。 */ @@ -695,7 +1054,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()) .stream() .filter(definition -> isYes(definition.visible())) - .filter(definition -> matchesMatrixExpression(definition.resultType(), task.resultType())) + .filter(definition -> matchesTaskResultType(definition.resultType(), task)) .filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType())) .filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype())) .filter(definition -> displayConditionMatches(task, definition, aiPayload, editedValues)) @@ -734,10 +1093,34 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl Map editedValues, Set fieldPathsToValidate, boolean validateRequired) { + return validateDefinitionValues( + task, + activeDefinitions, + aiPayload, + editedValues, + fieldPathsToValidate, + validateRequired, + false); + } + + /** + * 按字段矩阵执行校验。复核解阻场景允许只读证据字段缺失,因为用户无法在该接口补齐。 + */ + private List validateDefinitionValues( + ReservationTaskSnapshot task, + List activeDefinitions, + JsonNode aiPayload, + Map editedValues, + Set fieldPathsToValidate, + boolean validateRequired, + boolean allowMissingReadonlyRequired) { List errors = new ArrayList<>(); for (ReservationTaskCardFieldDefinition definition : activeDefinitions) { Object value = valueForField(aiPayload, editedValues, definition.fieldPath()); if (validateRequired && isMissingRequired(task, definition, aiPayload, editedValues, value)) { + if (allowMissingReadonlyRequired && !isYes(definition.editable())) { + continue; + } errors.add(definition.fieldPath() + ": 必填字段缺失。"); continue; } @@ -856,7 +1239,33 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } /** - * 只为 S000/S999 特殊任务透出入口结果,避免普通任务详情暴露完整 AI 原始 payload。 + * 任务详情顶层复核状态。仅 type-known manual_review 业务任务默认展示 PENDING。 + */ + private String reviewStatusForDetail(ReservationTaskSnapshot task, ReservationTaskCardSnapshot taskCard) { + if (!AiResultType.MANUAL_REVIEW.code().equals(task.resultType()) + || ReservationSystemTaskType.MANUAL_REVIEW.name().equals(task.systemTaskType())) { + return null; + } + String reviewStatus = trimToNull(taskCard.reviewStatus()); + return reviewStatus == null ? ReservationReviewStatus.PENDING.name() : reviewStatus; + } + + /** + * 任务详情透出 AI manual_review 白名单对象,供前端展示缺失字段和人工复核原因。 + */ + private Object manualReviewForDetail(ReservationTaskSnapshot task, ReservationTaskCardSnapshot taskCard) { + if (!AiResultType.MANUAL_REVIEW.code().equals(task.resultType())) { + return null; + } + JsonNode manualReviewNode = parseJson(taskCard.aiPayloadJson()).path("manual_review"); + if (manualReviewNode.isMissingNode() || manualReviewNode.isNull()) { + return null; + } + return objectMapper.convertValue(manualReviewNode, Object.class); + } + + /** + * 只为来源邮件只读任务透出入口结果,兼容旧 S000/S999 和新 S10/S99,不暴露普通任务完整 AI 原始 payload。 */ private ReservationSourceMessageOnlyResult buildSourceMessageOnlyResult( ReservationTaskSnapshot task, @@ -927,7 +1336,40 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl transition.systemProcessCategory(), transition.adapterErrorCode(), transition.adapterErrorMessage(), - jsonPayloadToObject(transition.aiPayloadJson())); + diagnosticPayloadFragment(transition.aiPayloadJson())); + } + + /** + * 从 AI item 中提取前端可展示的诊断白名单字段,避免泄露完整业务字段、附件和上下文。 + */ + private Object diagnosticPayloadFragment(String aiPayloadJson) { + if (trimToNull(aiPayloadJson) == null) { + return null; + } + JsonNode payload = parseJson(aiPayloadJson); + Map fragment = new LinkedHashMap<>(); + copyDiagnosticPayloadField(payload, fragment, "event_type"); + copyDiagnosticPayloadField(payload, fragment, "source_event_index"); + copyDiagnosticPayloadField(payload, fragment, "current_or_history"); + copyDiagnosticPayloadField(payload, fragment, "contract_errors"); + copyDiagnosticPayloadField(payload, fragment, "intent_type"); + copyDiagnosticPayloadField(payload, fragment, "reason_code"); + copyDiagnosticPayloadField(payload, fragment, "missing_fields"); + copyDiagnosticPayloadField(payload, fragment, "parent_source_event_index"); + copyDiagnosticPayloadField(payload, fragment, "linked_task_group_id"); + copyDiagnosticPayloadField(payload, fragment, "blocked_until_parent_completed"); + return fragment; + } + + /** + * 复制单个诊断字段到展示片段,忽略空值和不存在字段。 + */ + private void copyDiagnosticPayloadField(JsonNode payload, Map fragment, String fieldName) { + JsonNode value = payload == null ? null : payload.get(fieldName); + if (value == null || value.isNull() || value.isMissingNode()) { + return; + } + fragment.put(fieldName, objectMapper.convertValue(value, Object.class)); } /** @@ -1024,6 +1466,18 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return false; } + /** + * type-known manual_review 在原业务任务卡上复核,因此字段矩阵复用 normal_task 的业务卡规则。 + */ + private boolean matchesTaskResultType(String matrixResultType, ReservationTaskSnapshot task) { + if (AiResultType.MANUAL_REVIEW.code().equals(task.resultType()) + && !ReservationSystemTaskType.MANUAL_REVIEW.name().equals(task.systemTaskType()) + && matchesMatrixExpression(matrixResultType, AiResultType.NORMAL_TASK.code())) { + return true; + } + return matchesMatrixExpression(matrixResultType, task.resultType()); + } + /** * 按当前已知矩阵展示条件判断字段是否参与本次后端校验。 */ diff --git a/server/src/main/resources/db/migration/V17__add_manual_review_resolution_to_task_card.sql b/server/src/main/resources/db/migration/V17__add_manual_review_resolution_to_task_card.sql new file mode 100644 index 0000000..3f2bf4e --- /dev/null +++ b/server/src/main/resources/db/migration/V17__add_manual_review_resolution_to_task_card.sql @@ -0,0 +1,8 @@ +ALTER TABLE workflow_reservation_task_card + ADD COLUMN review_status VARCHAR(32) NULL COMMENT '同卡人工复核状态:PENDING、RESOLVED,仅 type-known manual_review 任务使用'; + +ALTER TABLE workflow_reservation_task_card + ADD COLUMN review_resolution_json LONGTEXT NULL COMMENT '同卡人工复核解阻结果 JSON,保存字段修正、订单归属确认、操作人和时间'; + +CREATE INDEX idx_reservation_task_card_review_status + ON workflow_reservation_task_card (hotel_id, review_status); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index d2706a1..82b2b71 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -1141,6 +1141,8 @@ class SuperAgentTaskResultControllerTest { .value("EVENT_CONTRACT_ERROR")) .andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.contract_errors[0].code") .value("unsupported_contract")) + .andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.case_keys").doesNotExist()) + .andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.extracted_fields").doesNotExist()) .andExpect(jsonPath("$.unhandled_intents.length()").value(1)) .andExpect(jsonPath("$.unhandled_intents[0].route_code") .value("R42_UNHANDLED_CURRENT_INTENT")) @@ -1730,6 +1732,258 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.error_code").value("TASK_READ_ONLY")); } + @Test + void shouldResolveTypeKnownManualReviewOnSameTaskAndMoveToReady() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-resolve-001"); + String body = typeKnownManualReviewBody( + "mail-v3-manual-review-resolve-001", + "CNF-V3-MR-RESOLVE-001", + "2026-09-01", + "2026-09-03"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-resolve-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_REVIEW")) + .andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING")) + .andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].order_id"); + Long sourceTaskCountBefore = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result_type").value("manual_review")) + .andExpect(jsonPath("$.review_status").value("PENDING")) + .andExpect(jsonPath("$.manual_review.reason_code").value("missing_room_type_code")) + .andExpect(jsonPath("$.manual_review.missing_fields[0]").value("/extracted_fields/pms_room_type_code")); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "reason": "确认 PMS 房型代码后解阻。", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "value": "RM3" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(taskId)) + .andExpect(jsonPath("$.order_id").value(orderId)) + .andExpect(jsonPath("$.task_status").value("READY")) + .andExpect(jsonPath("$.review_status").value("RESOLVED")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer") + .value("/extracted_fields/pms_room_type_code")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path") + .value("extracted_fields.pms_room_type_code")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].value").value("RM3")) + .andExpect(jsonPath("$.review_resolution.resolved_at").value(matchesPattern(UTC_INSTANT_PATTERN))) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']") + .value("RM3")) + .andExpect(jsonPath("$.opera_operations.length()").value(2)); + + Long sourceTaskCountAfter = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + String taskStatus = jdbcTemplate.queryForObject(""" + SELECT task_status + FROM workflow_reservation_task + WHERE id = ? + """, String.class, Long.valueOf(taskId)); + String reviewStatus = jdbcTemplate.queryForObject(""" + SELECT review_status + FROM workflow_reservation_task_card + WHERE task_id = ? + """, String.class, Long.valueOf(taskId)); + String reviewResolutionJson = jdbcTemplate.queryForObject(""" + SELECT review_resolution_json + FROM workflow_reservation_task_card + WHERE task_id = ? + """, String.class, Long.valueOf(taskId)); + String aiPayloadJson = jdbcTemplate.queryForObject(""" + SELECT ai_payload_json + FROM workflow_reservation_task_card + WHERE task_id = ? + """, String.class, Long.valueOf(taskId)); + Long auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_audit_log + WHERE task_id = ? + AND action = 'MANUAL_REVIEW_RESOLVE' + """, Long.class, Long.valueOf(taskId)); + assertThat(sourceTaskCountAfter).isEqualTo(sourceTaskCountBefore); + assertThat(taskStatus).isEqualTo("READY"); + assertThat(reviewStatus).isEqualTo("RESOLVED"); + assertThat(reviewResolutionJson).contains("\"field_pointer\":\"/extracted_fields/pms_room_type_code\""); + assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"RM3\""); + assertThat(auditCount).isEqualTo(1L); + } + + @Test + void shouldRejectGenericConfirmForTypeKnownManualReview() throws Exception { + captureSourceMessage("mail-v3-manual-review-confirm-001"); + String body = typeKnownManualReviewBody( + "mail-v3-manual-review-confirm-001", + "CNF-V3-MR-CONFIRM-001", + "2026-09-04", + "2026-09-06"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-confirm-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.pms_room_type_code": "RM3" + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_RESOLUTION_REQUIRED")); + + String taskStatus = jdbcTemplate.queryForObject(""" + SELECT task_status + FROM workflow_reservation_task + WHERE id = ? + """, String.class, Long.valueOf(taskId)); + assertThat(taskStatus).isEqualTo("PENDING_CONFIRM"); + } + + @Test + void shouldRejectManualReviewResolutionWhenPointerIsNotEditable() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-pointer-001"); + String body = typeKnownManualReviewBody( + "mail-v3-manual-review-pointer-001", + "CNF-V3-MR-POINTER-001", + "2026-09-05", + "2026-09-07"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-pointer-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].order_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/manual_review/visible_reason", + "value": "不允许覆盖复核说明" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID")) + .andExpect(jsonPath("$.details[0]").value(containsString("/manual_review/visible_reason"))); + + Long sourceTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(sourceTaskCount).isEqualTo(1L); + } + + @Test + void shouldRejectManualReviewResolutionWhenMissingFieldsAreNotResolved() throws Exception { + captureSourceMessage("mail-v3-manual-review-missing-001"); + String body = typeKnownManualReviewBody( + "mail-v3-manual-review-missing-001", + "CNF-V3-MR-MISSING-001", + "2026-09-06", + "2026-09-08"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-missing-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].order_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [] + } + """.formatted(orderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED")) + .andExpect(content().string(containsString("/extracted_fields/pms_room_type_code"))); + } + + @Test + void shouldRejectManualReviewResolutionWhenConfirmedOrderDoesNotMatchCurrentTask() throws Exception { + SourceMessageCaptureResult firstSource = captureSourceMessage("mail-v3-manual-review-order-001"); + String body = typeKnownManualReviewBody( + "mail-v3-manual-review-order-001", + "CNF-V3-MR-ORDER-001", + "2026-09-09", + "2026-09-11"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-order-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id"); + + SourceMessageCaptureResult otherSource = captureSourceMessage("mail-v3-manual-review-other-order-001"); + String otherBody = completeNewBookingBody(otherSource.inboxId().toString(), "CNF-V3-MR-OTHER-001"); + MvcResult otherResult = mockMvc.perform(signedPost(otherBody, "nonce-v3-manual-review-other-order-001")) + .andExpect(status().isCreated()) + .andReturn(); + String otherOrderId = com.jayway.jsonpath.JsonPath.read( + otherResult.getResponse().getContentAsString(), + "$.items[0].order_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/pms_room_type_code", + "value": "RM3" + } + ] + } + """.formatted(otherOrderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("REVIEW_ORDER_ASSIGNMENT_MISMATCH")); + } + @Test void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception { SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001"); @@ -2120,6 +2374,68 @@ class SuperAgentTaskResultControllerTest { """.formatted(caseKeys)); } + private String typeKnownManualReviewBody( + String externalSourceMessageId, + String confirmationNumber, + String arrivalDate, + String departureDate) { + return """ + { + "source_message": { + "source_message_id": "%s", + "subject": "Manual review new booking", + "from": null, + "cc": [], + "received_at": null, + "source_channel": "Email" + }, + "message_events": [ + { + "event_type": "New Booking", + "source_event_index": "E1", + "current_or_history": "current", + "case_keys": { + "group_code": null, + "confirmation_number": "%s" + }, + "extracted_fields": { + "booking_object_type": "FIT Reservation", + "arrival_date": "%s", + "departure_date": "%s", + "room_quantity": 2, + "room_type": "Deluxe King" + }, + "manual_review": { + "review_record_type": "business_event_review", + "reason_code": "missing_room_type_code", + "visible_reason": "需要人工确认 PMS 房型代码。", + "known_fields": { + "/extracted_fields/booking_object_type": "FIT Reservation", + "/extracted_fields/room_type": "Deluxe King" + }, + "missing_fields": [ + "/extracted_fields/pms_room_type_code" + ], + "blocking_points": [ + "缺少 PMS 房型代码,不能进入 OPERA 写入。" + ], + "conflicting_points": [], + "suggested_human_actions": [ + "查看邮件和附件后补充 PMS 房型代码。" + ], + "evidence_to_check": [ + "邮件正文房型描述" + ] + } + } + ], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] + } + """.formatted(externalSourceMessageId, confirmationNumber, arrivalDate, departureDate); + } + private String[] createReadyTaskWithTwoOperaOperations( String externalMessageId, String nonce,