diff --git a/docs/project/README.md b/docs/project/README.md index 7f9e3fe..38ebddf 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -32,6 +32,7 @@ | `requirements/M002-order-task-workflow-v1.md` | 历史参考 | M002 订单任务主流程 V1,已由 V2 承接,保留用于理解早期流程。 | | `requirements/M002-order-task-workflow-v2.md` | 阶段记录 | M002 订单任务主流程 V2,记录当前已阶段实现的 AI 过渡层、S000/S999 兼容、订单任务流转、任务确认和 OPERA 模拟骨架。 | | `requirements/M002-order-task-workflow-v3.md` | 当前有效 | M002 订单任务主流程 V3,基于 2026-07-11 P0 冻结基线和 2026-07-12 P0.1 Parent Group 修订,记录 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed 边界。 | +| `requirements/M002-task-field-control-contract-v1.md` | 当前有效 | M002 任务卡字段控件契约 V1,记录任务详情 `fields[]` 控件元数据、人工复核控件复用和前后端开发边界。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-ai-query-minimal-fields.md` | 阶段记录 | M002 SuperAgent 查询上下文接口 1、2 最小字段落地记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-backend-data-model-design.md` | 阶段记录 | M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 | @@ -70,5 +71,5 @@ - SuperAgent 对外 HTTP 接口以 `integrations/superagent-api-contract.md` 为权威来源。 - SuperAgent MCP 文档以 `integrations/superagent-mcp/` 为对外交付资料包,但字段语义应跟随 HTTP 总契约。 - M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 -- 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表和路由说明为白名单,后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 +- 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表、0712 字段控件说明和 `requirements/M002-task-field-control-contract-v1.md` 为白名单和控件契约基线;后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 - 时间点语义以 `backend-time-design.md` 为准;数据库时间点按 UTC 理解,API 返回带 `Z` 的 UTC 时间,页面再按酒店或用户时区展示。 diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 0bd60b4..338d7bf 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -21,6 +21,8 @@ | --- | --- | | `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` | 0711 P0 前端 / Adapter 路由说明。Parent split / 42 路由部分已被 0712 P0.1 覆盖;前端后续按 40 路由、S10/S99、type-known manual review 和 fail-closed 口径调整页面。 | | `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` | 最新前端任务卡展示 / 编辑白名单和三元组路由表。文件名保留 7 月 10 日,内部基线为 7 月 11 日。 | +| `docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md` | 字段控件、人工复核编辑和只读证据输入资料;当前项目落地口径以 `requirements/M002-task-field-control-contract-v1.md` 为准。 | +| `docs/project/requirements/M002-task-field-control-contract-v1.md` | 任务详情 `fields[]` 控件契约 V1,后续后端先扩展控件元数据,前端再按契约渲染字段和同卡复核输入。 | | `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` | 历史前端字段白名单,已被 0711 P0 冻结基线承接。 | | `docs/import/20260706/任务卡展示编辑矩阵.xlsx` | 后端完整规则来源。用于后端校验、最终确认写入、OPERA 映射、展示条件和任务卡完整约束。 | | `docs/import/20260706/AI输出参数并集字典.xlsx` | AI 输出字段路径、字段含义、建议存储方式和索引参考。 | @@ -40,6 +42,7 @@ | SuperAgent 任务结果入站接口 | `docs/project/requirements/M002-superagent-task-result-api-contract.md` | 阶段记录,用于理解 M002 接收 AI 结果的落地细节;如与总契约冲突,以总契约为准。 | | SuperAgent 查询上下文接口 1、2 | `docs/project/requirements/M002-ai-query-minimal-fields.md` | 阶段记录,用于理解接口 1、2 的最小字段实现;如与总契约冲突,以总契约为准。 | | 订单任务主流程 V3 | `docs/project/requirements/M002-order-task-workflow-v3.md` | 当前开发基线,基于 0711 P0 冻结基线和 0712 P0.1 Parent Group 修订,覆盖 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed。 | +| 任务卡字段控件契约 V1 | `docs/project/requirements/M002-task-field-control-contract-v1.md` | 后端已返回 `fields[]` 控件元数据,规定人工复核控件复用和前后端边界;前端待接入。 | | 订单任务主流程 V2 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 已实现阶段记录,保留用于理解当前代码中的 S000/S999、订单任务流转和 OPERA 模拟骨架。 | | 后端 checkpoint | `docs/project/requirements/M002-backend-checkpoint-plan.md` | 阶段记录,用于理解后端拆分和验收。 | | 前端可用接口与待补接口 | `docs/project/frontend-backend/frontend-to-backend-api-requests.md` | 前后端协作清单,已区分可用、后置和历史候选路径,不替代后端权威契约。 | @@ -53,6 +56,7 @@ - 邮件会话详情已返回 `html_body_sanitized` 和 `html_render_mode`;前端展示 HTML 时优先使用清洗字段,`html_body` 只作为原始内容兼容字段。 - 用户 / 权限底座后端 CP1 已完成;前端登录页、动态菜单、管理后台和业务审计 actor 全量迁移仍后置。 - 真实 OPERA / OHIP 接入后置;当前仅有 OPERA 模拟骨架。 +- 任务卡字段控件契约 V1 后端第一版已完成,任务详情 `fields[]` 已返回 `control_type/edit_scope/write_target/options_source/raw_readonly/control_hint`;前端后续按契约接入,不要硬编码 PMS 房型、Rate Code 或未冻结枚举。 ## 6. 前端开发注意事项 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 6fdb360..b3850bf 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -20,6 +20,8 @@ - `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` 是 0711 P0 前端 / Adapter 路由说明,覆盖 S10/S99、type-known manual review 和 fail-closed 口径;其中 Parent split / 42 路由口径已被 0712 P0.1 覆盖。 - `docs/project/requirements/M002-v3-p0.1-parent-group-routing-update.md` 是当前 Parent Group / Allotment 路由修订说明:前端应按 40 路由口径处理 Parent split。 +- `docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md` 是前端字段控件、人工复核编辑和只读证据的外部输入资料;本项目开发以 `docs/project/requirements/M002-task-field-control-contract-v1.md` 的落地口径为准。 +- `docs/project/requirements/M002-task-field-control-contract-v1.md` 是后端已扩展 `fields[]` 和前端后续控件渲染的字段控件契约 V1。 - `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` 是当前前端展示 / 编辑白名单和三元组路由表。 - `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 是历史前端展示 / 编辑白名单,已被 0711 P0 冻结基线承接。 - `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 是后端校验、最终确认写入、OPERA 映射和展示条件的完整规则来源。 @@ -148,6 +150,7 @@ POST /api/auth/logout - 任务列表、订单任务时间线和任务详情顶层已透出 `result_type`、`ai_task_type`、`route_code`、`system_process_category`。前端展示任务卡标题和标签时优先用这些稳定 code,不要只靠旧 `task_type` 判断。 - P0.1 后,Parent split 父事件不再是独立 Parent Cancel Booking 卡;前端应展示为 `Parent Group / Cancel Allotment / cancel_allotment_control_block`。`route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL` 是普通业务卡,`route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW` 是同卡人工复核业务卡,不应展示成 `adapter_contract_error`。`linked_parent_release_after_child_split` 只作为关系字段或详情信息,不作为任务 subtype 筛选项。 - `manual_review.reason_code=target_object_unclear` 时,前端需要在任务详情展示 `manual_review.visible_reason`、`missing_fields`、`blocking_points`、`conflicting_points`、`suggested_human_actions`、`evidence_to_check`,并展示 `context_used.parent_identity_candidates[]` 辅助确认 Parent Group identity。当前前端已兼容顶层 `context_used.parent_identity_candidates[]` 或 `manual_review.context_used.parent_identity_candidates[]`;若后端 DTO 不透出 candidates,页面会显示候选空态。 +- `Cancel Allotment / cancel_allotment_control_block` 第一版复用旧 `Cancel Booking` 字段矩阵。后端在确认和复核解阻时会派生 `extracted_fields.cancel_object_type=allotment_control_block`,并接受 `extracted_fields.cancel_scope=entire_allotment_control_block`;前端不需要为了这两个 P0.1 系统字段额外阻塞人工复核提交。 - P0.1 的“40 条路由”表示当前合法 route definition 数量;`route_code` 保持历史稳定且不连续重编号,因此 `R41_FALLBACK_BUSINESS_EVENT_REVIEW` 和 `R42_UNHANDLED_CURRENT_INTENT` 仍是合法展示 code。 - `adapter_contract_errors[]` 和 `unhandled_intents[]` 只在任务详情返回,表示同一 SuperAgent 入站批次中没有生成业务任务的诊断块;前端只读展示并提供来源邮件入口,不显示保存、确认、执行或重试按钮。 @@ -173,6 +176,7 @@ 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`。 +- Parent / Allotment 场景中,SuperAgent 可能在 `manual_review.missing_fields[]` 同时返回 `/case_keys/group_code` 和 `/case_keys/block_code`。本系统第一版任务卡只暴露 `case_keys.group_code`,后端复核解阻会把 `/case_keys/block_code` 视为同一业务字段的输入侧别名;前端按 `fields[]` 渲染并提交 `/case_keys/group_code` 即可,不需要额外造 `block_code` 输入框。 - 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` @@ -233,6 +237,24 @@ Content-Type: application/json - 前端保存草稿时不要自行按 `write_path` 重组 OPERA 参数;第一版按任务详情返回的字段和值提交即可,真实 OPERA 参数组装后续由后端 adapter / 转换层处理。 - 任务详情页控制按钮时以 `availability.editable`、`availability.confirmable`、`availability.executable`、`availability.read_only` 和 `availability.blocked` 为准;`can_process` 和 `readonly_reason_code` 只出现在任务列表 / 订单时间线摘要里。 +### 5.7.1 字段控件契约 V1 接入注意 + +后端已按 `docs/project/requirements/M002-task-field-control-contract-v1.md` 返回字段控件契约 V1。前端接入时注意: + +- 任务详情 `fields[]` 已新增 `control_type`、`edit_scope`、`write_target`、`options_source`、`raw_readonly`、`control_hint`。 +- 前端应优先按 `control_type` 渲染字段;旧 `input_editable`、`select_editable`、`date_picker`、`number_input`、`file_display`、`table_editable` 只作为兼容兜底。 +- `raw_readonly=true`、`edit_scope=never/system_only` 或 `write_target=none` 的字段不能展示普通编辑控件。 +- `source_message`、邮件正文、附件引用、raw evidence、`event_type`、`source_event_index`、关系索引、`route_code`、`result_type`、`task_type`、`task_subtype`、`manual_review.reason_code` 等字段必须只读。 +- `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[]` 指向的字段。 +- `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 或表格编辑后端能力仍未接入。 +- `control_type=structured_table` 第一版如未实现编辑控件,可以只读展示或按后端 `edit_scope/options_source` 给出待接入提示;不要把对象数组压成单行自由文本再提交。 +- `control_type=workflow_state` 表示流程状态或动作入口,例如复核解阻状态;不要把它作为普通 `field_values` 保存。 + ### 5.8 Debug EML 上传接口接入注意 后端已提供 Debug 页面专用的 `.eml` 上传和 SuperAgent 调试入口: @@ -255,7 +277,7 @@ run_label: 可选调试标签 - `hotel_id` 第一版可不传;单酒店阶段后端按平台酒店表唯一 `ACTIVE` 酒店解析。只有在调试人员明确要覆盖当前酒店时,前端才传当前选中酒店。 - 接口会解析 `.eml`,上传原始邮件、内联图片和附件到本系统阿里云 OSS,替换 HTML 内 `cid:` 图片,再写入 SourceMessage Inbox。 - SourceMessage 来源 provider 固定为 `DEBUG_EML_UPLOAD`,用于和 AgentBus 入库邮件区分。 -- 当前 AgentBus 实时收到邮件后自动推 SuperAgent 还没有做;这个接口是人工 Debug 上传链路,不代表实时生产链路。 +- AgentBus 实时收到邮件后自动推 SuperAgent 由 M007 单独建设;这个接口是人工 Debug 上传链路,不代表实时生产链路。 - `external_message_id` 是后端生成的 Debug 独立 ID,格式类似 `debug-eml-run-{debugRunId}-{sha256前缀}`;原始邮件 `Message-ID` 保存在 `agentbus_like_payload.source.original_message_id`。 - 邮件会话解析支持 `References`、`In-Reply-To` 和 `Thread-Index`,但 Debug EML 的 `external_message_id` 不使用原始 `Message-ID` 做幂等。 - `agentbus_like_payload.schema_version` 固定为 `debug-eml-upload-v1`,前端可用于调试展示和版本判断。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 222de21..3c83898 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | -| 日期 | 2026-07-12 | -| 状态 | 0712 P0.1 增量确认版;后端已完成 M002 V3 CP1-CP6 第一版,Parent Group / Cancel Allotment 路由按 P0.1 修订 | +| 文档版本 | 0.3 | +| 日期 | 2026-07-13 | +| 状态 | 0712 P0.1 增量确认版;已完成任务卡字段控件契约 V1 后端第一版 | | 适用范围 | SourceMessage 之后的 SuperAgent 输出适配、任务路由、只读通知卡、人工复核同卡解阻、前后端协作边界 | | 主要读者 | 产品、后端、前端、测试、SuperAgent 对接方、后续协作 agent | @@ -34,7 +34,9 @@ V3 以以下资料和决策为输入: | `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/` | P1/P2 未闭合范围,只用于识别暂缓和 fail-closed,不作为生产规则源 | | `docs/import/20260712/开发交付_P0_to_P0.1_增量修订说明_给开发Codex_2026-07-11.md` | Parent Group / Allotment 语义修订、40 路由、当前 producer 禁止旧 Parent Cancel Booking | | `docs/import/20260712/Agent 0711 1743/**` | P0.1 Main Agent prompt、booking-desk-event skill 和 references | +| `docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md` | 前端字段控件、人工复核编辑、只读证据和验收用例输入;本项目落地口径见 `M002-task-field-control-contract-v1.md` | | `docs/project/requirements/M002-v3-p0.1-parent-group-routing-update.md` | 本项目对 P0.1 增量的落地说明和验收清单 | +| `docs/project/requirements/M002-task-field-control-contract-v1.md` | 本项目任务卡字段控件契约 V1,后端已扩展 `fields[]`,前端后续按该契约渲染字段和同卡复核输入 | 本项目确认的产品决策: @@ -323,6 +325,8 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: 入站 `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` / 字段校验错误处理,不自动映射。 +Parent / Allotment 的 `block_code` 是 SuperAgent 输入侧同义 key,本系统订单和任务卡第一版统一落到 `case_keys.group_code`。因此 Parent key 冲突进入 `target_object_unclear` 复核时,即使 `manual_review.missing_fields[]` 同时包含 `/case_keys/group_code` 和 `/case_keys/block_code`,任务详情只要求用户填写 `case_keys.group_code`;后端复核解阻会把 `/case_keys/block_code` 视为 `case_keys.group_code` 的别名进行缺失字段校验。`Cancel Allotment / cancel_allotment_control_block` 第一版复用旧 `Cancel Booking` 字段矩阵,后端确认和复核解阻时会派生 `extracted_fields.cancel_object_type=allotment_control_block`,并允许 P0.1 的 `extracted_fields.cancel_scope=entire_allotment_control_block`。 + ### 9.3 后端第一版接口 ```text @@ -392,6 +396,9 @@ Content-Type: application/json - P1/P2 fail-closed 返回时,前端展示稳定错误和源邮件入口,不让用户误以为可以确认执行。 - Parent split 父事件展示为 `Cancel Allotment / cancel_allotment_control_block`,不再展示独立 Parent Cancel Booking 卡。 - 任务列表筛选不提供 `linked_parent_release_after_child_split`;新开发阶段只展示 `S10/S99` 筛选,不再展示旧 `S000/S999` 筛选项。 +- 任务详情字段控件以 `M002-task-field-control-contract-v1.md` 为准:前端优先消费后端 `fields[]` 中的 `control_type`、`edit_scope`、`write_target`、`options_source`、`raw_readonly`,旧矩阵开关只作为兼容兜底。 +- type-known manual review 的 `missing_fields[]` 不应统一渲染为普通文本输入;前端应按 `field_pointer` 匹配 `fields[]`,复用对应字段控件并提交 `review_resolution.field_overrides[]`。 +- 前端不得自行硬编码 PMS 房型目录、Rate Code 全集、Fix Charge 枚举或 P1/P2 未冻结字段;选项来源未接入时按只读、容错或“目录待接入”处理。 ## 12. 后端实施 checkpoint 建议 @@ -407,6 +414,7 @@ V3 建议拆成以下 checkpoint,避免一次性重构过大: | M002-V3-CP6 | P0 fixtures 回归 | 已完成第一版:引入 0711 P0 fixtures / validator 作为后端适配测试参考,覆盖 main_outcomes、candidate_gate、manual_review_resolution、source_identity_errors、parent_split_two_children、row_multiple_derived、allotment_scope;其中 candidate_gate 是 Main Agent 调 Skill 前契约,后端以 validator 和 fixture reference 固化,不作为任务结果回调直接建任务 | | M002-V3-CP7 | P0.1 Parent Group 路由修订 | 已完成:将 Parent split 父事件从旧 Cancel Booking 迁移为 Cancel Allotment,路由总数 42 → 40,并保留旧 payload 只读兼容 | | M002-V3-CP8 | MCP submit 稳定性 | 已完成 review 修正版:MCP `th_hotel_submit_task_results` 支持 V3 业务根、结构化 S10/S99 和 V2 兼容;V2 item 已暴露完整 schema 并在提交前校验;V3 按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,返回 `mapping_diagnostics` 但不污染业务 JSON;MCP 层只拦 transport 问题,event 业务契约问题继续由业务入站层落 `adapter_contract_error` | +| M002-V3-CP9 | 字段控件契约 V1 | 已完成后端第一版:`fields[]` 返回 `control_type/edit_scope/write_target/options_source/raw_readonly/control_hint`,覆盖 room_items、只读证据、S10/S99 只读卡和 type-known manual review 缺失字段控件;前端后续按契约接入 | ## 13. 明确不做 @@ -419,10 +427,11 @@ V3 P0.1 不做以下事项: - 不自动根据 `unhandled_current_intents[]` 创建业务任务卡。 - 不用 P1/P2 Known Issues 自行发明字段或 schema。 - 不移除历史 `S000/S999` 数据展示。 +- 不让前端绕过后端字段控件契约自行发明 PMS 房型、Rate Code、Fix Charge 或未冻结枚举。 ## 14. 当前代码现状提醒 -截至 M002 V3 CP7 落地后,当前后端已经实现: +截至 M002 V3 CP9 后端第一版落地后,当前后端已经实现: - `S000/S999` 文本结果兼容处理。 - 结构化 `S10/S99` 入站处理,复用 `SOURCE_MESSAGE_ONLY` 只读特殊任务。 @@ -437,6 +446,10 @@ V3 P0.1 不做以下事项: - 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`。 +- 任务详情 `fields[]` 已新增 `control_type`、`edit_scope`、`write_target`、`options_source`、`raw_readonly`、`control_hint`,用于前端字段控件和同卡复核输入渲染。 +- `room_items[0]` 字段第一版已返回 P0 JSON Pointer 和控件契约:房量为 `number`,PMS 房型代码为 `select + active_pms_room_type_catalog`,房型原文 `room_type_raw` 作为 raw 证据只读。 +- type-known manual review 的缺失字段返回 `edit_scope=manual_review_only` 和 `write_target=review_resolution.field_overrides`;只读诊断字段、源邮件通知卡和 route/result/task 字段不返回可编辑控件。 +- `CANCEL_ALLOTMENT` 第一版复用字段矩阵中的 `Cancel Booking 卡 / cancel_allotment_control_block` 规则;Parent / Allotment 复核中 `/case_keys/block_code` 按 `case_keys.group_code` 别名处理,前端只需要提交任务详情 `fields[]` 暴露的 `case_keys.group_code`。后端会派生 `cancel_object_type=allotment_control_block` 并接受 `cancel_scope=entire_allotment_control_block`。 - `source_message.source_message_id` 缺失时按 V3 typed `infrastructure_input_error` 结构响应。 - 0711 P0 fixtures 已纳入后端回归测试参考,并补齐 S10/S99 严格契约、P0 type-known manual review 解阻、candidate_gate reference 和当前扁平字段矩阵兼容;Parent split 相关用例按 0712 P0.1 增量改为 Cancel Allotment。 - `field_contract_version` 历史迁移已收紧:V18 只把没有 `draft_payload_json` 且没有 `confirmed_payload_json` 的 `code-v1` 任务卡标记为 `20260711-p0`;已经存在用户草稿或确认 payload 的历史任务卡保留旧版本,等待重新保存、确认或后续专项 backfill。 diff --git a/docs/project/requirements/M002-task-field-control-contract-v1.md b/docs/project/requirements/M002-task-field-control-contract-v1.md new file mode 100644 index 0000000..bab65e9 --- /dev/null +++ b/docs/project/requirements/M002-task-field-control-contract-v1.md @@ -0,0 +1,248 @@ +# M002 任务卡字段控件契约 V1 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-13 | +| 状态 | 当前有效;后端 CP9 第一版已实现;前端待接入 | +| 适用范围 | 任务详情 `fields[]`、保存草稿、最终确认、type-known manual review 同卡解阻、前端字段控件渲染 | +| 不适用范围 | Prompt / Skill 业务裁决、SuperAgent 输出根结构重设计、真实 OPERA / OHIP、PMS / Rate 配置中心真实接入 | + +## 1. 文档定位 + +本文把 `docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md` 中适合本系统当前阶段的字段控件要求,落为项目内可开发契约。 + +0712 导入文档的方向是正确的:前端不应继续只依赖旧矩阵里的“是否输入 / 是否下拉”列,而应消费后端返回的字段控件契约;同卡人工复核也不应把所有 `missing_fields[]` 都渲染成普通文本输入。 + +但当前项目还没有 PMS 房型目录、Rate 配置中心、真实 lookup 和真实 OPERA 参数 adapter,因此 V1 先做“可执行最小闭环”: + +- 后端在任务详情 `fields[]` 中补齐稳定控件元数据。 +- 前端只消费后端返回的控件元数据,不自行发明字段、枚举或目录。 +- 人工复核使用 `field_pointer` 定位字段,复用同一套控件渲染和校验。 +- 用户修正值写入 `review_resolution.field_overrides[]` 和确认 payload,不回写 `ai_payload_json`。 + +## 2. 输入资料 + +| 来源 | 用途 | +| --- | --- | +| `docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md` | 控件类型、编辑边界、只读证据和验收用例来源 | +| `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` | 0711 P0 路由、S10/S99、type-known manual review 和 fail-closed 规则 | +| `docs/import/20260712/开发交付_P0_to_P0.1_增量修订说明_给开发Codex_2026-07-11.md` | Parent Group / Cancel Allotment P0.1 修订 | +| `docs/project/requirements/M002-order-task-workflow-v3.md` | 当前 M002 业务主流程权威基线 | +| `docs/project/frontend-backend/backend-to-frontend-notes.md` | 前端接入注意事项 | + +## 3. 当前系统现状 + +当前后端已经具备以下基础: + +- `GET /api/reservation/tasks/{taskId}` 返回 `fields[]`。 +- `fields[]` 已包含 `field_path`、`field_pointer`、`legacy_field_path`、`editable`、`input_editable`、`select_editable`、`date_picker`、`number_input`、`file_display`、`table_editable`、`enum_options`、`required_rule`、`validation_rule` 等旧矩阵字段。 +- 0711 P0 房型字段已经第一版迁移到 `room_items[0]` 主路径。 +- type-known manual review 已在同一张业务卡上解阻,保存 `review_status`、`review_resolution.field_overrides[]` 和 `confirmed_payload`。 +- 解阻接口已经支持 `field_pointer`、P0 主 `field_path` 和旧扁平 `field_path`,并校验只能指向当前任务卡可编辑字段。 + +当前后端 CP9 第一版已经补齐: + +- `fields[]` 已显式返回 `control_type`、`edit_scope`、`write_target`、`options_source`、`raw_readonly`、`control_hint`。 +- `room_items[0]` 主路径字段已返回 P0 JSON Pointer 和控件元数据。 +- type-known manual review 的 `manual_review.missing_fields[]` 可匹配到对应 `fields[].field_pointer`,缺失字段返回 `edit_scope=manual_review_only` 和 `write_target=review_resolution.field_overrides`。 +- `S10/S99` 源邮件只读通知卡不返回可编辑字段控件。 + +当前仍有缺口: + +- 前端字段渲染器仍主要通过旧矩阵开关推断控件。 +- 前端人工复核面板仍把缺失字段统一渲染为普通文本输入。 +- PMS 房型目录、Rate 配置中心、lookup 查询和结构化表格编辑还没有真实后端能力。 + +## 4. 后端 `fields[]` V1 控件契约 + +后端已在 `ReservationTaskFieldResult` 中新增以下字段,并在 `GET /api/reservation/tasks/{taskId}` 的 `fields[]` 中返回。 + +| 字段 | 类型 | 中文说明 | +| --- | --- | --- | +| `control_type` | string | 前端应使用的控件类型。 | +| `edit_scope` | string | 字段可编辑范围。 | +| `options_source` | string / null | 选项来源。没有选项或暂不接目录时返回 `null` 或 `none`。 | +| `write_target` | string | 用户修改值写入目标。 | +| `raw_readonly` | boolean | 是否属于原文、证据或 AI 原始值,必须只读保留。 | +| `control_hint` | string / null | 后端给前端的补充渲染提示,第一版可为空。 | + +### 4.1 `control_type` + +V1 支持以下枚举: + +| 值 | 说明 | +| --- | --- | +| `readonly` | 只读文本、证据、路由、状态或审计字段。 | +| `text` | 普通短文本输入。 | +| `textarea` | 较长业务备注输入。 | +| `number` | 数字输入。 | +| `date` | 酒店本地业务日期输入,格式 `YYYY-MM-DD`。 | +| `select` | 后端返回固定枚举时使用的下拉。 | +| `lookup` | 未来查询当前系统对象或目录的组合框;V1 没有真实查询能力时前端可降级为 `text` 或只读提示。 | +| `multiselect` | 多选字段;V1 若无选项来源则只读展示。 | +| `structured_table` | 结构化数组 / 表格字段,例如 `before_after[]`、`room_items[]`、`fix_charge_items[]`、`trace_items[]`。V1 未实现表格编辑时只读展示。 | +| `file` | 文件 / 附件展示。 | +| `workflow_state` | `review_status`、解阻动作等流程状态,不作为普通字段保存。 | + +旧矩阵列到 V1 控件的默认推导规则: + +| 旧矩阵字段 | 推导 | +| --- | --- | +| `file_display=是` | `control_type=file` | +| `table_editable=是` 或 `field_path` 以 `[]` 结尾 | `control_type=structured_table` | +| `date_picker=是` 或字段名以 `_date` 结尾 | `control_type=date` | +| `number_input=是` 或校验规则包含数字 / 正整数 / 非负 | `control_type=number` | +| `select_editable=是` 且 `enum_options` 非空 | `control_type=select` | +| `input_editable=是` 或 `editable=是` | `control_type=text` | +| 其他情况 | `control_type=readonly` | + +如果字段属于备注、说明、取消原因等长文本,后端可返回 `textarea`。如果字段语义是目标对象定位,例如 `case_keys.group_code`、`case_keys.confirmation_number`,后端可返回 `lookup`;在没有真实 lookup 接口前,前端不得自行查询数据库或外部系统。 + +### 4.2 `edit_scope` + +V1 支持以下枚举: + +| 值 | 说明 | +| --- | --- | +| `never` | 永远只读。 | +| `normal_task` | 普通任务草稿 / 最终确认可编辑。 | +| `manual_review_only` | 仅 type-known manual review 解阻时可编辑。 | +| `normal_and_manual_review` | 普通编辑和同卡复核均可编辑。 | +| `workflow_only` | 只能通过专用流程动作改变,例如复核解阻状态。 | +| `system_only` | 只能由后端系统派生或写入。 | + +默认规则: + +- `raw_readonly=true` 时必须是 `never` 或 `system_only`。 +- `result_type=manual_review` 且字段由 `manual_review.missing_fields[]` 指向时,返回 `manual_review_only`。 +- type-known manual review 中其他可编辑业务字段第一版返回 `normal_and_manual_review`,前端仍应优先只渲染 `missing_fields[]` 指向的字段。 +- `source_message`、附件、邮件原文、AI 路由字段、`event_type`、`task_type`、`task_subtype`、`route_code`、`source_event_index`、关系索引、raw evidence 一律 `never`。 + +### 4.3 `write_target` + +V1 支持以下枚举: + +| 值 | 说明 | +| --- | --- | +| `none` | 不写入用户字段值。 | +| `draft_payload.field_values` | 保存草稿和最终确认写入任务 payload。 | +| `review_resolution.field_overrides` | type-known manual review 解阻写入复核覆盖值。 | +| `draft_payload_and_review_resolution` | 普通编辑和复核都可使用同一字段。 | +| `system_state` | 写入系统状态,不走普通字段 payload。 | + +约束: + +- `ai_payload_json` 永远不是 `write_target`。 +- 前端保存草稿和最终确认继续提交 `field_values`;不要按 `write_path` 自己组 OPERA 参数。 +- 前端提交复核解阻时继续提交 `field_overrides[]`;优先带 `field_pointer`,可同时带 `field_path`。 + +### 4.4 `options_source` + +V1 支持以下值: + +| 值 | 说明 | +| --- | --- | +| `static_enum` | 选项来自 `enum_options`。 | +| `active_pms_room_type_catalog` | PMS 房型目录,后续接真实目录接口;V1 不得由前端硬编码全集。 | +| `rate_code_catalog` | Rate Code 配置中心,后续接真实目录接口;V1 不得硬编码旧 Excel 全集。 | +| `system_case_lookup` | 当前系统订单 / 任务 / 对象查询,后续接 lookup 接口。 | +| `none` | 无选项来源。 | +| `pending_contract` | 业务路径或枚举尚未冻结,前端应只读或容错展示。 | + +如果 `control_type=select` 但 `options_source` 不是 `static_enum` 且后端没有返回实际选项,前端不得擅自造选项。可以显示只读值、普通文本兜底,或展示“目录待接入”的状态。 + +当前后端第一版推导口径: + +- `pms_room_type_code` 返回 `options_source=active_pms_room_type_catalog`,同时保留旧矩阵 `enum_options` 作为过渡展示参考;前端不得把它当成真实 PMS 全量目录。 +- `rate_code` 返回 `options_source=rate_code_catalog`,真实 Rate Code 配置中心后置。 +- `case_keys.*` 返回 `options_source=system_case_lookup`,真实 lookup API 后置。 +- 其他普通下拉字段若有 `enum_options`,返回 `options_source=static_enum`。 +- 目录、lookup 和结构化表格待接入时,后端可通过 `control_hint=catalog_backend_pending`、`lookup_backend_pending`、`structured_table_editor_pending` 提醒前端降级。 + +## 5. 只读和禁止编辑规则 + +以下字段或字段族必须只读: + +- `source_message`、`source_message_id`、邮件主题、发件人、接收时间、邮件正文、附件引用。 +- `message_events[].event_type`、`event_role`、`current_or_history`、`source_event_index`。 +- `attachments[]`、`file_references[]`、`context_used`、QBD sheet / row / highlight / raw evidence。 +- `relevant_message_excerpt`、`text_raw`、`room_type_raw`、价格 raw marker。 +- `manual_review.reason_code`、`manual_review.visible_reason`、`blocking_points`、`conflicting_points`、`suggested_human_actions`、`evidence_to_check`。 +- `route_code`、`result_type`、`task_type`、`task_subtype`、`system_process_category`。 +- S10 / S99 / `infrastructure_input_error` / `adapter_contract_error` / `unhandled_current_intent` 的诊断字段。 + +前端不得通过修改字段控件来切换 `event_type`、业务任务类型、任务 subtype 或 Adapter 路由。跨路由转换若未来需要,必须另行设计专用后端接口和审计契约。 + +## 6. Type-known manual review 渲染规则 + +type-known manual review 的页面行为: + +```text +manual_review.missing_fields[] +→ 使用 JSON Pointer 匹配任务详情 fields[].field_pointer +→ 读取对应 field 的 control_type / edit_scope / options_source / validation_rule +→ 渲染字段控件 +→ 提交 review_resolution.field_overrides[] +→ 后端校验并流转 READY +``` + +前端要求: + +- 只渲染能在 `fields[]` 中匹配到、且后端标记可复核编辑的字段。 +- 匹配不到的 pointer 不应临时生成任意输入框,应展示稳定错误或空态,由后端 / 契约修复。 +- `field_overrides[]` 优先提交 `field_pointer`;同时提交 `field_path` 时必须使用 `fields[]` 中返回的主路径。 +- 当前订单归属确认仍按现有 `confirmed_order_id` 提交;V1 只能确认当前任务订单,不开放普通任务任意切换订单。 + +后端要求: + +- `missing_fields[]` 不是 RFC 6901 pointer 或无法映射到可编辑字段时,入站阶段应 fail closed 或解阻阶段返回 `TASK_REVIEW_POINTER_INVALID`。 +- 复核解阻不得调用通用确认接口绕过审计。 +- 解阻成功后写入 `review_resolution`、`confirmed_payload`、审计和 OPERA 模拟操作,不改写 AI 原始 payload。 + +## 7. V1 暂不做 + +- 不接真实 PMS 房型目录接口。 +- 不接真实 Rate Code 配置中心。 +- 不实现通用 lookup API。 +- 不实现所有结构化表格的可编辑 UI。 +- 不做普通任务任意切换订单。 +- 不做真实 OPERA / OHIP 参数映射。 +- 不允许前端根据旧 Excel 自行硬编码完整枚举全集。 + +## 8. 开发顺序 + +### 8.1 文档 checkpoint + +已完成:已落本文档和相关索引。 + +### 8.2 后端 checkpoint + +已完成第一版: + +- 扩展 `ReservationTaskFieldResult`,返回字段控件契约元数据。 +- 基于现有矩阵列推导 `control_type`、`edit_scope`、`write_target`、`options_source`、`raw_readonly`。 +- 更新 `GET /api/reservation/tasks/{taskId}` 测试,覆盖 room_items、只读证据、S10/S99、type-known manual review。 +- 保留 `manual-review-resolutions` 的可编辑 pointer 校验,非法或只读 pointer 仍返回 `TASK_REVIEW_POINTER_INVALID`。 +- 更新前后端沟通文档中的接口字段说明。 + +### 8.3 前端 checkpoint + +前端后续建议: + +- 扩展 `ReservationTaskFieldResult` TypeScript 类型。 +- 更新字段渲染器,优先按 `control_type` 渲染,旧矩阵开关只作为兼容兜底。 +- 人工复核面板复用同一套字段控件,不再统一使用 text input。 +- `options_source=pending_contract` 或目录未接入时 fail closed / 只读 / 明确提示,不硬编码业务目录。 +- 补充字段渲染、复核提交、只读诊断卡、S10/S99 的前端测试。 + +## 9. 验收标准 + +- 任务详情 `fields[]` 能告诉前端“显示什么控件、何时可编辑、写到哪里、选项从哪来”。 +- 前端可以在不理解业务矩阵中文说明的情况下渲染基本字段控件。 +- 同卡人工复核缺失字段按 `field_pointer` 复用字段控件,并提交 `field_overrides[]`。 +- raw evidence、source message、route、关系索引和审计字段不可编辑。 +- 前端不硬编码 PMS 房型目录、Rate Code 全集或 P1/P2 未冻结枚举。 +- 后端仍负责最终校验、审计、payload 写入和状态流转。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java index d7f4a7d..7bbecaa 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java @@ -32,6 +32,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @param operaWriteParticipation 是否参与 OPERA 参数组装 * @param operaParameterMapping OPERA 参数映射说明 * @param notes 备注说明 + * @param controlType 前端控件类型,后端基于矩阵列和字段语义推导 + * @param editScope 字段可编辑范围,区分普通任务、同卡复核和永远只读 + * @param writeTarget 用户修改值写入目标,不允许指向 AI 原始 payload + * @param optionsSource 字段选项来源,静态枚举、目录或 lookup 未接入时用稳定代码提示 + * @param rawReadonly 是否为来源原文、证据、路由或 AI 原始诊断字段,只读保留 + * @param controlHint 给前端的补充控件提示,第一版主要用于目录或表格待接入提示 * @param value 当前回显值 */ public record ReservationTaskFieldResult( @@ -86,6 +92,18 @@ public record ReservationTaskFieldResult( @JsonProperty("opera_parameter_mapping") String operaParameterMapping, String notes, + @JsonProperty("control_type") + String controlType, + @JsonProperty("edit_scope") + String editScope, + @JsonProperty("write_target") + String writeTarget, + @JsonProperty("options_source") + String optionsSource, + @JsonProperty("raw_readonly") + Boolean rawReadonly, + @JsonProperty("control_hint") + String controlHint, Object value ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java index 2c093de..d3b994d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/JsonReservationTaskCardFieldDefinitionProvider.java @@ -24,7 +24,8 @@ public class JsonReservationTaskCardFieldDefinitionProvider implements Reservati Map.entry(ReservationTaskCardType.NEW_BOOKING.name(), "New Booking 卡"), Map.entry(ReservationTaskCardType.UPDATE_BOOKING.name(), "Update Booking 卡"), Map.entry(ReservationTaskCardType.CANCEL_BOOKING.name(), "Cancel Booking 卡"), - Map.entry(ReservationTaskCardType.CANCEL_ALLOTMENT.name(), "Cancel Allotment 卡"), + // 字段矩阵当前未单列 Cancel Allotment 卡,P0.1 的 cancel_allotment_control_block 复用 Cancel Booking 规则。 + Map.entry(ReservationTaskCardType.CANCEL_ALLOTMENT.name(), "Cancel Booking 卡"), Map.entry(ReservationTaskCardType.VOUCHER_RECEIVED.name(), "Voucher Received 卡"), Map.entry(ReservationTaskCardType.PAYMENT_EVIDENCE.name(), "Payment Evidence 卡"), Map.entry(ReservationTaskCardType.ROOMING_LIST.name(), "Rooming List 卡"), diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index 5f4dfd5..36436b0 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -60,6 +60,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.springframework.dao.DuplicateKeyException; @@ -208,9 +209,11 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl List allDefinitions = fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()); Map submittedValues = normalizeSubmittedFieldValues( + task, allDefinitions, request == null ? null : request.fieldValues()); Map draftValues = normalizeStoredFieldValues( + task, allDefinitions, fieldValuesFromPayloadJson(taskCard.draftPayloadJson())); Map mergedDraftValues = mergeFieldValues(draftValues, submittedValues); @@ -256,12 +259,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl List allDefinitions = fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()); Map submittedValues = normalizeSubmittedFieldValues( + task, allDefinitions, request == null ? null : request.fieldValues()); Map draftValues = normalizeStoredFieldValues( + task, allDefinitions, fieldValuesFromPayloadJson(taskCard.draftPayloadJson())); - Map editedValues = mergeFieldValues(draftValues, submittedValues); + Map editedValues = applySystemDerivedFieldDefaults( + task, + aiPayload, + mergeFieldValues(draftValues, submittedValues)); List activeDefinitions = activeDefinitions(task, aiPayload, editedValues); List validationErrors = validateSubmittedFields(activeDefinitions, submittedValues); validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues, @@ -314,16 +322,22 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl Map draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson()); List activeDefinitions = activeDefinitions(task, aiPayload, draftValues); List fieldOverrides = - resolveFieldOverrides(reviewPointerDefinitions(task), request == null ? null : request.fieldOverrides()); + resolveFieldOverrides( + task, + reviewPointerDefinitions(task), + request == null ? null : request.fieldOverrides()); Map submittedValues = fieldOverrideValues(fieldOverrides); - Map editedValues = mergeFieldValues(draftValues, submittedValues); + Map editedValues = applySystemDerivedFieldDefaults( + task, + aiPayload, + mergeFieldValues(draftValues, submittedValues)); activeDefinitions = activeDefinitions(task, aiPayload, editedValues); List validationErrors = validateSubmittedFields(activeDefinitions, submittedValues); validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues, submittedValues.keySet(), false)); validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues, definitionFieldPaths(activeDefinitions), true, true)); - validationErrors.addAll(validateManualReviewMissingFieldsResolved(aiPayload, activeDefinitions, editedValues)); + validationErrors.addAll(validateManualReviewMissingFieldsResolved(task, aiPayload, activeDefinitions, editedValues)); validationErrors.addAll(validateDateRange(activeDefinitions, aiPayload, editedValues)); if (!validationErrors.isEmpty()) { throw validationError(validationErrors); @@ -814,6 +828,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 将复核字段 JSON Pointer 校验并映射到矩阵 field_path,只允许当前任务卡可编辑字段。 */ private List resolveFieldOverrides( + ReservationTaskSnapshot task, List activeDefinitions, List requests) { if (requests == null || requests.isEmpty()) { @@ -828,7 +843,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl String fieldPointer = trimToNull(request == null ? null : request.fieldPointer()); String fieldPath = trimToNull(request == null ? null : request.fieldPath()); ReservationTaskCardFieldDefinition definition = - findEditableDefinitionByOverride(definitionByPath, fieldPointer, fieldPath); + findEditableDefinitionByOverride(task, definitionByPath, fieldPointer, fieldPath); String resultFieldPointer = fieldPointer != null ? fieldPointer : fieldPointerFor(definition.fieldPath()); if (!seenPointers.add(resultFieldPointer) || !seenFieldPaths.add(definition.fieldPath())) { throw reviewPointerDuplicateError(resultFieldPointer, definition.fieldPath()); @@ -861,7 +876,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl .stream() .filter(definition -> isYes(definition.visible())) .filter(definition -> matchesTaskResultType(definition.resultType(), task)) - .filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType())) + .filter(definition -> matchesTaskTypeExpression(definition.taskType(), task)) .filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype())) .toList(); } @@ -870,14 +885,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 按 JSON Pointer 查找可编辑字段定义。 */ private ReservationTaskCardFieldDefinition findEditableDefinitionByPointer( + ReservationTaskSnapshot task, Map definitionByPath, String fieldPointer) { if (fieldPointer == null || !fieldPointer.startsWith("/")) { throw reviewPointerError(fieldPointer); } - for (String fieldPath : fieldPathCandidatesFromPointer(fieldPointer)) { + for (String fieldPath : fieldPathCandidatesFromPointer(task, fieldPointer)) { ReservationTaskCardFieldDefinition definition = definitionByPath.get(fieldPath); - if (definition != null && isYes(definition.editable())) { + if (definition != null && isUserEditableField(definition)) { return definition; } if (definition != null) { @@ -891,16 +907,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 按复核请求定位可编辑字段,支持 JSON Pointer、P0 主 field_path 和旧扁平 field_path。 */ private ReservationTaskCardFieldDefinition findEditableDefinitionByOverride( + ReservationTaskSnapshot task, Map definitionByPath, String fieldPointer, String fieldPath) { ReservationTaskCardFieldDefinition pointerDefinition = null; if (fieldPointer != null) { - pointerDefinition = findEditableDefinitionByPointer(definitionByPath, fieldPointer); + pointerDefinition = findEditableDefinitionByPointer(task, definitionByPath, fieldPointer); } ReservationTaskCardFieldDefinition pathDefinition = null; if (fieldPath != null) { - pathDefinition = findEditableDefinitionByFieldPath(definitionByPath, fieldPath); + pathDefinition = findEditableDefinitionByFieldPath(task, definitionByPath, fieldPath); } if (pointerDefinition != null && pathDefinition != null && !pointerDefinition.fieldPath().equals(pathDefinition.fieldPath())) { @@ -919,11 +936,12 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 按矩阵字段路径查找可编辑字段,先将旧扁平路径归一化到 P0 主路径。 */ private ReservationTaskCardFieldDefinition findEditableDefinitionByFieldPath( + ReservationTaskSnapshot task, Map definitionByPath, String fieldPath) { - String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath); + String canonicalFieldPath = canonicalSubmittedFieldPath(task, definitionByPath, fieldPath); ReservationTaskCardFieldDefinition definition = definitionByPath.get(canonicalFieldPath); - if (definition == null || !isYes(definition.editable())) { + if (definition == null || !isUserEditableField(definition)) { throw reviewPointerError(fieldPath); } return definition; @@ -932,7 +950,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl /** * 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 与旧扁平路径。 */ - private List fieldPathCandidatesFromPointer(String fieldPointer) { + private List fieldPathCandidatesFromPointer(ReservationTaskSnapshot task, String fieldPointer) { String[] rawTokens = fieldPointer.substring(1).split("/", -1); List plainTokens = new ArrayList<>(); List collapsedArrayTokens = new ArrayList<>(); @@ -959,7 +977,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (!collapsed.equals(candidates.get(0))) { candidates.add(collapsed); } - for (String legacyAlias : p0PointerFieldPathAliases(candidates.get(0), collapsed)) { + for (String legacyAlias : p0PointerFieldPathAliases(task, candidates.get(0), collapsed)) { if (!candidates.contains(legacyAlias)) { candidates.add(legacyAlias); } @@ -970,7 +988,14 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl /** * 兼容 P0 room_items[] 和旧扁平字段在复核指针中的双向过渡。 */ - private List p0PointerFieldPathAliases(String plainFieldPath, String collapsedFieldPath) { + private List p0PointerFieldPathAliases( + ReservationTaskSnapshot task, + String plainFieldPath, + String collapsedFieldPath) { + // Parent/Allotment 在本系统内统一用 group_code 落库,block_code 只作为 SuperAgent 输入侧同义 key。 + if (allowBlockCodeAsGroupCodeAlias(task) && "case_keys.block_code".equals(plainFieldPath)) { + return List.of("case_keys.group_code"); + } String legacyAlias = legacyFieldPathFor(plainFieldPath); if (legacyAlias != null) { return List.of(legacyAlias); @@ -1039,6 +1064,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 校验 SuperAgent 明确标记的缺失字段已在复核解阻后有值,避免只靠矩阵必填遗漏业务阻塞点。 */ private List validateManualReviewMissingFieldsResolved( + ReservationTaskSnapshot task, JsonNode aiPayload, List activeDefinitions, Map editedValues) { @@ -1051,7 +1077,8 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl List errors = new ArrayList<>(); for (JsonNode missingFieldNode : missingFieldsNode) { String fieldPointer = trimToNull(missingFieldNode.asText(null)); - ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer); + ReservationTaskCardFieldDefinition definition = + findEditableDefinitionByPointer(task, definitionByPath, fieldPointer); if (isEmptyValue(editedValues.get(definition.fieldPath()))) { errors.add(fieldPointer + ": 复核缺失字段仍未补齐。"); } @@ -1122,6 +1149,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 标准化请求中的 field_values。支持前端传矩阵 field_path、RFC 6901 pointer 或旧扁平字段。 */ private Map normalizeSubmittedFieldValues( + ReservationTaskSnapshot task, List definitions, Map rawFieldValues) { Map normalized = new LinkedHashMap<>(); @@ -1132,7 +1160,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl List errors = new ArrayList<>(); rawFieldValues.forEach((rawFieldPath, value) -> { String fieldPath = rawFieldPath == null ? "" : rawFieldPath.trim(); - String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath); + String canonicalFieldPath = canonicalSubmittedFieldPath(task, definitionByPath, fieldPath); if (normalized.containsKey(canonicalFieldPath)) { errors.add(fieldPath + ": 与其他提交字段指向同一字段 " + canonicalFieldPath + "。"); return; @@ -1149,12 +1177,13 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 标准化已保存草稿里的历史字段 key,避免旧 payload 影响 P0 新矩阵校验。 */ private Map normalizeStoredFieldValues( + ReservationTaskSnapshot task, List definitions, Map storedFieldValues) { Map definitionByPath = definitionByPath(definitions); Map normalized = new LinkedHashMap<>(); storedFieldValues.forEach((fieldPath, value) -> { - String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath); + String canonicalFieldPath = canonicalSubmittedFieldPath(task, definitionByPath, fieldPath); normalized.put(canonicalFieldPath, value); }); return normalized; @@ -1174,6 +1203,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl * 将提交字段 key 转成当前矩阵主 field_path;未知字段保留原值,后续由矩阵校验报错。 */ private String canonicalSubmittedFieldPath( + ReservationTaskSnapshot task, Map definitionByPath, String fieldPathOrPointer) { String normalized = trimToNull(fieldPathOrPointer); @@ -1181,7 +1211,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return ""; } if (normalized.startsWith("/")) { - for (String candidate : fieldPathCandidatesFromPointer(normalized)) { + for (String candidate : fieldPathCandidatesFromPointer(task, normalized)) { if (definitionByPath.containsKey(candidate)) { return candidate; } @@ -1191,6 +1221,10 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (definitionByPath.containsKey(normalized)) { return normalized; } + String businessAlias = businessFieldPathAlias(task, normalized); + if (businessAlias != null && definitionByPath.containsKey(businessAlias)) { + return businessAlias; + } String canonicalAlias = canonicalP0RoomItemFieldPath(normalized); if (canonicalAlias != null && definitionByPath.containsKey(canonicalAlias)) { return canonicalAlias; @@ -1209,6 +1243,21 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return merged; } + /** + * 补齐系统可确定的派生字段。只影响草稿/确认后的业务 payload,不回写 SuperAgent 原始 AI payload。 + */ + private Map applySystemDerivedFieldDefaults( + ReservationTaskSnapshot task, + JsonNode aiPayload, + Map editedValues) { + Map values = new LinkedHashMap<>(editedValues); + if (isCancelAllotmentTask(task) + && isEmptyValue(valueForField(aiPayload, values, "extracted_fields.cancel_object_type"))) { + values.put("extracted_fields.cancel_object_type", "allotment_control_block"); + } + return values; + } + /** * 根据当前任务类型、subtype 和展示条件筛出本次需要参与后端校验的字段。 */ @@ -1220,7 +1269,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl .stream() .filter(definition -> isYes(definition.visible())) .filter(definition -> matchesTaskResultType(definition.resultType(), task)) - .filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType())) + .filter(definition -> matchesTaskTypeExpression(definition.taskType(), task)) .filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype())) .filter(definition -> displayConditionMatches(task, definition, aiPayload, editedValues)) .toList(); @@ -1241,7 +1290,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl errors.add(fieldPath + ": 不属于当前任务卡、task_subtype 或展示条件。"); continue; } - if (!isYes(definition.editable())) { + if (!isUserEditableField(definition)) { errors.add(fieldPath + ": 当前字段为只读,不允许人工修改。"); } } @@ -1292,7 +1341,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (!fieldPathsToValidate.contains(definition.fieldPath()) || isEmptyValue(value)) { continue; } - errors.addAll(validateEnumOptions(definition, value)); + errors.addAll(validateEnumOptions(task, definition, value)); errors.addAll(validateBasicRule(definition, value)); } return errors; @@ -1749,6 +1798,26 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return matchesMatrixExpression(matrixResultType, task.resultType()); } + /** + * P0.1 将 Parent release 路由为 Cancel Allotment,但字段矩阵仍复用 Cancel Booking 行。 + */ + private boolean matchesTaskTypeExpression(String matrixTaskType, ReservationTaskSnapshot task) { + if (ReservationTaskCardType.CANCEL_ALLOTMENT.name().equals(task.taskCardType()) + && matchesMatrixExpression(matrixTaskType, "Cancel Booking")) { + return true; + } + return matchesMatrixExpression(matrixTaskType, task.aiTaskType()); + } + + /** + * 判断是否为 P0.1 Parent / Allotment 取消卡,用于兼容旧 Cancel Booking 字段矩阵。 + */ + private boolean isCancelAllotmentTask(ReservationTaskSnapshot task) { + return task != null + && ReservationTaskCardType.CANCEL_ALLOTMENT.name().equals(task.taskCardType()) + && "cancel_allotment_control_block".equals(task.taskSubtype()); + } + /** * 按当前已知矩阵展示条件判断字段是否参与本次后端校验。 */ @@ -1863,7 +1932,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl /** * 校验字段枚举值。对象型字段和 key=value 复合枚举先不做强校验。 */ - private List validateEnumOptions(ReservationTaskCardFieldDefinition definition, Object value) { + private List validateEnumOptions( + ReservationTaskSnapshot task, + ReservationTaskCardFieldDefinition definition, + Object value) { + if (isCancelAllotmentTask(task) + && "extracted_fields.cancel_scope".equals(definition.fieldPath()) + && "entire_allotment_control_block".equals(String.valueOf(value))) { + return List.of(); + } String enumOptions = trimToNull(definition.enumOptions()); if (enumOptions == null || "-".equals(enumOptions) || enumOptions.contains("=") || value instanceof Map) { return List.of(); @@ -1961,6 +2038,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl */ private boolean isNumberField(ReservationTaskCardFieldDefinition definition) { return isYes(definition.numberInput()) + || containsText(definition.validationRule(), "数字") || containsText(definition.validationRule(), "正整数") || containsText(definition.validationRule(), "非负数"); } @@ -2024,6 +2102,23 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl }; } + /** + * 处理 P0.1 Parent / Allotment 的业务同义字段。普通 Group/FIT 卡不启用该别名。 + */ + private String businessFieldPathAlias(ReservationTaskSnapshot task, String fieldPath) { + if (allowBlockCodeAsGroupCodeAlias(task) && "case_keys.block_code".equals(fieldPath)) { + return "case_keys.group_code"; + } + return null; + } + + /** + * Parent/Allotment 的 block_code 只在 Cancel Allotment 卡上作为 group_code 输入侧别名。 + */ + private boolean allowBlockCodeAsGroupCodeAlias(ReservationTaskSnapshot task) { + return isCancelAllotmentTask(task); + } + /** * P0 room_items[0] 主字段转旧扁平字段路径,用于前端兼容显示。 */ @@ -2164,7 +2259,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()) .stream() - .map(definition -> toFieldResult(definition, aiPayload)) + .map(definition -> toFieldResult(task, definition, aiPayload)) .toList(); } @@ -2195,7 +2290,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl /** * 将字段定义转换为详情字段结果,并从 AI payload 中读取当前值。 */ - private ReservationTaskFieldResult toFieldResult(ReservationTaskCardFieldDefinition definition, JsonNode aiPayload) { + private ReservationTaskFieldResult toFieldResult( + ReservationTaskSnapshot task, + ReservationTaskCardFieldDefinition definition, + JsonNode aiPayload) { + boolean rawReadonly = isRawReadonlyField(definition); + String controlType = resolveControlType(definition, rawReadonly); + String editScope = resolveEditScope(task, definition, aiPayload, rawReadonly, controlType); + String writeTarget = resolveWriteTarget(editScope); + String optionsSource = resolveOptionsSource(definition, controlType); return new ReservationTaskFieldResult( definition.rowNumber(), definition.cardName(), @@ -2224,9 +2327,236 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl definition.operaWriteParticipation(), definition.operaParameterMapping(), definition.notes(), + controlType, + editScope, + writeTarget, + optionsSource, + rawReadonly, + resolveControlHint(controlType, optionsSource, editScope), valueForField(aiPayload, Map.of(), definition.fieldPath())); } + /** + * 按字段矩阵和只读语义推导前端控件类型,前端优先使用该字段而不是自行猜矩阵列。 + */ + private String resolveControlType(ReservationTaskCardFieldDefinition definition, boolean rawReadonly) { + if (isYes(definition.fileDisplay())) { + return "file"; + } + if (isYes(definition.tableEditable()) || safeFieldPath(definition).endsWith("[]")) { + return "structured_table"; + } + if (rawReadonly) { + return "readonly"; + } + if (isDateField(definition)) { + return "date"; + } + if (isNumberField(definition)) { + return "number"; + } + if (isYes(definition.selectEditable()) && hasEnumOptions(definition)) { + return "select"; + } + if (isFieldEditable(definition)) { + return isLongTextField(definition) ? "textarea" : "text"; + } + return "readonly"; + } + + /** + * 按任务结果类型和字段只读边界推导编辑范围;同卡复核只把可编辑字段写入复核解阻结果。 + */ + private String resolveEditScope( + ReservationTaskSnapshot task, + ReservationTaskCardFieldDefinition definition, + JsonNode aiPayload, + boolean rawReadonly, + String controlType) { + if (rawReadonly || !isFieldEditable(definition) || "file".equals(controlType)) { + return "never"; + } + if (isTypeKnownManualReviewTask(task)) { + return isManualReviewMissingField(task, definition, aiPayload) + ? "manual_review_only" + : "normal_and_manual_review"; + } + return "normal_task"; + } + + /** + * 将编辑范围映射为前端提交后实际落点,避免前端把用户修改写回 ai_payload_json。 + */ + private String resolveWriteTarget(String editScope) { + return switch (editScope) { + case "normal_task" -> "draft_payload.field_values"; + case "manual_review_only" -> "review_resolution.field_overrides"; + case "normal_and_manual_review" -> "draft_payload_and_review_resolution"; + case "workflow_only", "system_only" -> "system_state"; + default -> "none"; + }; + } + + /** + * 推导字段选项来源。目录和 lookup 第一版只表达来源,不代表后端已经提供通用查询接口。 + */ + private String resolveOptionsSource(ReservationTaskCardFieldDefinition definition, String controlType) { + String fieldPath = safeFieldPath(definition); + if (fieldPath.endsWith("pms_room_type_code") || fieldPath.contains(".pms_room_type_code")) { + return "active_pms_room_type_catalog"; + } + if (fieldPath.endsWith("rate_code") || fieldPath.contains("rate_code_result.rate_code")) { + return "rate_code_catalog"; + } + if (fieldPath.startsWith("case_keys.")) { + return "system_case_lookup"; + } + if (("select".equals(controlType) || "multiselect".equals(controlType)) && hasEnumOptions(definition)) { + return "static_enum"; + } + if ("structured_table".equals(controlType) && hasEnumOptions(definition)) { + return "pending_contract"; + } + return "none"; + } + + /** + * 给前端补充非强制提示;真实目录、lookup 和通用表格编辑未接入前用于安全降级。 + */ + private String resolveControlHint(String controlType, String optionsSource, String editScope) { + if ("active_pms_room_type_catalog".equals(optionsSource) || "rate_code_catalog".equals(optionsSource)) { + return "catalog_backend_pending"; + } + if ("system_case_lookup".equals(optionsSource)) { + return "lookup_backend_pending"; + } + if ("structured_table".equals(controlType) && !"never".equals(editScope)) { + return "structured_table_editor_pending"; + } + return null; + } + + /** + * 判断字段是否属于原文、证据、路由或 AI 诊断字段,这类字段只能只读保留。 + */ + private boolean isRawReadonlyField(ReservationTaskCardFieldDefinition definition) { + String fieldPath = safeFieldPath(definition); + String lowerPath = fieldPath.toLowerCase(Locale.ROOT); + if (isYes(definition.fileDisplay())) { + return true; + } + if (!isFieldEditable(definition) && safeText(definition.writePath()).startsWith("ai_payload_json.")) { + return true; + } + if (lowerPath.equals("source_message_id") || lowerPath.startsWith("source_message.")) { + return true; + } + if (lowerPath.equals("attachments") || lowerPath.startsWith("attachments") + || lowerPath.startsWith("file_references") || lowerPath.startsWith("context_used")) { + return true; + } + if (lowerPath.equals("relevant_message_excerpt") + || lowerPath.equals("text_raw") + || lowerPath.endsWith("room_type_raw") + || lowerPath.contains("evidence") + || lowerPath.startsWith("manual_review.")) { + return true; + } + return Set.of( + "event_type", + "event_role", + "current_or_history", + "source_event_index", + "related_source_event_index", + "related_source_event_indices", + "relationship_type", + "related_event_type", + "route_code", + "result_type", + "task_type", + "task_subtype", + "system_process_category" + ).contains(lowerPath); + } + + /** + * 判断字段是否可由用户通过当前任务卡修改。 + */ + private boolean isFieldEditable(ReservationTaskCardFieldDefinition definition) { + return isYes(definition.editable()) || isYes(definition.inputEditable()) || isYes(definition.selectEditable()); + } + + /** + * 判断字段是否最终允许用户写入;字段矩阵可编辑列不能覆盖 raw evidence、原文、路由等强只读边界。 + */ + private boolean isUserEditableField(ReservationTaskCardFieldDefinition definition) { + return isFieldEditable(definition) && !isRawReadonlyField(definition); + } + + /** + * 判断同卡人工复核中当前字段是否被 missing_fields 指向。 + */ + private boolean isManualReviewMissingField( + ReservationTaskSnapshot task, + ReservationTaskCardFieldDefinition definition, + JsonNode aiPayload) { + JsonNode missingFieldsNode = aiPayload.path("manual_review").path("missing_fields"); + if (!missingFieldsNode.isArray()) { + return false; + } + String fieldPath = safeFieldPath(definition); + String fieldPointer = fieldPointerFor(fieldPath); + for (JsonNode missingFieldNode : missingFieldsNode) { + String missingPointer = trimToNull(missingFieldNode.asText(null)); + if (missingPointer == null) { + continue; + } + if (missingPointer.equals(fieldPointer) + || (missingPointer.startsWith("/") + && fieldPathCandidatesFromPointer(task, missingPointer).contains(fieldPath))) { + return true; + } + } + return false; + } + + /** + * 判断字段是否有静态枚举选项。 + */ + private boolean hasEnumOptions(ReservationTaskCardFieldDefinition definition) { + String enumOptions = trimToNull(definition.enumOptions()); + return enumOptions != null && !"-".equals(enumOptions); + } + + /** + * 判断字段是否适合长文本控件展示。 + */ + private boolean isLongTextField(ReservationTaskCardFieldDefinition definition) { + String fieldPath = safeFieldPath(definition).toLowerCase(Locale.ROOT); + String displayName = safeText(definition.displayName()); + return fieldPath.contains("note") + || fieldPath.contains("remark") + || fieldPath.contains("reason") + || fieldPath.contains("comment") + || displayName.contains("备注") + || displayName.contains("说明") + || displayName.contains("原因"); + } + + /** + * 安全读取字段路径,避免空字段路径影响控件推导。 + */ + private String safeFieldPath(ReservationTaskCardFieldDefinition definition) { + return safeText(definition == null ? null : definition.fieldPath()); + } + + /** + * 安全读取字符串,空值返回空串。 + */ + private String safeText(String value) { + return value == null ? "" : value.trim(); + } + /** * 任务详情来源消息展示上下文。只包含 SourceMessage Inbox 安全摘要字段。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java index 2d2b777..098215a 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java @@ -96,6 +96,13 @@ class SuperAgentTaskResultP0FixtureRegressionTest { .andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY")) .andReturn(); + String s10TaskId = com.jayway.jsonpath.JsonPath.read( + s10Result.getResponse().getContentAsString(), + "$.items[0].task_id"); + mockMvc.perform(get("/api/reservation/tasks/{taskId}", s10TaskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.fields.length()").value(0)); + assertSourceMessageOnlyTaskCount(s10Result, "S10"); assertSourceMessageOnlyTaskCount(s99Result, "S99"); } @@ -407,6 +414,92 @@ class SuperAgentTaskResultP0FixtureRegressionTest { assertThat(parentReviewTaskCount).isEqualTo(1L); } + @Test + void shouldResolveParentKeyConflictManualReviewWithGroupCodeAlias() throws Exception { + ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); + useParentSplitBusinessKeys(root, "PARENT-2608-RESOLVE", "CHILD-2608-RESOLVE-A", "CHILD-2608-RESOLVE-B"); + convertParentSplitRootToP01(root); + ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2); + ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys"); + parentCaseKeys.putNull("group_code"); + parentCaseKeys.putNull("block_code"); + ObjectNode contextUsed = (ObjectNode) parentEvent.path("context_used"); + ArrayNode candidates = contextUsed.putArray("parent_identity_candidates"); + candidates.addObject() + .put("field", "group_code") + .put("value", "PARENT-2608-RESOLVE-A") + .put("evidence_source", "subject"); + candidates.addObject() + .put("field", "block_code") + .put("value", "PARENT-2608-RESOLVE-B") + .put("evidence_source", "attachment"); + ObjectNode manualReview = parentEvent.putObject("manual_review"); + manualReview.put("reason_code", "target_object_unclear"); + manualReview.put("visible_reason", "Parent Group 的 group_code 与 block_code 原始候选冲突,请人工确认目标。"); + manualReview.put("review_record_type", "business_event_review"); + manualReview.putArray("missing_fields") + .add("/case_keys/group_code") + .add("/case_keys/block_code"); + manualReview.putArray("blocking_points") + .add("Parent Group identity cannot be safely normalized."); + manualReview.putArray("conflicting_points") + .add("PARENT-2608-RESOLVE-A") + .add("PARENT-2608-RESOLVE-B"); + manualReview.putArray("suggested_human_actions") + .add("confirm_parent_group_identity"); + manualReview.putArray("evidence_to_check") + .add("parent_identity_candidates"); + manualReview.putObject("known_fields"); + String externalId = "p0-parent-split-key-conflict-resolve-001"; + captureSourceMessage(externalId); + + MvcResult createResult = mockMvc.perform(signedPost( + withSourceMessageId(root, externalId), + "nonce-p0-parent-key-conflict-resolve-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[2].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[2].order_id"); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.group_code')].field_pointer") + .value(contains("/case_keys/group_code"))); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "reason": "确认 Parent Group 目标。", + "field_overrides": [ + { + "field_pointer": "/case_keys/group_code", + "value": "PARENT-2608-RESOLVED" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_status").value("READY")) + .andExpect(jsonPath("$.review_status").value("RESOLVED")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer") + .value("/case_keys/group_code")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path") + .value("case_keys.group_code")) + .andExpect(jsonPath("$.confirmed_payload.field_values['case_keys.group_code']") + .value("PARENT-2608-RESOLVED")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.cancel_object_type']") + .value("allotment_control_block")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.cancel_scope']") + .value("entire_allotment_control_block")); + } + @Test void shouldFailClosedWhenCurrentProducerUsesLegacyParentCancelBooking() throws Exception { ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); @@ -590,12 +683,67 @@ class SuperAgentTaskResultP0FixtureRegressionTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].field_pointer") .value(contains("/extracted_fields/room_items/0/room_quantity"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].control_type") + .value(contains("number"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].edit_scope") + .value(contains("normal_task"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].write_target") + .value(contains("draft_payload.field_values"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].options_source") + .value(contains("none"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].raw_readonly") + .value(contains(false))) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].field_pointer") .value(contains("/extracted_fields/room_items/0/room_type_raw"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].control_type") + .value(contains("readonly"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].edit_scope") + .value(contains("never"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].raw_readonly") + .value(contains(true))) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer") .value(contains("/extracted_fields/room_items/0/pms_room_type_code"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].control_type") + .value(contains("select"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].options_source") + .value(contains("active_pms_room_type_catalog"))) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].value") - .value(contains("RM2"))); + .value(contains("RM2"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].control_type") + .value(contains("readonly"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].edit_scope") + .value(contains("never"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].write_target") + .value(contains("none"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].raw_readonly") + .value(contains(true))); + + mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.room_items.0.room_type_raw": "SHOULD-NOT-OVERWRITE-RAW", + "extracted_fields.room_items.0.room_quantity": 5 + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.room_items.0.room_type_raw"))); + + mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.room_type": "SHOULD-NOT-OVERWRITE-LEGACY-RAW" + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.room_items.0.room_type_raw"))); mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId) .contentType(MediaType.APPLICATION_JSON) @@ -708,9 +856,59 @@ class SuperAgentTaskResultP0FixtureRegressionTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer") .value(contains("/extracted_fields/room_items/0/pms_room_type_code"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].control_type") + .value(contains("select"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].edit_scope") + .value(contains("manual_review_only"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].write_target") + .value(contains("review_resolution.field_overrides"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].options_source") + .value(contains("active_pms_room_type_catalog"))) + .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].raw_readonly") + .value(contains(false))) .andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].value") .value(contains("SUITE"))); + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/0/room_type_raw", + "value": "SHOULD-NOT-OVERWRITE-RAW" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID")) + .andExpect(jsonPath("$.details[0]") + .value(containsString("/extracted_fields/room_items/0/room_type_raw"))); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/0/pms_room_type_code", + "value": "SU1" + }, + { + "field_pointer": "/extracted_fields/room_items/0/room_type_raw", + "value": "SHOULD-NOT-OVERWRITE-RAW" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID")) + .andExpect(jsonPath("$.details[0]") + .value(containsString("/extracted_fields/room_items/0/room_type_raw"))); + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -758,6 +956,45 @@ class SuperAgentTaskResultP0FixtureRegressionTest { assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\""); } + @Test + void shouldRejectBlockCodeAliasOutsideCancelAllotmentManualReview() throws Exception { + JsonNode manualReview = fixture("manual_review_resolution.json"); + ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy(); + useManualReviewGroupCode(event, "CHILD-SUITE-BLOCK-ALIAS-001"); + String externalId = "p0-manual-review-block-alias-scope-001"; + captureSourceMessage(externalId); + + MvcResult createResult = mockMvc.perform(signedPost( + businessRoot(externalId, event), + "nonce-p0-manual-review-block-alias-scope-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_REVIEW")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].order_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/case_keys/block_code", + "value": "SHOULD-NOT-BECOME-GROUP-CODE" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID")) + .andExpect(jsonPath("$.details[0]").value(containsString("/case_keys/block_code"))); + } + @Test void shouldFailClosedWhenManualReviewMissingFieldPointerDoesNotResolve() throws Exception { ObjectNode event = fixture("manual_review_resolution.json")