修复M002 V3字段矩阵迁移问题
This commit is contained in:
@@ -168,17 +168,19 @@ POST /api/auth/logout
|
||||
```
|
||||
|
||||
- `field_pointer` 必须是 RFC 6901 JSON Pointer,并且只能指向当前任务卡可编辑字段;后端会映射到矩阵 `field_path`。非法或只读字段会返回 `TASK_REVIEW_POINTER_INVALID`。
|
||||
- 复核解阻也可以提交 `field_path`,支持 P0 主路径和旧扁平路径;如果同时提交 `field_pointer` 和 `field_path`,两者必须指向同一个字段。前端新页面优先用任务详情 `fields[].field_pointer`,无法方便处理 JSON Pointer 时可用 `fields[].field_path`。
|
||||
- 0711 P0 的房型字段主路径已迁移到 `room_items[0]`。任务详情 `fields[]` 中,房量、房型原文、PMS 房型代码分别返回:
|
||||
- `field_path=extracted_fields.room_items.0.room_quantity`,`field_pointer=/extracted_fields/room_items/0/room_quantity`
|
||||
- `field_path=extracted_fields.room_items.0.room_type_raw`,`field_pointer=/extracted_fields/room_items/0/room_type_raw`
|
||||
- `field_path=extracted_fields.room_items.0.pms_room_type_code`,`field_pointer=/extracted_fields/room_items/0/pms_room_type_code`
|
||||
- `legacy_field_path` 仅用于前端过渡显示旧扁平字段;新页面保存草稿、最终确认和复核解阻应优先提交 `field_pointer` 或 P0 主 `field_path`。
|
||||
- 后端仍兼容旧提交 key:`extracted_fields.room_quantity`、`extracted_fields.room_type`、`extracted_fields.pms_room_type_code`,但响应会归一化到 P0 主 `field_path`。`confirmed_payload.legacy_field_values` / `draft_payload.legacy_field_values` 只供旧前端回显,不作为新逻辑判断依据。
|
||||
- 后端仍兼容旧提交 key:`extracted_fields.room_quantity`、`extracted_fields.room_type`、`extracted_fields.pms_room_type_code`;同卡复核也兼容旧 `field_path` / 旧 pointer。响应会归一化到 P0 主 `field_path`;当只提交 `field_path` 时,响应里的 `field_pointer` 使用 P0 主 JSON Pointer。`confirmed_payload.legacy_field_values` / `draft_payload.legacy_field_values` 只供旧前端回显,不作为新逻辑判断依据。
|
||||
- 当前第一版只支持 `room_items[0]`;`/extracted_fields/room_items/1/...` 或更大下标不会自动落到 0。
|
||||
- 同一次请求不能重复提交同一字段;重复 `field_pointer` 或重复映射到同一 `field_path` 会返回 `TASK_REVIEW_POINTER_DUPLICATE`。
|
||||
- `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` 和 `confirmed_payload.effective_payload` 中。`effective_payload` 是后端第一版嵌套结构,后续真实 OPERA 参数仍会在 OPERA 层重新组装。
|
||||
- 历史 `field_contract_version=code-v1` 的任务卡如果已经有 `draft_payload_json` 或 `confirmed_payload_json`,后端迁移不会强行改成 `20260711-p0`。前端读取历史任务时,如果看到旧版本,应优先使用 `legacy_field_path` / `legacy_field_values` 做过渡回显;新保存或新确认后再以 P0 主路径为准。
|
||||
- `review_resolution.resolved_at` 是 UTC `Z` 时间点。
|
||||
- type-known manual review 不允许调用通用 `POST /api/reservation/tasks/{taskId}/confirm`;前端必须使用本节解阻接口,否则后端返回 `TASK_REVIEW_RESOLUTION_REQUIRED`。
|
||||
|
||||
|
||||
@@ -339,7 +339,7 @@ POST /api/system/reservation/demo-data
|
||||
- 任务列表已按 `result_type`、`route_code`、`system_process_category` 识别 `source_message_review_notification`、`adapter_contract_error`、`unhandled_current_intent` 只读诊断任务;S10/S99 和旧 S000/S999 都不展示订单入口。
|
||||
- 任务详情已展示 `result_type`、`ai_task_type`、`task_subtype`、`route_code`、`system_process_category`、`review_status`、来源邮件入口、`source_message_only_result`、`manual_review`、`adapter_contract_errors[]` 和 `unhandled_intents[]`。
|
||||
- type-known `result_type=manual_review` 已在原业务任务卡展示复核状态和缺失字段,并调用 `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` 解阻,不再创建第二张人工复核任务卡。
|
||||
- 第一版解阻 UI 已支持 `/extracted_fields/pms_room_type_code` 和 `/extracted_fields/room_items/0/pms_room_type_code` 两类 pointer,提交成功后刷新任务详情和审计流水。
|
||||
- 第一版解阻 UI 可提交 `/extracted_fields/pms_room_type_code` 和 `/extracted_fields/room_items/0/pms_room_type_code` 两类 pointer;后端也支持提交 P0 主 `field_path` 或旧扁平 `field_path`,提交成功后刷新任务详情和审计流水。
|
||||
- 前端只读规则已收口:S10/S99、适配契约异常、未处理意图、前置任务阻塞和同卡人工复核待解阻状态都不显示保存草稿、确认任务、人工转换或 OPERA 执行 / 重试入口。
|
||||
- 前端 fixture 已补 V3 最小结构样例:`source_message` 完整对象、`message_events[]` 的 `event_role`、`current_or_history`、`source_event_index`、四字段 `case_keys`、`relevant_message_excerpt`、`attachments`、`file_references`、`context_used`、`extracted_fields`、`manual_review`。
|
||||
|
||||
@@ -536,13 +536,14 @@ Content-Type: application/json
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_pointer": "/extracted_fields/room_items/0/pms_room_type_code",
|
||||
"field_path": "extracted_fields.room_items.0.pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
0711 P0 房型复核说明:前端应优先提交 `/extracted_fields/room_items/0/pms_room_type_code` 或任务详情 `fields[].field_path=extracted_fields.room_items.0.pms_room_type_code`。后端仍兼容旧扁平 key / pointer,但会归一化到 P0 主路径。响应里的 `review_resolution.field_overrides[].field_pointer` 保留前端原始 pointer,`field_path` 使用 P0 主路径,`legacy_field_path` 仅用于旧页面过渡。
|
||||
0711 P0 房型复核说明:前端应优先提交 `/extracted_fields/room_items/0/pms_room_type_code` 或任务详情 `fields[].field_path=extracted_fields.room_items.0.pms_room_type_code`。后端仍兼容旧扁平 key / pointer,也支持只提交 `field_path`。如果同时提交 `field_pointer` 和 `field_path`,两者必须指向同一字段。响应里的 `review_resolution.field_overrides[].field_path` 使用 P0 主路径;前端只提交 `field_path` 时,后端会返回 P0 主 `field_pointer`;前端提交旧 pointer 时,`field_pointer` 保留前端原始值,`legacy_field_path` 仅用于旧页面过渡。
|
||||
|
||||
返回示例:
|
||||
|
||||
@@ -592,7 +593,7 @@ Content-Type: application/json
|
||||
|
||||
前端注意:
|
||||
|
||||
- `field_pointer` 必须是 RFC 6901 JSON Pointer,并且只能指向任务详情 `fields[]` 中当前可编辑字段;只读字段或未知字段会返回 `TASK_REVIEW_POINTER_INVALID`。
|
||||
- `field_overrides[]` 必须提供 `field_pointer` 或 `field_path`。`field_pointer` 必须是 RFC 6901 JSON Pointer;`field_path` 可以是 P0 主路径或旧扁平路径。两者都只能指向任务详情 `fields[]` 中当前可编辑字段;只读字段、未知字段或两者指向不一致会返回 `TASK_REVIEW_POINTER_INVALID`。
|
||||
- 同一次请求不能重复提交同一字段;重复 `field_pointer` 或重复映射到同一 `field_path` 会返回 `TASK_REVIEW_POINTER_DUPLICATE`。
|
||||
- `confirmed_order_id` 第一版必须等于当前任务 `order_id`;普通任务任意切换订单继续后置。
|
||||
- type-known manual review 不能调用通用 `POST /api/reservation/tasks/{taskId}/confirm`;必须调用本节解阻接口,否则后端返回 `TASK_REVIEW_RESOLUTION_REQUIRED`。
|
||||
|
||||
@@ -506,14 +506,26 @@ S99 与 S10 使用相同结构,但 `route_code=S99`,`agent_assessment.status
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "CNF-001"
|
||||
"confirmation_number": "CNF-001",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create a new FIT reservation.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation"
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "2026-09-01",
|
||||
"departure_date": "2026-09-03",
|
||||
"room_quantity": 2,
|
||||
"room_type": "Deluxe King",
|
||||
"pms_room_type_code": "RM2"
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
@@ -533,9 +545,15 @@ V3 字段说明:
|
||||
| `result_type` | S10/S99 必填 | 固定为 `source_message_review_notification` |
|
||||
| `message_events[]` | 业务根必填 | SuperAgent 最终业务事件列表,本系统逐 event 派生路由 |
|
||||
| `message_events[].event_type` | 是 | V3 active event 或 `Need Manual Review` |
|
||||
| `message_events[].source_event_index` | 建议必填 | 可为 `E1` 或数字;后端会归一为数字序号 |
|
||||
| `message_events[].case_keys` | 否 | 订单关联候选键 |
|
||||
| `message_events[].extracted_fields` | 否 | 业务字段主体和 subtype 判别字段 |
|
||||
| `message_events[].event_role` | 是 | 事件来源角色,第一版必须是非空字符串 |
|
||||
| `message_events[].source_event_index` | 是 | 可为 `E1` 或数字;后端会归一为数字序号 |
|
||||
| `message_events[].current_or_history` | 是 | 第一版只接受 `current` |
|
||||
| `message_events[].case_keys` | 是 | 必须包含 `group_code`、`confirmation_number`、`reservation_number`、`block_code` 四个字段,值为 string 或 null |
|
||||
| `message_events[].relevant_message_excerpt` | 是 | 当前事件的邮件证据摘录,必须是字符串 |
|
||||
| `message_events[].attachments` | 是 | 当前事件引用附件数组,无附件传空数组 |
|
||||
| `message_events[].file_references` | 是 | 当前事件引用文件数组,无文件传空数组 |
|
||||
| `message_events[].context_used` | 是 | 当前事件使用的上下文对象,无上下文传空对象 |
|
||||
| `message_events[].extracted_fields` | 是 | 业务字段主体和 subtype 判别字段,必须是对象 |
|
||||
| `message_events[].manual_review` | 否 | `null` 表示普通任务;对象表示 type-known manual review |
|
||||
| `unhandled_current_intents[]` | 否 | 第一版只保存 `UNHANDLED_CURRENT_INTENT` transition,不自动创建业务任务 |
|
||||
|
||||
@@ -546,7 +564,8 @@ V3 字段说明:
|
||||
- 业务 event 能派生到稳定路由时,复用现有订单 / 任务 / 任务卡创建链路。
|
||||
- event 判别字段不完整、显式携带 `contract_errors`、根 `missing_fields`、不完整 `manual_review` 或不完整 parent split 候选时,写入 `adapter_contract_error` transition,不创建订单和任务;同一邮件其他 sibling event 继续处理。
|
||||
- `unhandled_current_intents[]` 写入 `UNHANDLED_CURRENT_INTENT` transition,不返回 `adapter_error_code`。
|
||||
- type-known manual review 第一版在同一业务任务卡解阻;New Booking 房型字段主路径已迁移为 `room_items[0]`,例如 `/extracted_fields/room_items/0/pms_room_type_code`。旧扁平字段仍可作为过渡提交 key,但响应会归一化为 P0 主 `field_path`。
|
||||
- V3 `message_events[].relevant_message_excerpt` 入站后会归一化到任务卡 AI payload 根路径,供旧字段矩阵读取证据字段;SuperAgent 仍只需要按 V3 event 契约提供该字段。
|
||||
- type-known manual review 第一版在同一业务任务卡解阻;New Booking 房型字段主路径已迁移为 `room_items[0]`,例如 `/extracted_fields/room_items/0/pms_room_type_code`。旧扁平字段仍可作为过渡提交 key,解阻接口也支持提交 P0 主 `field_path` 或旧扁平 `field_path`,响应会归一化为 P0 主 `field_path`。
|
||||
|
||||
`source_message.source_message_id` 缺失时返回 `HTTP 400`,响应体不使用通用错误包装:
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ V2 一条 `ai_task_results[]` item 对应一行。M002 V3 后,`message_events[
|
||||
|
||||
- 第一版任务卡字段配置写在代码中,但通过 `TaskCardFieldDefinitionProvider` 之类的接口提供,避免 Controller / Service 直接依赖硬编码数组。
|
||||
- 后续如果改为数据库配置,只替换 Provider 实现,不改任务核心流程。
|
||||
- 字段契约从 `code-v1` 迁移到 `20260711-p0` 时,不能盲目改写已经存在用户草稿或确认结果的历史任务卡。当前 V18 仅更新 `draft_payload_json IS NULL` 且 `confirmed_payload_json IS NULL` 的旧任务卡;已存在 payload 的历史数据继续保留原 `field_contract_version`,待重新保存、最终确认或后续专项 backfill 时再迁移。
|
||||
|
||||
## 10. 审计表
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态:
|
||||
- `missing_fields[]` 必须是 RFC 6901 JSON Pointer。
|
||||
- 入站阶段 `missing_fields[]` 不完整或不是 RFC 6901 Pointer 时,按 `adapter_contract_error` fail closed,不创建业务任务。
|
||||
- 解阻接口提交的 Pointer 必须能映射到该业务卡当前可展示且可编辑字段,否则返回 `TASK_REVIEW_POINTER_INVALID`。
|
||||
- 解阻接口 `field_overrides[]` 支持提交 `field_pointer` 或 `field_path`。`field_pointer` 使用 RFC 6901 JSON Pointer;`field_path` 支持 P0 主矩阵路径和旧扁平路径。两者同时提交时必须指向同一个字段,否则按非法字段处理。
|
||||
- 同一次解阻请求中 `field_overrides[]` 不允许重复指向同一 `field_pointer` 或同一矩阵 `field_path`,否则返回 `TASK_REVIEW_POINTER_DUPLICATE`。
|
||||
- 订单归属确认可作为复核解阻的一部分保存;当前第一版只允许确认当前任务所属订单,不开放普通任务随意切换订单能力。
|
||||
- 全部缺失字段、订单归属、目录值和依赖校验通过后,才进入 Preflight / READY。
|
||||
@@ -297,7 +298,7 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态:
|
||||
| 房型原文 | `extracted_fields.room_items.0.room_type_raw` | `/extracted_fields/room_items/0/room_type_raw` | `extracted_fields.room_type` |
|
||||
| PMS 房型代码 | `extracted_fields.room_items.0.pms_room_type_code` | `/extracted_fields/room_items/0/pms_room_type_code` | `extracted_fields.pms_room_type_code` |
|
||||
|
||||
入站 `ai_payload_json` 保留 SuperAgent 原始结构,不回写用户修正。任务详情 `fields[]` 中 `field_path` 和 `field_pointer` 以 P0 主结构为准,`legacy_field_path` 仅用于前端过渡展示。草稿保存、最终确认和同卡复核解阻可继续提交旧扁平 key,但后端会归一化到 P0 主 `field_path`;响应中的 `field_values` 使用主路径,`legacy_field_values` 提供旧扁平回显兼容,`effective_payload` 提供嵌套结构供后续 OPERA 参数组装使用。当前第一版只支持 `room_items[0]`,非 0 下标或不存在路径按 `TASK_REVIEW_POINTER_INVALID` / 字段校验错误处理,不自动映射。
|
||||
入站 `ai_payload_json` 保留 SuperAgent 原始结构,不回写用户修正。V3 `message_events[]` 归一化为任务卡时,会把 `relevant_message_excerpt` 同步到任务 item 根路径,便于旧字段矩阵读取证据字段。任务详情 `fields[]` 中 `field_path` 和 `field_pointer` 以 P0 主结构为准,`legacy_field_path` 仅用于前端过渡展示。草稿保存、最终确认的 `field_values` 可继续提交旧扁平 key;同卡复核解阻可提交 `field_pointer` 或 `field_path`,其中 `field_path` 可为 P0 主路径或旧扁平路径。后端会归一化到 P0 主 `field_path`;响应中的 `field_values` 使用主路径,`legacy_field_values` 提供旧扁平回显兼容,`effective_payload` 提供嵌套结构供后续 OPERA 参数组装使用。当前第一版只支持 `room_items[0]`,非 0 下标或不存在路径按 `TASK_REVIEW_POINTER_INVALID` / 字段校验错误处理,不自动映射。
|
||||
|
||||
### 9.3 后端第一版接口
|
||||
|
||||
@@ -315,6 +316,7 @@ Content-Type: application/json
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_pointer": "/extracted_fields/room_items/0/pms_room_type_code",
|
||||
"field_path": "extracted_fields.room_items.0.pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
@@ -328,6 +330,7 @@ Content-Type: application/json
|
||||
- `review_resolution.field_overrides[]` 同时返回 `field_pointer`、矩阵主 `field_path`、`legacy_field_path` 和人工值。
|
||||
- `review_resolution.resolved_at` 使用带 `Z` 的 UTC 时间点。
|
||||
- `confirmed_payload.field_values` 使用 P0 主 `field_path` 保存,`legacy_field_values` 仅用于旧扁平字段过渡回显;`effective_payload` 是嵌套结构,不等同真实 OPERA 参数。
|
||||
- 通用最终确认不会放宽历史 V2 只读必填证据字段;仅对 V3 message_event 缺省的旧根 `visible_reason` 做窄口兼容,防止旧结构普通任务缺少证据仍进入 READY。
|
||||
- 自动生成第一版固定两条 OPERA 模拟操作。
|
||||
- 写入 `MANUAL_REVIEW_RESOLVE` 审计。
|
||||
- type-known manual review 不允许走通用 `POST /api/reservation/tasks/{taskId}/confirm`,否则会返回 `TASK_REVIEW_RESOLUTION_REQUIRED`。
|
||||
@@ -404,8 +407,10 @@ V3 P0 不做以下事项:
|
||||
- 业务任务详情按同一 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 模拟操作创建和审计记录。
|
||||
- `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` 的 `field_overrides[]` 已支持 `field_pointer` 和 `field_path` 两种定位方式;`field_path` 可为 P0 主路径或旧扁平路径,成功后响应归一化到 P0 主 `field_path`。
|
||||
- `source_message.source_message_id` 缺失时按 V3 typed `infrastructure_input_error` 结构响应。
|
||||
- 0711 P0 fixtures 已纳入后端回归测试参考,并补齐 S10/S99 严格契约、P0 type-known manual review 解阻、candidate_gate reference 和当前扁平字段矩阵兼容。
|
||||
- `field_contract_version` 历史迁移已收紧:V18 只把没有 `draft_payload_json` 且没有 `confirmed_payload_json` 的 `code-v1` 任务卡标记为 `20260711-p0`;已经存在用户草稿或确认 payload 的历史任务卡保留旧版本,等待重新保存、确认或后续专项 backfill。
|
||||
- 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。
|
||||
- SuperAgent 查询上下文接口 1、2,以及邮件会话相关查询。
|
||||
|
||||
|
||||
@@ -3,14 +3,17 @@ package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 同卡人工复核字段修正请求。field_pointer 使用 RFC 6901 JSON Pointer。
|
||||
* 同卡人工复核字段修正请求。field_pointer 使用 RFC 6901 JSON Pointer,field_path 用于兼容矩阵主路径或旧扁平路径。
|
||||
*
|
||||
* @param fieldPointer 指向任务卡可编辑字段的 JSON Pointer
|
||||
* @param fieldPath 指向任务卡可编辑字段的矩阵路径,支持 P0 主路径和旧扁平路径
|
||||
* @param value 人工修正后的字段值
|
||||
*/
|
||||
public record ManualReviewResolutionFieldOverrideRequest(
|
||||
@JsonProperty("field_pointer")
|
||||
String fieldPointer,
|
||||
@JsonProperty("field_path")
|
||||
String fieldPath,
|
||||
Object value
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -616,6 +616,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
item.put("task_type", route.taskType());
|
||||
item.put("task_subtype", route.taskSubtype());
|
||||
copyIfPresent(event, item, "current_or_history");
|
||||
copyIfPresent(event, item, "relevant_message_excerpt");
|
||||
copyIfPresent(event, item, "case_keys");
|
||||
copyIfPresent(event, item, "extracted_fields");
|
||||
copyIfPresent(event, item, "manual_review");
|
||||
|
||||
@@ -85,6 +85,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
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 V3_CATALOG_CODE = "M002V3";
|
||||
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;
|
||||
@@ -825,12 +826,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
Set<String> seenFieldPaths = new LinkedHashSet<>();
|
||||
for (ManualReviewResolutionFieldOverrideRequest request : requests) {
|
||||
String fieldPointer = trimToNull(request == null ? null : request.fieldPointer());
|
||||
ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
if (!seenPointers.add(fieldPointer) || !seenFieldPaths.add(definition.fieldPath())) {
|
||||
throw reviewPointerDuplicateError(fieldPointer, definition.fieldPath());
|
||||
String fieldPath = trimToNull(request == null ? null : request.fieldPath());
|
||||
ReservationTaskCardFieldDefinition definition =
|
||||
findEditableDefinitionByOverride(definitionByPath, fieldPointer, fieldPath);
|
||||
String resultFieldPointer = fieldPointer != null ? fieldPointer : fieldPointerFor(definition.fieldPath());
|
||||
if (!seenPointers.add(resultFieldPointer) || !seenFieldPaths.add(definition.fieldPath())) {
|
||||
throw reviewPointerDuplicateError(resultFieldPointer, definition.fieldPath());
|
||||
}
|
||||
results.add(new ManualReviewResolutionFieldOverrideResult(
|
||||
fieldPointer,
|
||||
resultFieldPointer,
|
||||
definition.fieldPath(),
|
||||
legacyFieldPathFor(definition.fieldPath()),
|
||||
request.value()));
|
||||
@@ -883,6 +887,48 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按复核请求定位可编辑字段,支持 JSON Pointer、P0 主 field_path 和旧扁平 field_path。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByOverride(
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPointer,
|
||||
String fieldPath) {
|
||||
ReservationTaskCardFieldDefinition pointerDefinition = null;
|
||||
if (fieldPointer != null) {
|
||||
pointerDefinition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
}
|
||||
ReservationTaskCardFieldDefinition pathDefinition = null;
|
||||
if (fieldPath != null) {
|
||||
pathDefinition = findEditableDefinitionByFieldPath(definitionByPath, fieldPath);
|
||||
}
|
||||
if (pointerDefinition != null && pathDefinition != null
|
||||
&& !pointerDefinition.fieldPath().equals(pathDefinition.fieldPath())) {
|
||||
throw reviewPointerError(fieldPointer + " / " + fieldPath);
|
||||
}
|
||||
if (pointerDefinition != null) {
|
||||
return pointerDefinition;
|
||||
}
|
||||
if (pathDefinition != null) {
|
||||
return pathDefinition;
|
||||
}
|
||||
throw reviewPointerError(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按矩阵字段路径查找可编辑字段,先将旧扁平路径归一化到 P0 主路径。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByFieldPath(
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPath) {
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath);
|
||||
ReservationTaskCardFieldDefinition definition = definitionByPath.get(canonicalFieldPath);
|
||||
if (definition == null || !isYes(definition.editable())) {
|
||||
throw reviewPointerError(fieldPath);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 与旧扁平路径。
|
||||
*/
|
||||
@@ -1223,7 +1269,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
}
|
||||
|
||||
/**
|
||||
* 按字段矩阵执行校验。复核解阻场景允许只读证据字段缺失,因为用户无法在该接口补齐。
|
||||
* 按字段矩阵执行校验。P0 V3 业务事件缺省旧 visible_reason 时做窄口兼容,其他只读必填仍要校验。
|
||||
*/
|
||||
private List<String> validateDefinitionValues(
|
||||
ReservationTaskSnapshot task,
|
||||
@@ -1237,7 +1283,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
for (ReservationTaskCardFieldDefinition definition : activeDefinitions) {
|
||||
Object value = valueForField(aiPayload, editedValues, definition.fieldPath());
|
||||
if (validateRequired && isMissingRequired(task, definition, aiPayload, editedValues, value)) {
|
||||
if (allowMissingReadonlyRequired && !isYes(definition.editable())) {
|
||||
if (allowMissingReadonlyRequired && isReadonlyRequiredMissingAllowed(definition, aiPayload)) {
|
||||
continue;
|
||||
}
|
||||
errors.add(definition.fieldPath() + ": 必填字段缺失。");
|
||||
@@ -1252,6 +1298,18 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容 0711 P0 业务事件:V3 message_event 没有旧矩阵根 visible_reason 字段,但保留 relevant_message_excerpt 作为证据。
|
||||
*/
|
||||
private boolean isReadonlyRequiredMissingAllowed(
|
||||
ReservationTaskCardFieldDefinition definition,
|
||||
JsonNode aiPayload) {
|
||||
return !isYes(definition.editable())
|
||||
&& "visible_reason".equals(definition.fieldPath())
|
||||
&& V3_CATALOG_CODE.equals(aiPayload.path("catalog_code").asText())
|
||||
&& aiPayload.path("v3_message_event").isObject();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字段定义路径集合,用于最终确认时校验全部展示字段。
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
UPDATE workflow_reservation_task_card
|
||||
SET field_contract_version = '20260711-p0'
|
||||
WHERE field_contract_version = 'code-v1';
|
||||
WHERE field_contract_version = 'code-v1'
|
||||
AND draft_payload_json IS NULL
|
||||
AND confirmed_payload_json IS NULL;
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
@@ -901,6 +902,73 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(transitionCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmV3BusinessTaskWithoutLegacyVisibleReason() throws Exception {
|
||||
captureSourceMessage("mail-v3-confirm-without-legacy-visible-reason-001");
|
||||
String body = """
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "mail-v3-confirm-without-legacy-visible-reason-001",
|
||||
"subject": "New booking without legacy visible reason",
|
||||
"from": null,
|
||||
"cc": [],
|
||||
"received_at": null,
|
||||
"source_channel": "Email"
|
||||
},
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "CNF-V3-NO-VISIBLE-001",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create a new FIT reservation without old root visible reason.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "2026-09-01",
|
||||
"departure_date": "2026-09-03",
|
||||
"room_quantity": 2,
|
||||
"room_type": "Deluxe King",
|
||||
"pms_room_type_code": "RM2"
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
""";
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-confirm-without-visible-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": {}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['relevant_message_excerpt']")
|
||||
.value("Please create a new FIT reservation without old root visible reason."))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
|
||||
.value("RM2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectEmptyV3BusinessRootWithoutPersistingBatch() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-empty-business-root-001");
|
||||
@@ -1556,6 +1624,61 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(auditCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConfirmWhenReadonlyRequiredEvidenceFieldMissing() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-readonly-required-missing-001");
|
||||
String body = """
|
||||
{
|
||||
"source_message_id": "%s",
|
||||
"ai_task_results": [
|
||||
{
|
||||
"source_event_index": 1,
|
||||
"catalog_code": "S01",
|
||||
"skill_id": "S01_new_booking_skill",
|
||||
"result_type": "normal_task",
|
||||
"task_type": "New Booking",
|
||||
"task_subtype": "new_fit_reservation",
|
||||
"current_or_history": "current",
|
||||
"relevant_message_excerpt": "Please handle booking message.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"case_keys": {"confirmation_number": "CNF-CP5-READONLY-MISSING-001"},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "2026-08-01",
|
||||
"departure_date": "2026-08-02",
|
||||
"room_quantity": 2,
|
||||
"room_type": "Deluxe King",
|
||||
"pms_room_type_code": "RM2"
|
||||
},
|
||||
"additional_operations": [],
|
||||
"idempotency_key": null
|
||||
}
|
||||
],
|
||||
"extraction_warnings": []
|
||||
}
|
||||
""".formatted(source.inboxId());
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-readonly-required-missing-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": {}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value(containsString("visible_reason")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExecuteFirstOperaSimulationOperationAndRecordAttempt() throws Exception {
|
||||
String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
|
||||
@@ -1915,6 +2038,90 @@ class SuperAgentTaskResultControllerTest {
|
||||
assertThat(auditCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTypeKnownManualReviewFromP0FieldPath() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-field-path-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-field-path-001",
|
||||
"CNF-V3-MR-FIELD-PATH-001",
|
||||
"2026-09-02",
|
||||
"2026-09-04");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-field-path-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_path": "extracted_fields.room_items.0.pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/extracted_fields/room_items/0/pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
|
||||
.value("extracted_fields.room_items.0.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']")
|
||||
.value("RM3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTypeKnownManualReviewFromLegacyFieldPath() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-legacy-field-path-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-legacy-field-path-001",
|
||||
"CNF-V3-MR-LEGACY-FIELD-PATH-001",
|
||||
"2026-09-02",
|
||||
"2026-09-04");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-legacy-field-path-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_path": "extracted_fields.pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/extracted_fields/room_items/0/pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
|
||||
.value("extracted_fields.room_items.0.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].legacy_field_path")
|
||||
.value("extracted_fields.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.pms_room_type_code']")
|
||||
.value("RM3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectGenericConfirmForTypeKnownManualReview() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-confirm-001");
|
||||
@@ -2112,6 +2319,18 @@ class SuperAgentTaskResultControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("REVIEW_ORDER_ASSIGNMENT_MISMATCH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMarkHistoricalPayloadRowsAsP0ContractInV18Migration() throws Exception {
|
||||
ClassPathResource migration = new ClassPathResource(
|
||||
"db/migration/V18__update_reservation_field_contract_version.sql");
|
||||
assertThat(migration.exists()).isTrue();
|
||||
|
||||
String sql = migration.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("field_contract_version = 'code-v1'");
|
||||
assertThat(sql).contains("draft_payload_json IS NULL");
|
||||
assertThat(sql).contains("confirmed_payload_json IS NULL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception {
|
||||
SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001");
|
||||
|
||||
Reference in New Issue
Block a user