diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 665ec15..9354428 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -5,15 +5,15 @@ | 最近更新 | 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 后端联动、Room Information 复核 pointer 与任务详情安全边界修复、Room Information 复核 pointer 运行时规则收口、复核 pointer 部署证明与运行时 trace、部署包 build commit 注入收口、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 | -| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug 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` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;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` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。测试机部署证明已补齐:`GET /api/health` 返回 `runtime_marker`、`build_commit`、`build_time`、`build_version`,启动日志也输出同一组非敏感字段;复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。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 目录供给方案。 | +| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`,V4 后新业务主线只写 V4 order task / cards / source notification;Debug 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` 自行推导;如果卡片 payload 已经是稳定 `room_information.final_values` 结构,后端会按稳定模型归一化查询和复核;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` 这类安全语义,不暴露内部列名;查询侧 `fields[].editable` 和命令侧 `review-resolution` 复核 pointer 校验已共用同一套 Room Information 字段策略。复核 pointer 拒绝前会记录 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,包含 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers 和 reject reason,但不记录 payload、邮件正文或附件 URL。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面底部,只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文;Basic Information 和普通业务卡的展示 / 确认 payload 不再返回 Agent `target_order`,普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。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 -- 名称:`M002-V4-build-commit-injection-fix` -- 状态:In Progress,正在收口部署包 build commit 注入和 package fail-fast。 -- 目标:让测试机 / UAT / 生产 Jar 必须从干净 Git 工作区打包,且 Jar 内嵌 build-info 中的 `build.commit` 必须是真实 Git commit;`GET /api/health` 的 `build_commit=UNKNOWN` 不能再作为可部署包产物出现。 -- 边界:本 checkpoint 不做 Payment 附件预览、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。 -- 联调备注:Source conversation 接口仍是带 `SOURCE_MESSAGE_ORIGINAL_READ` 的原文权限接口,可返回 `html_body` 和 `html_body_sanitized`;V4 task detail 本身不得返回 raw HTML。若测试机仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,先访问 `/api/health` 确认 `build_commit` 是否为预期提交;`build_commit` 优先来自 Jar 内嵌 build-info,不能仅靠运行时环境变量证明代码包已更新;本次修复必须先提交后再用新 HEAD 打包,推荐使用 `scripts/package-server-with-build-info.sh`,不能把未提交工作区代码标记为旧提交;再检索日志 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`。没有 health build 字段、`build_commit=UNKNOWN` 或没有该 marker,优先按部署包未更新 / 未进入新代码路径处理,不继续改业务逻辑。 +- 名称:`M002-V4-review-pointer-command-policy-runtime-fix` +- 状态:In Progress,正在收口 Room Information 复核 pointer 查询侧 / 命令侧白名单一致性。 +- 目标:当 `GET /api/reservation/order-tasks/{orderTaskId}` 返回 `editable=true` 且 `write_target=review_resolution.field_overrides` 的 Room Information pointer 时,`POST /review-resolution` 原样提交同 pointer 不应再返回 `V4_REVIEW_POINTER_NOT_ALLOWED`;如果字段级值或目录校验失败,应返回对应字段级错误。 +- 边界:本 checkpoint 不处理 `build_commit=UNKNOWN`,不做 Payment 附件预览、不做 Rate Code lookup、不做 PMS / OPERA / OHIP、不推进 M011。 +- 联调备注:Source conversation 接口仍是带 `SOURCE_MESSAGE_ORIGINAL_READ` 的原文权限接口,可返回 `html_body` 和 `html_body_sanitized`;V4 task detail 本身不得返回 raw HTML。若测试机仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,直接检索日志 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,日志会输出 query-side editable pointers、command-side allowed pointers、validation error pointers、incoming pointer、card type / event type / status、`display_payload_has_room_information_final_values`、稳定 Room Information payload 判断和 reject reason,且不得输出 payload、邮件正文或附件 URL。 ## 2. 当前优先级 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 26693ce..bdaf4ef 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -542,7 +542,7 @@ RESERVATION_ROOMING_LIST_GENERATE - 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`;`REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer;查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略。测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,先访问 `GET /api/health` 看 `build_commit` 是否为最新后端提交,且 `runtime_marker=m002_v4_review_pointer_deployment_proof_v1`;`build_commit` 应来自后端构建包内嵌 build-info,不能只靠运行时环境变量证明代码已部署;确认部署后仍失败,再让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`,日志会输出两侧 pointer 白名单和拒绝原因。前端不要自行补未返回字段。 +- 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;即使该叶子字段原始值缺失、详情页显示 `value=null`,前端仍可按原样提交该 pointer。查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略。测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,确认部署后让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,日志会输出两侧 pointer 白名单、validation error pointers、`display_payload_has_room_information_final_values` 和拒绝原因。前端不要自行补未返回字段。 - 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 Code,Account 改变后清空或重新校验已选 Rate Code;缺失条件时禁用或空态,不硬编码 OWNER RATE Excel。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source`、`catalog_version` 和 `warnings[]`。 - M002 V4 CP12 前端已接入上述三个 lookup API:V4 多卡详情页会按当前卡 `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 多卡最终接口。 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index 86b56be..85a7991 100644 --- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md +++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md @@ -174,7 +174,7 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 - CP11 起确认和复核都会校验当前酒店数据库目录字段;Basic Information 的 `account_code` 必须来自当前酒店 ACTIVE Account 目录,通过后后端派生 `market_code` / `source_code`。 - 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。 - `field_overrides[].field_pointer` 必须是当前卡 `fields[]` 白名单中允许编辑的 RFC 6901 JSON Pointer。`REVIEW_REQUIRED` 是整张原业务卡的复核状态,前端仍在原卡片内展示业务表单,问题字段用红字 / `validation_errors` 强调;用户可修改当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。 -- Room Information 卡的 `fields[].editable` 和 `review-resolution` pointer 校验必须共用同一套字段策略;只要详情接口返回 `editable=true` 且 `write_target=review_resolution.field_overrides`,同一个 pointer 就不得再因为白名单不一致返回 `V4_REVIEW_POINTER_NOT_ALLOWED`。如果仍被拒绝,应先通过 `GET /api/health` 检查 `build_commit` 和 `runtime_marker=m002_v4_review_pointer_deployment_proof_v1`,确认测试机运行包是否包含最新修复;如果确认已部署,再看后端日志 `review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1`,其中会输出 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers 和 reject reason,但不得输出 payload、邮件正文、附件 URL 或敏感数据。 +- Room Information 卡的 `fields[].editable` 和 `review-resolution` pointer 校验必须共用同一套字段策略;只要详情接口返回 `editable=true` 且 `write_target=review_resolution.field_overrides`,同一个 pointer 就不得再因为白名单不一致返回 `V4_REVIEW_POINTER_NOT_ALLOWED`。稳定 Room Information 模型允许复核补写当前卡白名单内缺失叶子字段,例如 `final_values.room_items[0].room_type_code` 原始值缺失但详情页返回可编辑时,命令侧必须接受同一 pointer。若仍被拒绝,应先通过 `GET /api/health` 检查测试机运行包;确认已部署后看后端日志 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,其中会输出 order task、card、incoming pointer、query-side editable pointers、command-side allowed pointers、validation error pointers、`display_payload_has_room_information_final_values` 和 reject reason,但不得输出 payload、邮件正文、附件 URL 或敏感数据。 - 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。 - 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。 - 如果订单任务已经有 `order_id` 且 `target_resolution_status=RESOLVED`,复核请求不能提交不同的 `confirmed_order_id`,否则返回 `V4_ORDER_REBIND_NOT_ALLOWED`;普通任务任意切换订单继续后置。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java index 7d809fa..9a613aa 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java @@ -1696,8 +1696,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ logReviewPointerRejected(card, pointer, "V4_REVIEW_VALUE_INVALID", confirmedPayload); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。"); } + boolean stableRoomInformationPointer = isStableRoomInformationReviewPointer(confirmedPayload, pointer); JsonNode current = findPointerValue(confirmedPayload, segments); if (current == null || current.isMissingNode()) { + if (stableRoomInformationPointer) { + return; + } logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload); throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); } @@ -1780,11 +1784,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } List querySideEditablePointers = roomInformationQuerySideEditablePointers(card, confirmedPayload); List commandSideAllowedPointers = roomInformationCommandSideAllowedPointers(card, confirmedPayload); + Set validationErrorPointers = collectValidationErrorPointers(card.validationErrorsJson()); log.warn( - "V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1, " + "V4 review pointer rejected. review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1, " + "order_task_id={}, card_id={}, card_type={}, event_type={}, card_status={}, review_status={}, " + "incoming_pointer={}, query_side_editable_pointers={}, command_side_allowed_pointers={}, " - + "reject_reason={}, stable_room_information_payload={}", + + "validation_error_pointers={}, reject_reason={}, " + + "display_payload_has_room_information_final_values={}, stable_room_information_payload={}", card.v4OrderTaskId(), card.id(), card.cardType(), @@ -1794,10 +1800,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ safeLogPointer(pointer), querySideEditablePointers, commandSideAllowedPointers, + validationErrorPointers, reasonCode, + isStableRoomInformationPayload(confirmedPayload), isStableRoomInformationPayload(confirmedPayload)); } + private boolean isStableRoomInformationReviewPointer(ObjectNode confirmedPayload, String pointer) { + return isStableRoomInformationPayload(confirmedPayload) + && pointer != null + && pointer.startsWith("/room_information/final_values/"); + } + private List roomInformationQuerySideEditablePointers( ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload) { @@ -1814,77 +1828,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private List roomInformationCommandSideAllowedPointers( ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload) { - if (!isStableRoomInformationPayload(confirmedPayload)) { - return List.of(); - } - JsonNode roomInformation = confirmedPayload.path("room_information"); - JsonNode finalValues = roomInformation.path("final_values"); - if (!finalValues.isObject()) { - return List.of(); - } - List pointers = new ArrayList<>(); - collectRoomInformationCommandAllowedPointers( - pointers, - card, - confirmedPayload, - finalValues, - List.of()); - return List.copyOf(pointers); - } - - private void collectRoomInformationCommandAllowedPointers( - List pointers, - ReservationV4TaskCardSnapshot card, - ObjectNode confirmedPayload, - JsonNode current, - List finalValuePath) { - if (current == null || current.isMissingNode() || current.isNull()) { - addRoomInformationCommandAllowedPointer(pointers, card, confirmedPayload, current, finalValuePath); - return; - } - if (current.isObject()) { - current.fields().forEachRemaining(field -> collectRoomInformationCommandAllowedPointers( - pointers, - card, - confirmedPayload, - field.getValue(), - appendPath(finalValuePath, field.getKey()))); - return; - } - if (current.isArray()) { - for (int index = 0; index < current.size(); index++) { - collectRoomInformationCommandAllowedPointers( - pointers, - card, - confirmedPayload, - current.get(index), - appendPath(finalValuePath, String.valueOf(index))); - } - return; - } - addRoomInformationCommandAllowedPointer(pointers, card, confirmedPayload, current, finalValuePath); - } - - private void addRoomInformationCommandAllowedPointer( - List pointers, - ReservationV4TaskCardSnapshot card, - ObjectNode confirmedPayload, - JsonNode current, - List finalValuePath) { - if (finalValuePath.isEmpty()) { - return; - } - JsonNode roomInformation = confirmedPayload.path("room_information"); - ReservationV4RoomInformationFieldPolicy.RoomInformationWriteDecision decision = - ReservationV4RoomInformationFieldPolicy.finalValueWriteDecision( - card.eventType(), - textAt(roomInformation, "booking_type"), - finalValuePath, - roomInformation.path("final_values")); - String pointer = "/room_information/final_values/" + toJsonPointer(finalValuePath); - if (decision.writable() && isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) { - pointers.add(pointer); - } + return roomInformationQuerySideEditablePointers(card, confirmedPayload); } private String safeLogPointer(String pointer) { diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java index 4faaac1..ac86df8 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java @@ -1183,6 +1183,86 @@ class ReservationV4CommandControllerTest { .value("RM2")); } + @Test + void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-pointer-missing-leaf-001", + Instant.parse("2026-07-19T01:22:19Z"), + 990000000000070117L, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardType.ROOM_INFORMATION.name(), + "UPDATE_BOOKING", + """ + { + "card_type":"ROOM_INFORMATION", + "event_type":"UPDATE_BOOKING", + "target_order":{"booking_type":"GROUP","locator_type":"GROUP_CODE","locator_value":"GRP-V4-REVIEW-MISSING-LEAF-001"}, + "business_fields":{ + "event_type":"UPDATE_BOOKING", + "after":{ + "arrival_date":"2026-08-01", + "departure_date":"2026-08-03", + "room_items":[{"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); + + MvcResult detailResult = performAuthorized(mockMvc, adminToken(), + get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode detail = objectMapper.readTree(detailResult.getResponse().getContentAsString()); + JsonNode editableField = null; + for (JsonNode field : detail.path("business_cards").get(0).path("fields")) { + if ("/room_information/final_values/room_items/0/room_type_code" + .equals(field.path("field_pointer").asText())) { + editableField = field; + break; + } + } + org.assertj.core.api.Assertions.assertThat(editableField) + .as("任务详情必须返回缺失叶子字段的可复核 pointer") + .isNotNull(); + org.assertj.core.api.Assertions.assertThat(editableField.path("editable").asBoolean(false)).isTrue(); + org.assertj.core.api.Assertions.assertThat(editableField.path("write_target").asText()) + .isEqualTo("review_resolution.field_overrides"); + + ObjectNode request = objectMapper.createObjectNode(); + request.put("version", 0); + request.put("reason", "按详情页返回的缺失叶子字段补房型"); + ArrayNode overrides = request.putArray("field_overrides"); + ObjectNode override = overrides.addObject(); + override.put("field_pointer", editableField.path("field_pointer").asText()); + override.put("value", "TWN"); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code") + .value("TWN")); + } + @Test void shouldResolveRoomInformationReviewWhenDisplayPayloadAlreadyUsesStableModel() throws Exception { SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( @@ -1303,14 +1383,17 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY")); org.assertj.core.api.Assertions.assertThat(output.getOut()) - .contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1") + .contains("review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1") .contains("order_task_id=" + seeded.orderTask().id()) .contains("card_id=" + seeded.businessCard().id()) .contains("incoming_pointer=/room_information/final_values/nights") .contains("query_side_editable_pointers=") .contains("/room_information/final_values/room_items/0/room_type_code") .contains("command_side_allowed_pointers=") + .contains("validation_error_pointers=") + .contains("/room_information/final_values/nights") .contains("reject_reason=DERIVED_OR_SYSTEM_FIELD_READONLY") + .contains("display_payload_has_room_information_final_values=true") .doesNotContain("target_order") .doesNotContain("business_fields") .doesNotContain("raw_evidence") @@ -1530,7 +1613,7 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID")); org.assertj.core.api.Assertions.assertThat(output.getOut()) - .contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1") + .contains("review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1") .contains("order_task_id=" + seeded.orderTask().id()) .contains("card_id=" + seeded.businessCard().id()) .contains("incoming_pointer=business_fields/room_items/0/pms_room_type_code") @@ -1570,7 +1653,7 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID")); org.assertj.core.api.Assertions.assertThat(output.getOut()) - .contains("review_pointer_policy=m002_v4_review_pointer_runtime_trace_v1") + .contains("review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1") .contains("incoming_pointer=bad/[EMAIL]/[TOKEN]/[URL]") .contains("reject_reason=V4_REVIEW_POINTER_INVALID") .doesNotContain("user@example.test")