修复V4房型信息复核与自动DEF展示

This commit is contained in:
andy
2026-07-21 10:40:44 +07:00
parent c28130b3da
commit de6a52d708
10 changed files with 270 additions and 50 deletions

View File

@@ -5,7 +5,7 @@
| 最近更新 | 2026-07-21 |
| 当前分支 | `feature/huangting` |
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}``display_payload.room_information` 返回 New / Update / Cancel 的 `current_values``proposed_values``final_values``change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields``target_order` 自行推导Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义不暴露内部列名。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display来源邮件卡位于页面底部只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文Basic Information 的展示和确认 payload 不再返回 Agent `target_order`。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload``confirmed_payload` 和订单总览均以 DEF 后的确认快照为准如没有可更新投影Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`GROUP / FIT过滤和校验不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}``display_payload.room_information` 返回 New / Update / Cancel 的 `current_values``proposed_values``final_values``change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields``target_order` 自行推导Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义不暴露内部列名。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display来源邮件卡位于页面底部只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文Basic Information 的展示和确认 payload 不再返回 Agent `target_order`。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload``confirmed_payload` 均以 DEF 后的确认快照为准;当前订单详情 `order_overview` 不返回 Group Booking Status 字段;如没有可更新投影Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`GROUP / FIT过滤和校验不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
## 1. 当前 Checkpoint

View File

@@ -56,8 +56,8 @@
| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`;未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;用 `can_process``readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL已返回来源邮件会话摘要字段并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和 V3 S10/S99 以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type``ai_task_type``route_code``system_process_category`。V4 S10/S99 不再进入该旧任务表,应从 V4 工作台来源通知接口展示。 |
| `GET /api/reservation/workbench-items` | 查询 V4 工作台统一列表 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`;返回 V4 业务订单任务和 S10/S99 来源通知混排摘要;支持 `hotel_id``item_type``keyword``page_num``page_size`;默认按 `source_received_at` 倒序,同一来源时间下按 `updated_at``created_at`、数字 `target_id` 倒序;列表不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 |
| `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id``order_id``order_task_status``card_status``keyword``page_num``page_size``order_task_status``OPEN` / `COMPLETED` 返回 400`card_status` 非 V4 卡状态返回 400`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task``source_message_summary``source_message_card``basic_information_card``business_cards[]``card_counts``adapter_contract_errors[]``availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display来源邮件卡位于页面最下方正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡下一阶段可返回 `payment_attachments[]` 安全摘要用于展示凭证附件,但本接口不得返回附件 URL图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`前端应以该字段白名单渲染可编辑控件。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields``target_order` 自行推导业务展示。 |
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id``items[]``items[]` 用于展示 V4 卡片确认、复核解阻订单归属确认轨迹只包含脱敏后的审计摘要不包含邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret。 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task``source_message_summary``source_message_card``basic_information_card``business_cards[]``card_counts``adapter_contract_errors[]``availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display来源邮件卡位于页面最下方正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡下一阶段可返回 `payment_attachments[]` 安全摘要用于展示凭证附件,但本接口不得返回附件 URL图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件`write_target` 只返回 `confirmed_payload``review_resolution.field_overrides``none` 等前端安全语义,不暴露内部列名。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields``target_order` 自行推导业务展示Basic Information 的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`。 |
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id``items[]``items[]` 用于展示 V4 卡片确认、复核解阻订单归属确认轨迹`V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 摘要只包含脱敏后的审计摘要不包含邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret。 |
| `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 |
| `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 S10/S99 来源通知审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按来源通知实际酒店校验访问权;返回 `notification_id``items[]``items[]` 第一版用于展示来源通知 ack 记录,只包含脱敏后的审计摘要。 |
| `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;返回统一 wrapper`hotel_id``catalog_type=ACCOUNT``catalog_source``catalog_version``stale``items[]``page``warnings[]``keyword` 匹配目录 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时 `items=[]` / `page.total=0`,但只要酒店未过滤目录存在,`catalog_source/catalog_version` 仍保持真实目录元数据,不代表目录未初始化。前端在 `options_source=reservation_v4_account_catalog` 时调用,只提交 `items[].code`Market / Source 以后端确认派生结果为准。 |
@@ -72,7 +72,7 @@
前端已在系统设置下新增 `/system/reservation-catalogs` 消费上述目录管理接口。页面入口要求 `RESERVATION_CATALOG_MANAGE`,列表过滤直接传 `hotel_id``keyword``status``page_num``page_size`Account 新增第一版固定提交 `market_code=LEISURE``source_code=TRAVEL_AGENT`;状态重复提交按成功提示处理,不额外弹失败。
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段后端以展示快照为基准合并未开放字段会被忽略Room Information 卡优先提交 `confirmed_payload.room_information.final_values`,后端写入稳定确认快照并重新派生 Nights / Breakfast / Group Booking Status 文案,不写 Agent `target_order`、Adult、邮件正文、附件 URL 或前端注入字段;确认 Rooming List 卡时前端只提交 `version` 即可,若同订单为 Group 且存在可更新 Room Information 确认快照,后端会自动把 Group Booking Status 置为 `DEF` 并写审计;没有可更新投影时确认仍成功且不创建不完整 Room Information确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code下一阶段 `rate_code` 还必须属于已确认 Account + 当前业务 event `booking_type` 的适用范围Room Information 新结构错误路径形如 `room_information.final_values.room_items.0.room_type_code`,历史兼容结构可能返回如 `business_fields.after.room_items.0.room_type_code`,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED``confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段后端以展示快照为基准合并未开放字段会被忽略Room Information 卡优先提交 `confirmed_payload.room_information.final_values`,后端写入稳定确认快照并重新派生 Nights / Breakfast / Group Booking Status 文案,不写 Agent `target_order`、Adult、邮件正文、附件 URL 或前端注入字段;确认 Rooming List 卡时前端只提交 `version` 即可,若同订单为 Group 且存在可更新 Room Information 确认快照,后端会自动把 Group Booking Status 置为 `DEF` 并写审计,刷新详情时 `display_payload` / `confirmed_payload` 均显示 DEF;没有可更新投影时确认仍成功且不创建不完整 Room Information确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code下一阶段 `rate_code` 还必须属于已确认 Account + 当前业务 event `booking_type` 的适用范围Room Information 新结构错误路径形如 `room_information.final_values.room_items.0.room_type_code`,历史兼容结构可能返回如 `business_fields.after.room_items.0.room_type_code`,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`内部写入确认快照 / 确认人 / 确认时间并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]``reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;`field_overrides[]` 只允许当前卡 `fields[]` 白名单内可编辑业务字段,问题字段可按 `fields[].validation_errors` 红字提示Room Information 复核优先使用 `/room_information/final_values/...` pointer不允许指向 `nights``target_order`、Adult、Block ID、Confirmation Number 等只读 / 派生字段;成功后卡片 `CONFIRMED``review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 |
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` / `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 业务审计展示数据 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`;前端可在 V4 订单任务详情和 S10/S99 来源通知详情的“审计时间线”中调用。响应沿用旧审计行结构:`audit_id``actor_type``actor_id``action``reason``before_snapshot``after_snapshot``occurred_at`。快照已由后端脱敏,前端仍不要把未知 URL-like 字符串当附件或正文直渲。 |
@@ -309,7 +309,7 @@ Content-Type: application/json
- `extracted_fields.room_items.0.room_type_raw` 是房型原文证据,第一版返回 `control_type=readonly``edit_scope=never``raw_readonly=true`;用户应确认或修改 `pms_room_type_code`,不要覆盖 raw 原文。
- `extracted_fields.room_items.0.room_quantity` 返回 `control_type=number``extracted_fields.room_items.0.pms_room_type_code` 返回 `control_type=select``options_source=active_pms_room_type_catalog`
- type-known manual review 的 `manual_review.missing_fields[]` 应按 JSON Pointer 匹配 `fields[].field_pointer`,并复用对应字段控件提交 `field_overrides[]`;匹配不到的 pointer 不要临时生成任意输入框。
- 缺失字段会返回 `edit_scope=manual_review_only``write_target=review_resolution.field_overrides`;同卡复核中其他可编辑业务字段可能返回 `normal_and_manual_review`,前端第一版仍优先只渲染 `missing_fields[]` 指向的字段
- 缺失字段会返回 `edit_scope=manual_review_only``write_target=review_resolution.field_overrides`;同卡复核中其他可编辑业务字段可能按当前卡白名单返回 `editable=true`,前端不要只渲染 `missing_fields[]` 指向的字段,应该以 `fields[]` 中真实 `editable` 状态为准
- `field_overrides[]` 新页面优先提交 `field_pointer`,可同时提交 `fields[]` 中的主 `field_path`;不要提交旧扁平 key 作为新逻辑首选。
- `options_source=active_pms_room_type_catalog``rate_code_catalog``system_case_lookup` 第一版仅代表选项来源,真实目录 / lookup 未接入前,前端不得硬编码 PMS 房型、Rate Code 或系统对象全集。
- 后端可能返回 `control_hint=catalog_backend_pending``lookup_backend_pending``structured_table_editor_pending`用于提示前端目录、lookup 或表格编辑后端能力仍未接入。
@@ -499,7 +499,7 @@ RESERVATION_ROOMING_LIST_GENERATE
- `/reservation/order-tasks/{orderTaskId}` 应使用 `GET /api/reservation/order-tasks/{orderTaskId}` 展示 `basic_information_card``business_cards[]``source_message_card``card_counts``order_task` 摘要和 `adapter_contract_errors[]` 只读诊断;页面顺序固定为 Basic Information、业务卡、SourceMessage Display。
- `/reservation/source-notifications/{notificationId}` 应使用 `GET /api/reservation/source-notifications/{notificationId}` 展示 S10/S99 来源通知详情,并通过 `POST /api/reservation/source-notifications/{notificationId}/ack` 确认已读 / 已处理。
- V4 审计时间线应分别调用 `GET /api/reservation/order-tasks/{orderTaskId}/audits``GET /api/reservation/source-notifications/{notificationId}/audits`;按钮权限使用 `/api/auth/me.permissions[]` 中的 `RESERVATION_AUDIT_READ`
- V4 卡片确认应使用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm`,请求带 `version`;前端只从当前卡 `fields[]` 中挑选 `editable=true``raw_readonly!=true``write_target` 指向确认 payload 的字段构造 `confirmed_payload`
- V4 卡片确认应使用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm`,请求带 `version`;前端只从当前卡 `fields[]` 中挑选 `editable=true``raw_readonly!=true``write_target=confirmed_payload` 的字段构造 `confirmed_payload`
- V4 复核解阻应使用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`,请求带 `version``field_overrides[]` 只使用当前卡 `fields[].field_pointer`,订单任务归属未解决时允许用户填写 `confirmed_order_id`
- 所有按钮应按后端 `availability.confirmable``availability.reviewable``availability.ackable` 和前端权限共同控制;不可操作原因优先展示 `readonly_reason_message`,否则按 `readonly_reason_code` 做友好映射。
- `fields[].validation_errors` 应展示在对应字段旁边;接口返回 `V4_FIELD_VALIDATION_FAILED``details[]` 带嵌套路径时,前端应尝试定位到对应 field定位不到则在当前卡片动作错误区展示。
@@ -526,9 +526,9 @@ RESERVATION_ROOMING_LIST_GENERATE
- M002 V4 入站解析与数据模型基线已完成第一版:后端可接收 `source_message + order_contexts[] + message_events[]`,识别 `NEW_BOOKING``UPDATE_BOOKING``CANCEL_BOOKING``TRACE_RESERVATION_NOTES``ROOMING_LIST``PAYMENT`,并保存 V4 原始 payload、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。前端暂不需要直接调用 V4 回调接口。
- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items``/api/reservation/order-tasks``/api/reservation/order-tasks/{orderTaskId}` 查看V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口M002 V4 CP7 已开放 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻接口M002 V4 CP8 已开放 V4 卡片 `fields[]` 白名单和目录校验M002 V4 CP11 已把固定种子迁移到数据库目录,并开放 Account / Room Type / Rate Code lookup API目录管理后台 CP1 已开放 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口。
- V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染;前端对来源消息卡附件字段仍做 URL-like 文本兜底脱敏。
- Room Information 卡已由后端返回业务展示模型,前端已按该模型完成第一版业务化展示,不再自行从 Agent raw payload、`business_fields``target_order` 计算。读取路径是业务卡 `display_payload.room_information``NEW_BOOKING` 展示最终值Group 的最终订单投影字段 `group_block_name` 可编辑,默认来自 `target_order.locator_value``locator_type=GROUP_CODE`Fit 的最终订单投影字段 `fit_name` 可编辑,默认来自 `guest_name ?? target_order.locator_value`Agent 原始 `target_order.locator_value` 始终只读,用户编辑只影响本系统最终订单投影和确认快照。`UPDATE_BOOKING` 顶部展示本地当前值到 Agent 修改后值的 `change_summary[]`,日期变化时连带展示 Nights 差异,字段区展示合并后的最终值;`CANCEL_BOOKING` 从本地订单投影只读展示 current / final 模型不显示编辑控件。Nights 由后端按酒店本地日期派生前端只展示不计算Adult 不显示。
- Room Information 卡已由后端返回业务展示模型,前端已按该模型完成第一版业务化展示,不再自行从 Agent raw payload、`business_fields``target_order` 计算。读取路径是业务卡 `display_payload.room_information``NEW_BOOKING` 展示最终值Group 的最终订单投影字段 `group_block_name` 可编辑,默认来自 Agent `target_order.locator_value``locator_type=GROUP_CODE`Fit 的最终订单投影字段 `fit_name` 可编辑,默认来自 `guest_name ?? target_order.locator_value`Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,用户编辑只影响本系统最终订单投影和确认快照。`UPDATE_BOOKING` 顶部展示本地当前值到 Agent 修改后值的 `change_summary[]`,日期变化时连带展示 Nights 差异,字段区展示合并后的最终值;`CANCEL_BOOKING` 从本地订单投影只读展示 current / final 模型不显示编辑控件。Nights 由后端按酒店本地日期派生前端只展示不计算Adult 不显示。
- Room Information 卡 Breakfast 前端显示为“含早”勾选框Group 固定勾选且只读Fit 由后端按最终 Rate Code 中 `RB` / `RO` 派生无法派生时作为必填勾选项。Group Booking Status 仅 Group 显示,稳定 code 为 `TEN` / `DEF` / `INQ`,展示文案为 `TEN-Tentative``DEF-Definite``INQ-Inquiry`New Group 默认 `TEN``NEW_BOOKING` / `UPDATE_BOOKING` 确认前可改选,`CANCEL_BOOKING` 只读。
- Rooming List 卡确认存在跨卡联动:同订单为 Group 时,确认 `ROOMING_LIST` 后后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN``INQ`Fit 不显示也不变更该状态。该自动变更由后端写 `V4_ROOMING_LIST_AUTO_DEF` 审计,前端只展示刷新后的状态;如果没有可更新 Room Information 投影,确认仍成功,后端只写安全审计提示。
- Rooming List 卡确认存在跨卡联动:同订单为 Group 时,确认 `ROOMING_LIST` 后后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN``INQ`Fit 不显示也不变更该状态。该自动变更由后端写 `V4_ROOMING_LIST_AUTO_DEF` 审计,并在后续任务详情刷新时让 Room Information 的 `display_payload.room_information.final_values``confirmed_payload.room_information.final_values` 保持 DEF 口径一致;当前订单详情 `order_overview` 不返回 Group Booking Status 字段,仍只展示既有确认快照字段;如果没有可更新 Room Information 投影,确认仍成功,后端只写安全审计提示。
- Rooming List 卡第一版是轻量事项确认卡:前端展示卡片标题、状态、目标订单信息和“确认卡片”按钮即可;不要做名单 rows、附件预览、Excel 生成或 PMS 导入入口。确认仅表示该 Rooming List 事项已人工处理。
- Payment 卡下一阶段建议由后端在 `display_payload_json.payment_attachments[]` 返回安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等,不包含外链。第一版 `attachment_ids[]` 是 Agent 返回的只读业务事实,前端只展示并确认卡片,不允许用户增删、替换或重新选择附件集合,也不把 `attachment_ids[]``externalUrl` 或完整附件对象提交回确认接口。
- V4 复核态卡片仍是原业务卡,不新建单独复核任务卡;页面状态显示“需要复核”,问题字段用 `fields[].validation_errors` 红字提示,主按钮文案统一为“确认卡片”。前端内部必须根据 `card_status=REVIEW_REQUIRED` 调用 `review-resolution`,不要调用普通 `confirm`
@@ -539,10 +539,10 @@ RESERVATION_ROOMING_LIST_GENERATE
- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线M002 V4 CP4 已把正式 V4 回调写入这些表M002 V4 CP5 已开放查询M002 V4 CP6 已开放普通卡片确认和 S10/S99 ackM002 V4 CP7 已开放复核解阻与复核场景订单归属确认M002 V4 CP8 已开放目录校验和 V4 任务卡 `fields[]` 字段白名单M002 V4 CP11 已新增目录表、数据库种子和 lookup API目录管理后台 CP1 已新增 `RESERVATION_CATALOG_MANAGE` 后端接口V4 审计查询已开放 `RESERVATION_AUDIT_READ` 下的订单任务审计和来源通知审计。
- V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false``editable=true``confirmable=false``reviewable=true``readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`
- CP11 起 V4 入站阶段按当前酒店数据库目录做校验Account 缺失或不存在时 Basic Information 卡直接 `REVIEW_REQUIRED`;业务卡已有 `room_items[].room_type_code``rate_code` 但不在当前酒店目录时,业务卡也会直接 `REVIEW_REQUIRED`,错误会回显在 `fields[].validation_errors`
- CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order``order_ref``manual_review`、校验诊断字段以及前端额外注入字段不会写入 `confirmed_payload_json`
- CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order``order_ref``manual_review`、校验诊断字段以及前端额外注入字段不会写入内部确认快照
- 业务卡目录校验会递归检查稳定模型或历史兼容结构。例如 Room Information 新结构的房型位于 `/room_information/final_values/room_items/0/room_type_code`,错误详情会使用 `room_information.final_values.room_items.0.room_type_code`;历史兼容 `UPDATE_BOOKING` 的房型可能仍使用 `business_fields.after.room_items.0.room_type_code`。前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]`
- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/room_information/final_values/room_items/0/room_type_code","value":"RM2"}]}``confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*``room_information.final_values.*` 或历史兼容 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order``order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段也不能替换整个对象 / 数组。
- V4 `fields[]` 第一版字段说明Basic Information 固定返回 `/basic_information/account_code``/basic_information/market_code``/basic_information/source_code`;其中 Account `control_type=select``options_source=reservation_v4_account_catalog`Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code`;前端不要自行补未返回字段。
- V4 `fields[]` 第一版字段说明Basic Information 固定返回 `/basic_information/account_code``/basic_information/market_code``/basic_information/source_code`;其中 Account `control_type=select``options_source=reservation_v4_account_catalog`Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code``REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer前端不要自行补未返回字段。
- V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象后端确认前仍会重新校验目录。Rate Code 下一阶段依赖 Account + `booking_type`:前端需在 Account 已选 / 已确认且能取得当前业务 event `booking_type` 后再请求 Rate CodeAccount 改变后清空或重新校验已选 Rate Code缺失条件时禁用或空态不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]`
- M002 V4 CP12 前端已接入上述三个 lookup APIV4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项,空 `items[]``stale=true``warnings[]` 作为非阻塞提示展示Account 选择后只展示目录返回的 `market_code` / `source_code` 辅助确认,确认 / 复核请求仍只提交用户选择的 code。
- V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft``confirm``manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。

View File

@@ -33,7 +33,7 @@
| `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 |
| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段、V4 总览和 V4 `v4_order_tasks[]` 时间线,前端订单详情总览页已接入 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]`、V4 `v4_order_tasks[]``related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。 |
| `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 当前 Controller 不接收 `hotel_id`;如后续多酒店隔离需要前端显式传酒店上下文,请后端补可选入参或确认按 taskId 全局唯一即可。 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版Room Information 后端展示模型已补齐且前端业务化展示已接入Rooming List 确认自动 DEF 后端联动已完成;复核态字段白名单第一版已随 `fields[]` 返回Payment 附件安全摘要仍待补齐 | 可以Room Information 前端业务化展示已完成Rooming List 轻量确认可继续进入前端业务化展示Payment 预览仍需后端补摘要后再完整联动 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage DisplayTrace 卡 `department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup不开放自由输入。Room Information 已由后端返回 `display_payload.room_information`New 展示最终值Update 展示 `current_values``proposed_values``final_values``change_summary[]`Cancel 展示本地订单投影只读Nights 后端按酒店本地日期派生Breakfast 前端为含早勾选框Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,Group 默认来自 `target_order.locator_value``locator_type=GROUP_CODE`Fit 默认来自 `guest_name ?? target_order.locator_value`,但 Agent 原始 `target_order.locator_value` 只读且不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后,如同订单为 Group后端会自动把已确认 Room Information 快照中的 Group Booking Status 置为 `DEF`前端刷新后展示即可,不需要自行提交或计算该状态。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡下一阶段建议返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版Room Information 后端展示模型已补齐且前端业务化展示已接入Rooming List 确认自动 DEF 后端联动已完成;复核态字段白名单第一版已随 `fields[]` 返回Payment 附件安全摘要仍待补齐 | 可以Room Information 前端业务化展示已完成Rooming List 轻量确认可继续进入前端业务化展示Payment 预览仍需后端补摘要后再完整联动 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage DisplayTrace 卡 `department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup不开放自由输入。Room Information 已由后端返回 `display_payload.room_information`New 展示最终值Update 展示 `current_values``proposed_values``final_values``change_summary[]`Cancel 展示本地订单投影只读Nights 后端按酒店本地日期派生Breakfast 前端为含早勾选框Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,默认值可来自 Agent `target_order`,但 Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,也不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer`write_target=confirmed_payload` 是前端请求体语义,不是后端表字段名`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`,并以 `fields[].editable` 渲染当前卡白名单字段,不只渲染 missing/error 字段。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后,如同订单为 Group后端会自动把已确认 Room Information 快照中的 Group Booking Status 置为 `DEF`后续刷新任务详情的 `display_payload``confirmed_payload` 都会显示 DEF,不需要前端自行提交或计算该状态,并可通过订单任务审计看到 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡下一阶段建议返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 |
| `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 |
| `POST /api/reservation/tasks/{taskId}/confirm` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 |
| `GET /api/reservation/tasks/{taskId}/audits` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如审计查询需要酒店上下文隔离,请后端补可选入参。 |
@@ -1201,3 +1201,4 @@ POST /api/reservation/tasks/{taskId}/order-binding
- V4 真实目录与 Lookup API 第一版已在后端 CP11 落地,前端 CP12 已接入 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 用于 V4 字段选择控件。前端按 `options_source` 选择接口,空列表 / stale / warnings 只做非阻塞提示,确认和复核仍只提交 codeRate Code 下一阶段已确认要按 Account + `booking_type` 过滤,前端需等后端新增 `account_code``booking_type` 参数和适用性校验后再联动,不能自行硬编码 OWNER RATE Excel真实 PMS 同步、目录管理后台扩展和 SuperAgent 目录机器接口仍后置。
- V4 Payment 附件预览下一阶段已确认:后端需补 `payment_attachments[]` 安全摘要;前端图片缩略图 + 点击大图预览,非图片文件列表 + 下载;预览和下载仍走 SourceMessage conversation 原文权限链路。`attachment_ids[]` 第一版作为 Agent 返回的只读业务事实,前端只展示并确认卡片,不做附件集合编辑。
- V4 复核态交互已确认:`REVIEW_REQUIRED` 不新建独立复核任务卡,仍在原业务卡内编辑当前卡 `fields[]` 白名单业务字段;问题字段红字提示;主按钮文案统一为“确认卡片”,但前端内部调用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`
- V4 Room Information smoke 修复已完成:`REVIEW_REQUIRED` 下当前卡白名单业务字段会按 `fields[].editable=true` 暴露给前端,`/room_information/final_values/...` pointer 可用于复核提交Basic Information 的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`Rooming List 自动 DEF 后刷新任务详情的 Room Information `display_payload` / `confirmed_payload` 应显示 DEF审计接口返回 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。

View File

@@ -571,16 +571,16 @@ Room Information 卡展示模型:
- `ROOM_INFORMATION` 卡只由 `NEW_BOOKING``UPDATE_BOOKING``CANCEL_BOOKING` 三类 event 触发;`TRACE_RESERVATION_NOTES``ROOMING_LIST``PAYMENT` 不触发房型信息卡。
- SuperAgent 仍只输出字段契约中的业务字段。Nights、Breakfast、Group Booking Status、Block ID、Confirmation Number 和 Adult 不由 SuperAgent 输出;其中 Adult 第一版不在卡内展示。
- 后端已在 `GET /api/reservation/order-tasks/{orderTaskId}` 的 Room Information 业务卡 `display_payload.room_information` 中补稳定展示模型,结构为 `event_type``booking_type``current_values``proposed_values``final_values``change_summary[]``group_booking_status_options[]`。前端按该展示模型渲染业务 UI不再从 Agent raw payload / `target_order` 自行推导;`fields[]` 继续作为确认 / 复核的可编辑字段白名单。
- `fields[]` 的 Room Information 主路径统一为 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code`。确认接口收到该结构时,后端会从展示模型派生 `confirmed_payload_json.room_information.final_values`,并重新计算 `nights``breakfast_included``group_booking_status_label`只读字段、Agent `target_order`、Adult 和前端注入字段不会写入确认快照。
- 后端已在 `GET /api/reservation/order-tasks/{orderTaskId}` 的 Room Information 业务卡 `display_payload.room_information` 中补稳定展示模型,结构为 `event_type``booking_type``current_values``proposed_values``final_values``change_summary[]``group_booking_status_options[]`。前端按该展示模型渲染业务 UI不再从 Agent raw payload / `target_order` 自行推导;`fields[]` 继续作为确认 / 复核的可编辑字段白名单。`fields[].write_target` 对前端只表达请求体目标,例如 `confirmed_payload``review_resolution.field_overrides`,不暴露后端内部列名。
- `fields[]` 的 Room Information 主路径统一为 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code`。确认接口收到该结构时,后端会从展示模型派生 `confirmed_payload_json.room_information.final_values`,并重新计算 `nights``breakfast_included``group_booking_status_label`只读字段、Agent `target_order`、Adult 和前端注入字段不会写入确认快照。`REVIEW_REQUIRED` 状态下,当前卡白名单内业务字段可以返回 `editable=true` 并允许同一 pointer 走 `review-resolution`,不再限定只能修空值、`missing_fields[]` 或目录错误字段。
- `NEW_BOOKING`卡片展示创建后的最终值。Agent 提供 `target_order``arrival_date``departure_date``rate_code``booking_scenario``room_items[]`Fit 可提供 `guest_name`;后端派生 `nights``breakfast_included` 和 Group Booking Status。
- `UPDATE_BOOKING`:后端从本地订单投影读取当前值,用 Agent `after` 合并得到最终值;页面上方展示本次实际变化的 `change_summary[]`,例如 `入住日期2026-07-12 -> 2026-07-20`。如果日期变化导致 `nights` 变化,`nights` 也必须出现在差异区;字段区展示合并后的最终值。
- `CANCEL_BOOKING`:不使用 Agent 输出当前订单快照后端从本地订单投影读取当前值并只读展示用户只确认整单取消。Cancel 卡不允许编辑 Group Booking Status、Breakfast、日期、Rate Code 或房型房量。
- `nights` 由后端按酒店本地业务日期计算:`departure_date - arrival_date`,不涉及时区和 UTC日期缺失、非法或离店早于入住时`nights` 为空。第一版确认校验要求日期必填,后续如需更严格营业日规则另开 checkpoint。
- `breakfast_included` 是卡片展示和确认使用的布尔字段。Group 固定含早前端显示勾选且只读Fit 按 Rate Code 派生Rate Code 包含 `RB` 时含早,包含 `RO` 时不含早;如果 Rate Code 无法派生,前端显示必填勾选框,由用户确认是否含早。
- Group Booking Status 仅 Group 显示,稳定 code 为 `TEN``DEF``INQ`,前端显示 `TEN-Tentative``DEF-Definite``INQ-Inquiry`。New Group 默认 `TEN``booking_scenario=STANDARD | PROPOSAL` 仅保留为 Agent 场景参考,不映射 Group Booking Status。`NEW_BOOKING` / `UPDATE_BOOKING` 确认前可手动改选,`CANCEL_BOOKING` 只读。
- `ROOMING_LIST` 卡确认时,如果同订单为 Group后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN``INQ`;该自动变更写入 `V4_ROOMING_LIST_AUTO_DEF` 业务审计。Fit 不显示也不变更 Group Booking Status。
- `target_order.locator_value` 不作为前端可编辑字段;订单归属错误时通过 V4 复核选择正确订单或创建正确订单投影,不直接改写 Agent 原始 `target_order.locator_value`。但 New Booking 创建 / 确认的最终订单投影字段允许编辑Group 显示并允许编辑 `group_block_name`,默认值来自 `target_order.locator_value``locator_type=GROUP_CODE`Fit 显示并允许编辑 `fit_name`,默认值来自 `guest_name ?? target_order.locator_value`。用户修改这些字段只影响本系统最终订单投影和确认快照,不回写 Agent 原始定位字段。
- `ROOMING_LIST` 卡确认时,如果同订单为 Group后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN``INQ`;该自动变更写入 `V4_ROOMING_LIST_AUTO_DEF` 业务审计,并且刷新任务详情时 Room Information 的 `display_payload.room_information.final_values``confirmed_payload.room_information.final_values` 都以后端 DEF 后的确认快照为准。当前订单详情 `order_overview` 不返回 Group Booking Status 字段,仍只展示既有确认快照字段。Fit 不显示也不变更 Group Booking Status。
- `target_order.locator_value` 不作为前端可编辑字段,也不在普通任务详情的 Basic Information `display_payload` / `confirmed_payload` 中返回;订单归属错误时通过 V4 复核选择正确订单或创建正确订单投影,不直接改写 Agent 原始 `target_order.locator_value`。但 New Booking 创建 / 确认的最终订单投影字段允许编辑Group 显示并允许编辑 `group_block_name`,默认值来自 Agent `target_order.locator_value``locator_type=GROUP_CODE`Fit 显示并允许编辑 `fit_name`,默认值来自 `guest_name ?? target_order.locator_value`。用户修改这些字段只影响本系统最终订单投影和确认快照,不回写 Agent 原始定位字段。
- Block ID 和 Confirmation Number 第一版只读;存在本地投影或未来 PMS 结果时展示否则为空。Block ID 仅 Group 显示Confirmation Number 仅 Fit 显示。
V4 任务详情页第一版字段白名单:
@@ -601,7 +601,7 @@ Rooming List 卡事项确认规则:
- Rooming List 卡第一版只做事项确认不做名单解析、附件预览、Excel 生成或 PMS 导入。
- `ROOMING_LIST` event 不输出 `rows[]`、逐人名单、同住分组、18 列、Excel 或 PMS 导入参数,也不要求单独输出 `attachment_ids[]`
- 页面应展示卡片标题、状态、目标订单信息和“确认卡片”按钮;如需查看来源内容,仍通过本订单任务底部的 `SOURCE_MESSAGE_DISPLAY` 查看当前触发 SourceMessage 正文和附件摘要。
- 用户点击“确认卡片”表示已人工处理该 Rooming List 事项;该确认会更新 V4 卡片状态和订单任务派生状态;如果同订单为 Group 且存在可更新的已确认 Room Information 快照,后端同时覆盖该快照里的 `group_booking_status=DEF``group_booking_status_label=DEF-Definite`,不改变 Agent 原始 payload。没有可更新投影时确认仍成功只记录安全审计提示不临时创建不完整 Room Information。该动作不代表 M010 Rooming List Excel 已生成,也不代表 PMS / OPERA / OHIP 已执行。
- 用户点击“确认卡片”表示已人工处理该 Rooming List 事项;该确认会更新 V4 卡片状态和订单任务派生状态;如果同订单为 Group 且存在可更新的已确认 Room Information 快照,后端同时覆盖该快照里的 `group_booking_status=DEF``group_booking_status_label=DEF-Definite`,不改变 Agent 原始 payload,并写入可通过 V4 订单任务审计接口查询的 `V4_ROOMING_LIST_AUTO_DEF` 摘要。没有可更新投影时确认仍成功,只记录安全审计提示,不临时创建不完整 Room Information。该动作不代表 M010 Rooming List Excel 已生成,也不代表 PMS / OPERA / OHIP 已执行。
- 独立 Rooming List Excel 生成能力仍属于 M010 `/reservation/rooming-lists/new` 工具页面,第一版不嵌入 V4 Rooming List 卡。
Payment 卡附件展示规则:

View File

@@ -49,8 +49,8 @@
| `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 |
| `GET /api/reservation/workbench-items` | `FRONTEND_USER` | 已实现 M002 V4 CP5强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload同来源时间下使用 `updated_at` / `created_at` / 数字 ID 稳定排序 |
| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload`card_status` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage DisplayRoom Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值Payment 卡可返回付款凭证附件安全摘要Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计本接口不得直接返回邮件正文、HTML 或附件 URL来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取Room Information 展示模型不得返回 PMS 原始响应、价格明细、AI 原始 payload 或跨酒店订单值Payment 安全摘要只能包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 |
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;返回卡片确认复核解阻审计摘要 | 查询审计不再写审计返回快照必须脱敏不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage DisplayBasic Information 不返回 Agent `target_order`Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计本接口不得直接返回邮件正文、HTML 或附件 URL来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information 和 Room Information 展示模型不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order` 或跨酒店订单值Payment 安全摘要只能包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 |
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;返回卡片确认复核解阻`V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 审计摘要 | 查询审计不再写审计返回快照必须脱敏不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
| `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload 或附件 URL前端普通通知卡如遇 URL-like 附件字符串必须二次脱敏 |
| `GET /api/reservation/source-notifications/{notificationId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权;仅返回 S10/S99 来源通知 ack 审计摘要 | 查询审计不再写审计返回快照必须脱敏不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
| `GET /api/reservation/lookups/accounts` | `FRONTEND_USER` | 已实现 M002 V4 CP11强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 Account code、显示名、派生 Market / Source 和目录安全元数据 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、Secret 或外部同步错误详情 |

View File

@@ -61,7 +61,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
private static final List<String> V4_ORDER_TASK_AUDIT_ACTIONS = List.of(
"V4_CARD_CONFIRM",
"V4_CARD_REVIEW_RESOLVE");
"V4_CARD_REVIEW_RESOLVE",
"V4_ROOMING_LIST_AUTO_DEF");
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
private final ReservationAiBatchMapper batchMapper;

View File

@@ -594,7 +594,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
JsonNode submittedFinalValues = submittedPayload.path("room_information").path("final_values");
JsonNode submittedRoot = submittedFinalValues.isObject() ? submittedFinalValues : submittedPayload;
String bookingType = textAt(payload.path("room_information"), "booking_type");
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, submittedRoot, List.of());
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, finalValues, submittedRoot, List.of());
normalizeRoomInformationDerivedFields(bookingType, finalValues);
payload.withObject("/room_information").set("change_summary",
changeSummary(payload.path("room_information").path("current_values"), finalValues));
@@ -603,6 +603,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void overlayEditableRoomInformationLeaves(
ReservationV4TaskCardSnapshot card,
String bookingType,
JsonNode finalValues,
ObjectNode target,
JsonNode submitted,
List<String> path) {
@@ -613,11 +614,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
List<String> childPath = appendPath(path, field.getKey());
if (!isRoomInformationPathWritable(card, bookingType, childPath)) {
if (!isRoomInformationPathWritable(card, bookingType, childPath, finalValues)) {
continue;
}
JsonNode submittedValue = submitted.get(field.getKey());
overlayEditableRoomInformationValue(card, bookingType, target, field.getKey(), field.getValue(),
overlayEditableRoomInformationValue(card, bookingType, finalValues, target, field.getKey(), field.getValue(),
submittedValue, childPath);
}
}
@@ -625,6 +626,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void overlayEditableRoomInformationArray(
ReservationV4TaskCardSnapshot card,
String bookingType,
JsonNode finalValues,
ArrayNode target,
JsonNode submitted,
List<String> path) {
@@ -634,7 +636,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
int size = Math.min(target.size(), submitted.size());
for (int index = 0; index < size; index++) {
List<String> childPath = appendPath(path, String.valueOf(index));
overlayEditableRoomInformationValue(card, bookingType, target, index, target.get(index), submitted.get(index),
overlayEditableRoomInformationValue(card, bookingType, finalValues, target, index, target.get(index), submitted.get(index),
childPath);
}
}
@@ -642,21 +644,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void overlayEditableRoomInformationValue(
ReservationV4TaskCardSnapshot card,
String bookingType,
JsonNode finalValues,
ObjectNode parent,
String fieldName,
JsonNode currentValue,
JsonNode submittedValue,
List<String> path) {
if (currentValue != null && currentValue.isObject()) {
overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path);
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, (ObjectNode) currentValue, submittedValue, path);
return;
}
if (currentValue != null && currentValue.isArray()) {
overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path);
overlayEditableRoomInformationArray(card, bookingType, finalValues, (ArrayNode) currentValue, submittedValue, path);
return;
}
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()
&& isRoomInformationPathWritable(card, bookingType, path)) {
&& isRoomInformationPathWritable(card, bookingType, path, finalValues)) {
parent.set(fieldName, submittedValue);
}
}
@@ -664,21 +667,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private void overlayEditableRoomInformationValue(
ReservationV4TaskCardSnapshot card,
String bookingType,
JsonNode finalValues,
ArrayNode parent,
int index,
JsonNode currentValue,
JsonNode submittedValue,
List<String> path) {
if (currentValue != null && currentValue.isObject()) {
overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path);
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, (ObjectNode) currentValue, submittedValue, path);
return;
}
if (currentValue != null && currentValue.isArray()) {
overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path);
overlayEditableRoomInformationArray(card, bookingType, finalValues, (ArrayNode) currentValue, submittedValue, path);
return;
}
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()
&& isRoomInformationPathWritable(card, bookingType, path)) {
&& isRoomInformationPathWritable(card, bookingType, path, finalValues)) {
parent.set(index, submittedValue);
}
}
@@ -686,7 +690,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private boolean isRoomInformationPathWritable(
ReservationV4TaskCardSnapshot card,
String bookingType,
List<String> path) {
List<String> path,
JsonNode finalValues) {
if (path == null || path.isEmpty() || EVENT_CANCEL_BOOKING.equals(card.eventType())) {
return false;
}
@@ -708,7 +713,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return EVENT_NEW_BOOKING.equals(card.eventType());
}
if ("breakfast_included".equals(fieldName)) {
return BOOKING_TYPE_FIT.equals(bookingType);
JsonNode currentBreakfast = finalValues == null ? null : finalValues.path("breakfast_included");
return BOOKING_TYPE_FIT.equals(bookingType) && isMissingOrNull(currentBreakfast);
}
if ("group_booking_status".equals(fieldName)) {
return BOOKING_TYPE_GROUP.equals(bookingType);
@@ -1739,7 +1745,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
}
String bookingType = textAt(confirmedPayload.path("room_information"), "booking_type");
if (!isRoomInformationPathWritable(card, bookingType, finalValuePath)) {
JsonNode finalValues = confirmedPayload.path("room_information").path("final_values");
if (!isRoomInformationPathWritable(card, bookingType, finalValuePath, finalValues)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
}
}
@@ -1829,6 +1836,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
ObjectNode confirmedPayload,
String pointer,
JsonNode current) {
if (isStableRoomInformationPayload(confirmedPayload)
&& pointer.startsWith("/room_information/final_values/")) {
return true;
}
Set<String> validationPointers = collectValidationErrorPointers(card.validationErrorsJson());
if (validationPointers.contains(pointer)) {
return true;

View File

@@ -88,6 +88,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
private static final String BOOKING_TYPE_GROUP = "GROUP";
private static final String BOOKING_TYPE_FIT = "FIT";
private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE";
private static final String WRITE_TARGET_CONFIRMED_PAYLOAD = "confirmed_payload";
private static final String WRITE_TARGET_REVIEW_FIELD_OVERRIDES = "review_resolution.field_overrides";
private static final String GROUP_BOOKING_STATUS_TEN = "TEN";
private static final Map<String, String> GROUP_BOOKING_STATUS_LABELS = Map.of(
"TEN", "TEN-Tentative",
@@ -397,7 +399,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
ReservationV4ActionAvailabilityResult orderAvailability,
ReservationV4TaskCardSnapshot basicCard) {
JsonNode displayPayload = displayPayloadForCard(orderTask, card);
JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson());
JsonNode confirmedPayload = confirmedPayloadForCard(card);
JsonNode reviewResolution = parseJson(card.reviewResolutionJson());
JsonNode validationErrors = parseJson(card.validationErrorsJson());
ReservationV4ActionAvailabilityResult availability = cardAvailability(card, orderAvailability, basicCard);
@@ -429,6 +431,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card) {
JsonNode displayPayload = parseJson(card.displayPayloadJson());
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
return safeBasicInformationPayload(displayPayload);
}
if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) {
return displayPayload;
}
@@ -443,6 +448,52 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
return safePayload;
}
/**
* 生成卡片安全确认 payloadBasic Information 不向前端暴露 Agent target_order 或其它定位原文。
*/
private JsonNode confirmedPayloadForCard(ReservationV4TaskCardSnapshot card) {
JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson());
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
return safeBasicInformationPayload(confirmedPayload);
}
return confirmedPayload;
}
/**
* 清洗 Basic Information 展示 / 确认 payload只保留前端需要的订单级基础字段。
*/
private JsonNode safeBasicInformationPayload(JsonNode payload) {
if (payload == null || !payload.isObject()) {
return payload == null ? NullNode.getInstance() : payload;
}
ObjectNode safePayload = objectMapper.createObjectNode();
safePayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name());
String orderRef = textAt(payload, "order_ref");
if (hasText(orderRef)) {
safePayload.put("order_ref", orderRef);
}
JsonNode source = payload.path("basic_information").isObject()
? payload.path("basic_information")
: payload;
ObjectNode basicInformation = objectMapper.createObjectNode();
copySafeBasicInformationField(source, basicInformation, "account_code");
copySafeBasicInformationField(source, basicInformation, "account_name");
copySafeBasicInformationField(source, basicInformation, "market_code");
copySafeBasicInformationField(source, basicInformation, "source_code");
copySafeBasicInformationField(source, basicInformation, "manual_review");
copySafeBasicInformationField(source, basicInformation, "missing_fields");
safePayload.set("basic_information", basicInformation);
return safePayload;
}
private void copySafeBasicInformationField(JsonNode source, ObjectNode target, String fieldName) {
JsonNode value = source == null ? null : source.get(fieldName);
if (value == null || value.isMissingNode() || value.isNull()) {
return;
}
target.set(fieldName, value);
}
private ReservationV4TaskCardResult toSourceNotificationCard(
ReservationV4SourceNotificationSnapshot notification,
ReservationV4ActionAvailabilityResult availability) {
@@ -1019,6 +1070,11 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
ObjectNode currentValues = currentRoomInformationProjection(orderTask, card);
ObjectNode proposedValues = proposedRoomInformationValues(eventType, bookingType, locatorType, locatorValue, displayPayload);
ObjectNode finalValues = finalRoomInformationValues(eventType, bookingType, locatorType, locatorValue, currentValues, proposedValues);
ObjectNode confirmedFinalValues = confirmedStableRoomInformationFinalValues(card);
if (confirmedFinalValues != null) {
normalizeRoomInformationDerivedFields(bookingType, confirmedFinalValues);
finalValues = roomInformationDisplayValues(bookingType, confirmedFinalValues, true);
}
ObjectNode model = objectMapper.createObjectNode();
model.put("event_type", eventType);
@@ -1031,6 +1087,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
return model;
}
private ObjectNode confirmedStableRoomInformationFinalValues(ReservationV4TaskCardSnapshot card) {
JsonNode payload = parseJson(card.confirmedPayloadJson());
JsonNode finalValues = payload.path("room_information").path("final_values");
return finalValues.isObject() ? ((ObjectNode) finalValues).deepCopy() : null;
}
/**
* 从同一本地订单更早已确认 Room Information 卡中提取当前订单投影。
*/
@@ -1334,7 +1396,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
true,
"select",
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
"reservation_v4_account_catalog",
false,
validationMessages(validationErrors, "/basic_information/account_code", "basic_information.account_code"),
@@ -1462,7 +1524,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
required,
controlType,
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
optionsSource,
false,
validationMessages(validationErrors, pointer, fieldPath),
@@ -1491,7 +1553,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
required,
controlType,
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
optionsSource,
false,
validationMessages(validationErrors, pointer, fieldPath),
@@ -1506,13 +1568,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
if (!availability.editable()) {
return false;
}
if (!availability.reviewable()) {
return true;
}
if (validationErrorPointers(validationErrors).contains(pointer)) {
return true;
}
return isUnresolvedReviewLeaf(currentValue);
return true;
}
private Set<String> validationErrorPointers(JsonNode validationErrors) {
@@ -1641,7 +1697,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
controlType(fieldPath),
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
optionsSource(fieldPath),
false,
fieldValidationErrors,

View File

@@ -2,8 +2,10 @@ package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -228,6 +230,18 @@ class ReservationV4CommandControllerTest {
org.assertj.core.api.Assertions.assertThat(confirmedPayload)
.contains("\"group_booking_status\":\"DEF\"")
.contains("\"group_booking_status_label\":\"DEF-Definite\"");
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}",
roomInformation.orderTask().id()).param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_booking_status")
.value("DEF"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.group_booking_status")
.value("DEF"));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits",
roomingList.orderTask().id()).param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[?(@.action=='V4_ROOMING_LIST_AUTO_DEF')].after_snapshot.update_status")
.value(contains("UPDATED")));
assertAuditCount("V4_ROOMING_LIST_AUTO_DEF", "v4-command-admin",
roomInformation.businessCard().id().toString(), "\"update_status\":\"UPDATED\"", 1);
}
@@ -980,6 +994,10 @@ class ReservationV4CommandControllerTest {
"version": 0,
"reason": "按稳定 Room Information 模型修正房型",
"field_overrides": [
{
"field_pointer": "/room_information/final_values/arrival_date",
"value": "2026-08-02"
},
{
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
"value": "RM2"
@@ -990,10 +1008,14 @@ class ReservationV4CommandControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code")
.value("RM2"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.arrival_date")
.value("2026-08-02"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.nights")
.value(2))
.value(1))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields").doesNotExist())
.andExpect(jsonPath("$.business_cards[0].review_resolution.field_overrides[0].field_pointer")
.value("/room_information/final_values/arrival_date"))
.andExpect(jsonPath("$.business_cards[0].review_resolution.field_overrides[1].field_pointer")
.value("/room_information/final_values/room_items/0/room_type_code"));
}
@@ -1049,6 +1071,67 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
}
@Test
void shouldRejectRoomInformationReviewWhenFitBreakfastIsDerivedFromRateCode() throws Exception {
seedRateCode(HOTEL_ID, 990000000000070199L, "FIT-RB");
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-room-info-derived-breakfast-001",
Instant.parse("2026-07-19T01:22:08Z"),
990000000000070106L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
"""
{
"card_type":"ROOM_INFORMATION",
"event_type":"NEW_BOOKING",
"target_order":{"booking_type":"FIT","locator_type":"BOOKING_CODE","locator_value":"FIT-V4-REVIEW-RI-001"},
"business_fields":{
"event_type":"NEW_BOOKING",
"guest_name":"FIT REVIEW",
"arrival_date":"2026-08-01",
"departure_date":"2026-08-03",
"rate_code":"FIT-RB",
"room_items":[{"room_type_code":"TWN","room_count":1}]
}
}
""",
"""
[
{
"field_path": "room_information.final_values.room_items.0.room_type_code",
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
"message": "人工复核测试",
"detail": "room_information.final_values.room_items.0.room_type_code: 人工复核测试。"
}
]
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.breakfast_included")
.value(true))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/breakfast_included')]")
.doesNotExist());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "/room_information/final_values/breakfast_included", "value": false}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_NOT_ALLOWED"));
}
@Test
void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception {
Long currentOrderId = 990000000000070111L;
@@ -1577,6 +1660,20 @@ class ReservationV4CommandControllerTest {
.andExpect(status().isOk());
}
private void seedRateCode(String hotelId, Long id, String code) {
jdbcTemplate.update(
"""
INSERT INTO workflow_reservation_catalog_code (
id, hotel_id, catalog_type, code, display_name, status, source_system, external_id,
sort_order, catalog_version, last_synced_at, metadata_json, version,
created_at, updated_at, logic_deleted_at, logic_deleted_reason
) VALUES (?, ?, 'RATE_CODE', ?, ?, 'ACTIVE', 'SYSTEM_MANAGED', NULL,
990, 'test-review-derived-breakfast-v1', NULL, '{"pricing_available":false}', 0,
'2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL)
""",
id, hotelId, code, code);
}
private void resolveBusinessReviewCard(SeededOrderTask seeded) throws Exception {
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",

View File

@@ -429,7 +429,7 @@ class ReservationV4QueryControllerTest {
}
@Test
void shouldOnlyMarkRoomInformationReviewProblemFieldsEditable() throws Exception {
void shouldMarkRoomInformationReviewBusinessWhitelistFieldsEditable() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
"mail-v4-room-info-review-fields-001",
Instant.parse("2026-07-18T03:02:30Z"),
@@ -470,11 +470,60 @@ class ReservationV4QueryControllerTest {
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].editable")
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/arrival_date')].editable")
.value(contains(false)))
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/departure_date')].editable")
.value(contains(false)))
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].editable")
.value(contains(false)));
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].write_target")
.value(contains("review_resolution.field_overrides")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/nights')]")
.doesNotExist());
}
@Test
void shouldNotExposeBasicInformationTargetOrderInDisplayOrConfirmedPayload() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
"mail-v4-query-basic-target-order-safe-001",
Instant.parse("2026-07-18T03:02:40Z"),
"GROUP",
"GROUP_CODE",
"GRP-V4-BASIC-SAFE-001",
"NEW_BOOKING",
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name(),
"""
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
""",
null);
ReservationV4TaskCardSnapshot basicCard = workflowRepository.findTaskCardsByOrderTaskId(HOTEL_ID, orderTask.id())
.stream()
.filter(card -> ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType()))
.findFirst()
.orElseThrow();
workflowRepository.confirmTaskCardWithVersion(
HOTEL_ID,
basicCard.id(),
basicCard.version(),
"""
{
"basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"},
"target_order":{"locator_value":"SHOULD-NOT-LEAK-CONFIRMED"}
}
""",
"v4-query-admin",
LocalDateTime.ofInstant(Instant.parse("2026-07-18T03:02:50Z"), ZoneOffset.UTC));
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.basic_information_card.display_payload.basic_information.account_code")
.value("QBD_TRAVEL"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.account_code")
.value("QBD_TRAVEL"))
.andExpect(jsonPath("$.basic_information_card.display_payload.target_order").doesNotExist())
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.target_order").doesNotExist())
.andExpect(content().string(not(containsString("SHOULD-NOT-LEAK"))));
}
@Test
@@ -1024,7 +1073,12 @@ class ReservationV4QueryControllerTest {
""");
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
basicCardStatus, reviewStatusFor(basicCardStatus), """
{"card_type":"BASIC_INFORMATION","order_ref":"order-1","basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}}
{
"card_type":"BASIC_INFORMATION",
"order_ref":"order-1",
"target_order":{"locator_value":"SHOULD-NOT-LEAK-DISPLAY"},
"basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}
}
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(
orderTask,