收口V4 Trace卡后端契约
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -504,7 +504,7 @@ RESERVATION_ROOMING_LIST_GENERATE
|
||||
- 所有按钮应按后端 `availability.confirmable`、`availability.reviewable`、`availability.ackable` 和前端权限共同控制;不可操作原因优先展示 `readonly_reason_message`,否则按 `readonly_reason_code` 做友好映射。
|
||||
- `fields[].validation_errors` 应展示在对应字段旁边;接口返回 `V4_FIELD_VALIDATION_FAILED` 且 `details[]` 带嵌套路径时,前端应尝试定位到对应 field,定位不到则在当前卡片动作错误区展示。
|
||||
- `options_source=reservation_v4_account_catalog`、`reservation_v4_room_type_catalog`、`reservation_v4_rate_code_catalog` 时,前端应调用对应 lookup API,不再硬编码固定种子;如果 lookup 返回空列表或 `warnings[]`,前端展示非阻塞提示,但提交时仍以后端目录校验为准。
|
||||
- Trace 卡 `trace_items[].department_code` 第一版先固定下拉选项 `FO`、`HSK`、`FO+HSK`,不要调用不存在的 Department lookup API,也不要允许自由文本;正式 Department 目录和后端目录校验后续单独扩展。
|
||||
- Trace 卡第一版使用稳定业务展示模型:普通事项内容字段统一为 `trace_items[].text`,不要使用或提交 `trace_items[].content`;`GENERAL` 的可编辑字段为 `/trace_items/{index}/text` 和 `/trace_items/{index}/department_code`,`EXTRA_BED` 的可编辑字段为 `/trace_items/{index}/target_room_type_code`、`/trace_items/{index}/extra_bed_room_count` 和 `/trace_items/{index}/department_code`。`department_code` 固定下拉 `FO`、`HSK`、`FO+HSK`,后端在对应字段返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]`;`target_room_type_code` 使用 Room Type lookup,`extra_bed_room_count` 为正整数;确认和复核都只提交 `fields[]` 暴露的白名单 pointer,不要提交 `target_order`、邮件正文、附件 URL、raw evidence 或 Agent 原始 payload。
|
||||
- 前端不展示 `ai_payload_json`、附件 URL、raw evidence 或 SuperAgent 原始 payload;V4 任务详情页底部 `SOURCE_MESSAGE_DISPLAY` 可展示当前触发该 order task 的 SourceMessage 正文,但必须通过 `GET /api/source-messages/{sourceMessageId}/conversation` 读取并写原文读取审计,不能要求 `GET /api/reservation/order-tasks/{orderTaskId}` 直接返回正文。Payment 卡如展示付款凭证图片,也必须通过同一会话接口获取受控附件 URL:卡片内显示缩略图,点击打开大图预览;非图片只显示文件列表和下载。HTML 邮件优先渲染 `html_body_sanitized`,缺少原文权限或接口失败时降级为安全摘要和“查看邮件会话”入口。
|
||||
- V4 来源消息卡读取 `attachments`、`uploaded_media`、`file_references` 时,只允许展示附件名称、类型和大小等安全摘要;如果后端 payload 中异常出现 `https://`、`oss://`、`s3://` 等直接 URL 字符串,前端必须替换为“未命名附件”或隐藏,不得把 URL 渲染到普通业务页面。`SOURCE_MESSAGE_DISPLAY` 的邮件正文默认做长度折叠,用户可展开全文。
|
||||
- V4 主流程不调用旧 V2/V3 草稿、旧任务确认、旧同卡复核接口,也不展示 OPERA 模拟操作入口。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 |
|
||||
| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段、V4 总览和 V4 `v4_order_tasks[]` 时间线,前端订单详情总览页已接入 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。 |
|
||||
| `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 当前 Controller 不接收 `hotel_id`;如后续多酒店隔离需要前端显式传酒店上下文,请后端补可选入参或确认按 taskId 全局唯一即可。 |
|
||||
| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 后端展示模型已补齐且前端业务化展示已接入;Rooming List 确认自动 DEF 后端联动已完成;复核态字段白名单第一版已随 `fields[]` 返回;Payment 附件安全摘要已补齐 | 可以;Room Information 前端业务化展示已完成,Rooming List 轻量确认可继续进入前端业务化展示,Payment 预览可基于后端安全摘要继续联动 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡 `department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入。Room Information 已由后端返回 `display_payload.room_information`:New 展示最终值,Update 展示 `current_values`、`proposed_values`、`final_values` 和 `change_summary[]`,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,默认值可来自 Agent `target_order`,但 Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,也不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer;`write_target=confirmed_payload` 是前端请求体语义,不是后端表字段名。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`,并以 `fields[].editable` 渲染当前卡白名单字段,不只渲染 missing/error 字段。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后,如同订单为 Group,后端会自动把已确认 Room Information 快照中的 Group Booking Status 置为 `DEF`,后续刷新任务详情的 `display_payload` 和 `confirmed_payload` 都会显示 DEF,不需要前端自行提交或计算该状态,并可通过订单任务审计看到 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡已返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 |
|
||||
| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 后端展示模型已补齐且前端业务化展示已接入;Rooming List 确认自动 DEF 后端联动已完成;复核态字段白名单第一版已随 `fields[]` 返回;Payment 附件安全摘要已补齐;Trace 后端稳定字段契约已收口 | 可以;Room Information 前端业务化展示已完成,Rooming List 轻量确认可继续进入前端业务化展示,Payment 预览可基于后端安全摘要继续联动,Trace 专属卡可按 `fields[]` 接入 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡普通事项内容字段统一为 `trace_items[].text`,不要使用或提交 `content`;`GENERAL` 可编辑 `/trace_items/{index}/text`、`/trace_items/{index}/department_code`,`EXTRA_BED` 可编辑 `/trace_items/{index}/target_room_type_code`、`/trace_items/{index}/extra_bed_room_count`、`/trace_items/{index}/department_code`;`department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入,对应字段返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]`;`target_room_type_code` 使用 Room Type lookup 且只校验当前酒店目录存在,`extra_bed_room_count` 为正整数。Room Information 已由后端返回 `display_payload.room_information`:New 展示最终值,Update 展示 `current_values`、`proposed_values`、`final_values` 和 `change_summary[]`,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,默认值可来自 Agent `target_order`,但 Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,也不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer;`write_target=confirmed_payload` 是前端请求体语义,不是后端表字段名。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`,并以 `fields[].editable` 渲染当前卡白名单字段,不只渲染 missing/error 字段。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后,如同订单为 Group,后端会自动把已确认 Room Information 快照中的 Group Booking Status 置为 `DEF`,后续刷新任务详情的 `display_payload` 和 `confirmed_payload` 都会显示 DEF,不需要前端自行提交或计算该状态,并可通过订单任务审计看到 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡已返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 |
|
||||
| `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 |
|
||||
| `POST /api/reservation/tasks/{taskId}/confirm` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 |
|
||||
| `GET /api/reservation/tasks/{taskId}/audits` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如审计查询需要酒店上下文隔离,请后端补可选入参。 |
|
||||
|
||||
@@ -462,17 +462,18 @@ Fit 条件字段:
|
||||
| --- | --- | --- | --- |
|
||||
| `trace_items[]` | Trace | 是 | 同一订单一张 Trace 卡,卡内多条事项 |
|
||||
| `item_type` | Trace item | 是 | `GENERAL` 或 `EXTRA_BED` |
|
||||
| `text` | GENERAL | 是,可为 `null` | 普通备注内容 |
|
||||
| `text` | GENERAL | 是 | 普通备注内容;Trace 普通事项正式字段统一使用 `trace_items[].text`,不使用 `content` |
|
||||
| `department_code` | GENERAL / EXTRA_BED | 是 | 第一版固定为 `FO` / `HSK` / `FO+HSK`,分别表示前厅、客房和前厅 + 客房组合 |
|
||||
| `target_room_type_code` | EXTRA_BED | 是,可为 `null` | 加床目标房型 |
|
||||
| `target_room_type_code` | EXTRA_BED | 是 | 加床目标房型;第一版必须存在于当前酒店 Room Type 目录 |
|
||||
| `extra_bed_room_count` | EXTRA_BED | 是 | 加床房间数量 |
|
||||
|
||||
### 14.3 规则
|
||||
|
||||
- Trace 普通事项内容字段统一为 `trace_items[].text`;SuperAgent、后端存储和 API 展示均不使用 `trace_items[].content` 作为正式字段。
|
||||
- EXTRA_BED 不输出自由 `content`;信息系统固定显示 `SET EXTRA BED`。
|
||||
- EXTRA_BED 不输出 `adult_after_extra_bed`;信息系统按当前 / 基础 Adult +1 计算,页面确认前允许用户纠正。
|
||||
- Department code 由 Agent 必传。第一版先固定为 `FO`、`HSK`、`FO+HSK` 三个值,不接受自由文本;正式 Department 目录和 lookup API 后续单独扩展。
|
||||
- 加床目标房型不在当前订单时,Trace 卡不能确认,并提示用户核对目标房型和订单关联。
|
||||
- 加床目标房型第一版只校验是否存在于当前酒店 Room Type 目录;暂不要求必须属于当前订单已有房型。目录不存在时 Trace 卡不能确认,并提示用户核对目标房型。
|
||||
- 只有订单定位本身不可信时,才触发共享查单门槛阻断整个订单上下文。
|
||||
|
||||
## 15. ROOMING_LIST
|
||||
|
||||
@@ -266,7 +266,8 @@ V4 当前不做 OPERA / PMS 执行,但仍需要保留同订单处理顺序,
|
||||
- 除 Basic Information 必须先确认外,第一版不强制业务卡之间逐张顺序确认;业务卡可独立确认,但页面仍按固定顺序展示。
|
||||
- 同类型多张业务卡按 `source_event_index` 排序。
|
||||
- `SOURCE_MESSAGE_DISPLAY` 只读、不阻塞,V4 任务详情页固定放在最下方,用于查看当前触发该订单任务的 SourceMessage 正文和附件摘要。
|
||||
- Trace 卡 `trace_items[].department_code` 第一版固定为 `FO`、`HSK`、`FO+HSK` 三个值;前端可先做固定下拉,后端正式 Department 目录、lookup API 和目录校验后续单独 checkpoint 扩展。
|
||||
- Trace 卡普通事项内容字段统一使用 `trace_items[].text`,不使用 `trace_items[].content` 作为正式字段;`trace_items[].department_code` 第一版固定为 `FO`、`HSK`、`FO+HSK` 三个值,前端做固定下拉,不调用 Department lookup,也不允许自由文本。
|
||||
- Trace `EXTRA_BED` 的 `target_room_type_code` 第一版只校验当前酒店 Room Type 目录存在,暂不要求必须属于当前订单已有房型;当前订单已有房型约束后置。
|
||||
- Rooming List 卡第一版没有可编辑业务字段;页面展示为轻量事项确认卡,用户点击“确认卡片”仅表示已人工处理当前 Rooming List 事项,不代表名单已解析、Excel 已生成或 PMS 已导入。
|
||||
- Room Information 卡下一阶段采用业务展示模型,不再只依赖通用 `fields[]` 扁平渲染;具体规则见“Room Information 卡展示模型”。
|
||||
|
||||
@@ -592,7 +593,7 @@ V4 任务详情页第一版字段白名单:
|
||||
| `ROOM_INFORMATION` / `NEW_BOOKING` | `group_block_name` 或 `fit_name`、`arrival_date`、`departure_date`、`rate_code`、`room_items[].room_type_code`、`room_items[].room_count`、Group 的 `group_booking_status`、无法从 Fit Rate Code 派生时的 `breakfast_included` | `nights`、Group 固定 `breakfast_included=true`、Fit 可由 Rate Code 派生的 `breakfast_included`、Adult、Block ID、Confirmation Number、Agent 原始 `target_order` |
|
||||
| `ROOM_INFORMATION` / `UPDATE_BOOKING` | 修改后的 `arrival_date`、`departure_date`、`room_items[].room_type_code`、`room_items[].room_count`、Group 的 `group_booking_status`、无法从 Fit Rate Code 派生时的 `breakfast_included` | 当前值、本次变化摘要、`nights` 差异、Rate Code、Adult、Block ID、Confirmation Number、Agent 原始 `target_order` |
|
||||
| `ROOM_INFORMATION` / `CANCEL_BOOKING` | 无;用户只确认取消事项 | 本地订单投影当前值、`nights`、`breakfast_included`、Group Booking Status、Rate Code、房型房量、Block ID、Confirmation Number |
|
||||
| `TRACE_RESERVATION_NOTES` | `trace_items[].content`、`trace_items[].department_code` | Department 第一版只允许 `FO`、`HSK`、`FO+HSK`,不允许自由文本 |
|
||||
| `TRACE_RESERVATION_NOTES` | GENERAL:`trace_items[].text`、`trace_items[].department_code`;EXTRA_BED:`trace_items[].target_room_type_code`、`trace_items[].extra_bed_room_count`、`trace_items[].department_code` | `department_code` 第一版只允许 `FO`、`HSK`、`FO+HSK`,字段返回 `fixed_options[]` 三个固定选项,不允许自由文本;`target_room_type_code` 校验当前酒店 Room Type 目录;`extra_bed_room_count` 必须为正整数;不返回或确认 `content`、`target_order`、邮件正文、附件 URL、raw evidence 或 AI 原始 payload |
|
||||
| `ROOMING_LIST` | 无;用户只确认 Rooming List 事项 | 来源邮件正文和附件摘要通过底部 `SOURCE_MESSAGE_DISPLAY` 查看;确认后 Group 自动置为 `DEF` |
|
||||
| `PAYMENT` | 无;用户只确认附件关联事项 | `attachment_ids[]`、`payment_attachments[]` 安全摘要、图片缩略图、文件名、类型、大小、预览 / 下载可用性 |
|
||||
| `SOURCE_MESSAGE_DISPLAY` | 无 | 当前触发该 V4 order task 的 SourceMessage 正文,只读且默认长度折叠,可展开全文 |
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
| `GET /api/reservation/tasks/{taskId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_TASK_READ` + 任务所属酒店访问权 | 只读查询默认不写业务审计 |
|
||||
| `GET /api/reservation/workbench-items` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload;同来源时间下使用 `updated_at` / `created_at` / 数字 ID 稳定排序 |
|
||||
| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload;`card_status` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 |
|
||||
| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;普通业务卡 `display_payload` / `confirmed_payload` 会移除 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information、Room Information 展示模型和普通业务卡不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order` 或跨酒店订单值;Payment 安全摘要只能包含 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`、可选 `external_media_id` / `unavailable_reason_code`;`external_media_id` 只用于前端匹配会话媒体对象,不是 URL;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 |
|
||||
| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;Trace 卡只返回 `trace_items[].text`、`department_code`、`target_room_type_code`、`extra_bed_room_count` 等白名单字段,不返回 `content`;普通业务卡 `display_payload` / `confirmed_payload` 会移除 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information、Room Information 展示模型、Trace 卡和普通业务卡不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order`、`content`、raw evidence 或跨酒店订单值;Payment 安全摘要只能包含 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`、可选 `external_media_id` / `unavailable_reason_code`;`external_media_id` 只用于前端匹配会话媒体对象,不是 URL;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 |
|
||||
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;返回卡片确认、复核解阻和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
|
||||
| `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload 或附件 URL;前端普通通知卡如遇 URL-like 附件字符串必须二次脱敏 |
|
||||
| `GET /api/reservation/source-notifications/{notificationId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权;仅返回 S10/S99 来源通知 ack 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡字段固定选项结果。用于无独立 lookup API 的小范围固定枚举。
|
||||
*
|
||||
* @param value 选项稳定值,前端提交时使用
|
||||
* @param label 选项展示文案
|
||||
*/
|
||||
public record ReservationV4TaskCardFieldOptionResult(
|
||||
@JsonProperty("value")
|
||||
String value,
|
||||
@JsonProperty("label")
|
||||
String label
|
||||
) {
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import java.util.List;
|
||||
* @param editScope 编辑范围,区分普通确认、人工复核和只读
|
||||
* @param writeTarget 用户修改值写入目标
|
||||
* @param optionsSource 选项来源,目录未接口化前使用稳定代码
|
||||
* @param fixedOptions 固定选项列表;仅用于无 lookup API 的小范围枚举
|
||||
* @param rawReadonly 是否来源、派生或诊断字段,只读展示
|
||||
* @param validationErrors 当前字段对应的后端校验错误
|
||||
* @param controlHint 给前端的控件补充提示
|
||||
@@ -39,6 +40,8 @@ public record ReservationV4TaskCardFieldResult(
|
||||
String writeTarget,
|
||||
@JsonProperty("options_source")
|
||||
String optionsSource,
|
||||
@JsonProperty("fixed_options")
|
||||
List<ReservationV4TaskCardFieldOptionResult> fixedOptions,
|
||||
@JsonProperty("raw_readonly")
|
||||
Boolean rawReadonly,
|
||||
@JsonProperty("validation_errors")
|
||||
|
||||
@@ -423,7 +423,7 @@ final class ReservationAiV4CallbackValidator {
|
||||
|| !traceItem.has("department_code")
|
||||
|| !isStringOrNull(traceItem.path("target_room_type_code"))
|
||||
|| !isStringOrNull(traceItem.path("department_code"))
|
||||
|| !isPositiveIntOrNull(traceItem.path("extra_bed_room_count"))) {
|
||||
|| !isNumberOrNull(traceItem.path("extra_bed_room_count"))) {
|
||||
return new ReservationAiV4CallbackIssue("TRACE_EXTRA_BED_ITEM_INVALID", "V4 EXTRA_BED trace item 字段无效。");
|
||||
}
|
||||
continue;
|
||||
@@ -624,6 +624,13 @@ final class ReservationAiV4CallbackValidator {
|
||||
return node != null && (node.isNull() || node.isTextual());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段值是否为数字或 null。Trace 加床数量的正整数业务校验由 V4 卡片校验承接,避免可人工修正值被契约层拦截。
|
||||
*/
|
||||
private boolean isNumberOrNull(JsonNode node) {
|
||||
return node != null && (node.isNull() || node.isNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 JSON 节点是否为布尔 true。
|
||||
*/
|
||||
|
||||
@@ -538,6 +538,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
overlayRoomInformationPayload(orderTask, card, payload, submittedPayload);
|
||||
return;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
overlayTracePayload(payload, submittedPayload);
|
||||
return;
|
||||
}
|
||||
overlayBusinessPayload(card, payload, submittedPayload);
|
||||
}
|
||||
|
||||
@@ -684,6 +688,40 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace 卡只允许前端写入当前正式字段,不接受 content、target_order 或新增结构。
|
||||
*/
|
||||
private void overlayTracePayload(ObjectNode payload, ObjectNode submittedPayload) {
|
||||
JsonNode traceItems = payload.path("trace_items");
|
||||
if (!traceItems.isArray()) {
|
||||
return;
|
||||
}
|
||||
JsonNode submittedTraceItems = submittedPayload.path("trace_items");
|
||||
if (!submittedTraceItems.isArray()) {
|
||||
submittedTraceItems = submittedPayload.path("business_fields").path("trace_items");
|
||||
}
|
||||
if (!submittedTraceItems.isArray()) {
|
||||
return;
|
||||
}
|
||||
int size = Math.min(traceItems.size(), submittedTraceItems.size());
|
||||
for (int index = 0; index < size; index++) {
|
||||
JsonNode currentItem = traceItems.get(index);
|
||||
JsonNode submittedItem = submittedTraceItems.get(index);
|
||||
if (currentItem == null || !currentItem.isObject() || submittedItem == null || !submittedItem.isObject()) {
|
||||
continue;
|
||||
}
|
||||
ObjectNode targetItem = (ObjectNode) currentItem;
|
||||
for (ReservationV4TraceFieldPolicy.TraceEditableField field :
|
||||
ReservationV4TraceFieldPolicy.editableFields(currentItem)) {
|
||||
JsonNode submittedValue = submittedItem.get(field.fieldName());
|
||||
JsonNode normalizedValue = ReservationV4TraceFieldPolicy.normalizedScalarValue(field.fieldName(), submittedValue);
|
||||
if (normalizedValue != null) {
|
||||
targetItem.set(field.fieldName(), normalizedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRoomInformationPathWritable(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String bookingType,
|
||||
@@ -782,12 +820,26 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType()) && "attachment_ids".equals(path.get(0))) {
|
||||
return false;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
return isTraceBusinessPathWritable(path);
|
||||
}
|
||||
if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
|
||||
return false;
|
||||
}
|
||||
return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0));
|
||||
}
|
||||
|
||||
private boolean isTraceBusinessPathWritable(List<String> path) {
|
||||
if (path == null || path.size() != 3 || !"trace_items".equals(path.get(0))) {
|
||||
return false;
|
||||
}
|
||||
String fieldName = path.get(2);
|
||||
return ReservationV4TraceFieldPolicy.FIELD_TEXT.equals(fieldName)
|
||||
|| ReservationV4TraceFieldPolicy.FIELD_DEPARTMENT_CODE.equals(fieldName)
|
||||
|| ReservationV4TraceFieldPolicy.FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName)
|
||||
|| ReservationV4TraceFieldPolicy.FIELD_EXTRA_BED_ROOM_COUNT.equals(fieldName);
|
||||
}
|
||||
|
||||
private void validateAndEnrichConfirmedPayload(String hotelId, ReservationV4TaskCardSnapshot card, ObjectNode payload) {
|
||||
List<String> details = new ArrayList<>();
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
@@ -851,6 +903,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
validateRoomInformationPayload(hotelId, card, payload.path("room_information"), roomInformationFinalValues, details);
|
||||
return;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
JsonNode traceItems = ReservationV4TraceFieldPolicy.traceItems(payload);
|
||||
String fieldPath = payload.path("business_fields").isObject() ? "business_fields.trace_items" : "trace_items";
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
traceItems,
|
||||
fieldPath,
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
return;
|
||||
}
|
||||
JsonNode businessFields = payload.path("business_fields");
|
||||
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
|
||||
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
|
||||
@@ -953,6 +1015,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("room_items".equals(field.getKey())) {
|
||||
validateRoomItems(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("trace_items".equals(field.getKey())) {
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
field.getValue(),
|
||||
childPath,
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
} else {
|
||||
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
|
||||
}
|
||||
@@ -1018,6 +1086,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
|
||||
}
|
||||
|
||||
private boolean isTraceCard(ReservationV4TaskCardSnapshot card) {
|
||||
return card != null && ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType());
|
||||
}
|
||||
|
||||
private String textValue(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) {
|
||||
return null;
|
||||
@@ -1056,6 +1128,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
if (shouldUseStableRoomInformationPayload(card, displayPayload, submittedPayload, null)) {
|
||||
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
return traceConfirmedPayloadBase(card, displayPayload);
|
||||
}
|
||||
return displayPayload;
|
||||
}
|
||||
|
||||
@@ -1070,9 +1145,25 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
if (shouldUseStableRoomInformationPayload(card, displayPayload, null, fieldOverrides)) {
|
||||
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
return traceConfirmedPayloadBase(card, displayPayload);
|
||||
}
|
||||
return displayPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Trace 确认基础快照,对外只保留正式 trace_items 字段。
|
||||
*/
|
||||
private ObjectNode traceConfirmedPayloadBase(ReservationV4TaskCardSnapshot card, ObjectNode displayPayload) {
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
|
||||
payload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type")));
|
||||
payload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
|
||||
objectMapper,
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private boolean shouldUseStableRoomInformationPayload(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
JsonNode displayPayload,
|
||||
@@ -1711,9 +1802,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。");
|
||||
}
|
||||
boolean stableRoomInformationPointer = isStableRoomInformationReviewPointer(confirmedPayload, pointer);
|
||||
boolean tracePointer = isTraceReviewPointer(card, confirmedPayload, segments);
|
||||
JsonNode current = findPointerValue(confirmedPayload, segments);
|
||||
if (current == null || current.isMissingNode()) {
|
||||
if (stableRoomInformationPointer) {
|
||||
if (stableRoomInformationPointer || tracePointer) {
|
||||
return;
|
||||
}
|
||||
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload);
|
||||
@@ -1750,6 +1842,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
ensureRoomInformationReviewPointerWritable(card, confirmedPayload, pointer, segments);
|
||||
return;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
if (!ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload)) {
|
||||
logReviewPointerRejected(card, pointer, "TRACE_POINTER_NOT_WRITABLE", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Trace 卡允许编辑字段内。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ("business_fields".equals(root)) {
|
||||
return;
|
||||
}
|
||||
@@ -1826,6 +1925,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
&& pointer.startsWith("/room_information/final_values/");
|
||||
}
|
||||
|
||||
private boolean isTraceReviewPointer(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload,
|
||||
List<String> segments) {
|
||||
return isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload);
|
||||
}
|
||||
|
||||
private List<String> roomInformationQuerySideEditablePointers(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload) {
|
||||
@@ -1970,6 +2076,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
&& pointer.startsWith("/room_information/final_values/")) {
|
||||
return true;
|
||||
}
|
||||
if (isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer(
|
||||
decodeJsonPointer(pointer),
|
||||
confirmedPayload)) {
|
||||
return true;
|
||||
}
|
||||
Set<String> validationPointers = collectValidationErrorPointers(card.validationErrorsJson());
|
||||
if (validationPointers.contains(pointer)) {
|
||||
return true;
|
||||
|
||||
@@ -39,6 +39,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4Source
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldOptionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult;
|
||||
@@ -88,6 +89,10 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
private static final String EVENT_CANCEL_BOOKING = "CANCEL_BOOKING";
|
||||
private static final String BOOKING_TYPE_GROUP = "GROUP";
|
||||
private static final String BOOKING_TYPE_FIT = "FIT";
|
||||
private static final List<ReservationV4TaskCardFieldOptionResult> TRACE_DEPARTMENT_FIXED_OPTIONS = List.of(
|
||||
new ReservationV4TaskCardFieldOptionResult("FO", "FO"),
|
||||
new ReservationV4TaskCardFieldOptionResult("HSK", "HSK"),
|
||||
new ReservationV4TaskCardFieldOptionResult("FO+HSK", "FO+HSK"));
|
||||
private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE";
|
||||
private static final String WRITE_TARGET_CONFIRMED_PAYLOAD = "confirmed_payload";
|
||||
private static final String WRITE_TARGET_REVIEW_FIELD_OVERRIDES = "review_resolution.field_overrides";
|
||||
@@ -460,6 +465,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) {
|
||||
return paymentDisplayPayload(displayPayload, paymentAttachmentSources);
|
||||
}
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
|
||||
return traceDisplayPayload(card, displayPayload);
|
||||
}
|
||||
if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) {
|
||||
return safeBusinessPayload(displayPayload);
|
||||
}
|
||||
@@ -482,6 +490,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return safeBasicInformationPayload(confirmedPayload);
|
||||
}
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
|
||||
if (!hasText(card.confirmedPayloadJson())) {
|
||||
return NullNode.getInstance();
|
||||
}
|
||||
return traceDisplayPayload(card, confirmedPayload);
|
||||
}
|
||||
return safeBusinessPayload(confirmedPayload);
|
||||
}
|
||||
|
||||
@@ -532,6 +546,22 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Trace 卡安全展示 payload;前端只读取 trace_items[].text 等正式字段。
|
||||
*/
|
||||
private JsonNode traceDisplayPayload(ReservationV4TaskCardSnapshot card, JsonNode displayPayload) {
|
||||
ObjectNode safePayload = objectMapper.createObjectNode();
|
||||
safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
|
||||
safePayload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type")));
|
||||
if (card.sourceEventIndex() != null) {
|
||||
safePayload.put("source_event_index", card.sourceEventIndex());
|
||||
}
|
||||
safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
|
||||
objectMapper,
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
|
||||
return safePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Payment 卡展示 payload,补充付款凭证附件安全摘要,不返回任何 URL 字段。
|
||||
*/
|
||||
@@ -1366,6 +1396,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (isRoomInformationEventCard(card) && displayPayload.path("room_information").isObject()) {
|
||||
return roomInformationFields(card, displayPayload.path("room_information"), validationErrors, availability);
|
||||
}
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
|
||||
return traceFields(card, displayPayload, validationErrors, availability);
|
||||
}
|
||||
return businessFields(card, displayPayload, validationErrors, availability);
|
||||
}
|
||||
|
||||
@@ -2033,6 +2066,77 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Trace 卡专属字段白名单;字段指针固定使用 /trace_items/{index}/...。
|
||||
*/
|
||||
private List<ReservationV4TaskCardFieldResult> traceFields(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
JsonNode displayPayload,
|
||||
JsonNode validationErrors,
|
||||
ReservationV4ActionAvailabilityResult availability) {
|
||||
JsonNode traceItems = displayPayload.path("trace_items");
|
||||
if (!traceItems.isArray()) {
|
||||
traceItems = ReservationV4TraceFieldPolicy.traceItems(displayPayload);
|
||||
}
|
||||
if (!traceItems.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
|
||||
for (int index = 0; index < traceItems.size(); index++) {
|
||||
JsonNode traceItem = traceItems.get(index);
|
||||
for (ReservationV4TraceFieldPolicy.TraceEditableField editableField :
|
||||
ReservationV4TraceFieldPolicy.editableFields(traceItem)) {
|
||||
String fieldPath = "trace_items." + index + "." + editableField.fieldName();
|
||||
String pointer = "/trace_items/" + index + "/" + escapeJsonPointer(editableField.fieldName());
|
||||
JsonNode value = traceItem == null ? NullNode.getInstance() : traceItem.get(editableField.fieldName());
|
||||
if (value == null || value.isMissingNode()) {
|
||||
value = NullNode.getInstance();
|
||||
}
|
||||
List<String> fieldValidationErrors = traceValidationMessages(validationErrors, pointer, fieldPath);
|
||||
boolean editable = traceFieldEditable(availability);
|
||||
fields.add(field(
|
||||
fieldPath,
|
||||
pointer,
|
||||
editableField.displayName(),
|
||||
value,
|
||||
editable,
|
||||
true,
|
||||
editableField.controlType(),
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
|
||||
editableField.optionsSource(),
|
||||
traceFixedOptions(editableField),
|
||||
false,
|
||||
fieldValidationErrors,
|
||||
editableField.controlHint()));
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private List<ReservationV4TaskCardFieldOptionResult> traceFixedOptions(
|
||||
ReservationV4TraceFieldPolicy.TraceEditableField editableField) {
|
||||
if (ReservationV4TraceFieldPolicy.OPTIONS_SOURCE_DEPARTMENT.equals(editableField.optionsSource())) {
|
||||
return TRACE_DEPARTMENT_FIXED_OPTIONS;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private boolean traceFieldEditable(ReservationV4ActionAvailabilityResult availability) {
|
||||
return availability.editable();
|
||||
}
|
||||
|
||||
private List<String> traceValidationMessages(JsonNode validationErrors, String pointer, String fieldPath) {
|
||||
List<String> messages = validationMessages(validationErrors, pointer, fieldPath);
|
||||
if (!messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
return validationMessages(
|
||||
validationErrors,
|
||||
"/business_fields" + pointer,
|
||||
"business_fields." + fieldPath);
|
||||
}
|
||||
|
||||
private void collectBusinessLeafFields(
|
||||
List<ReservationV4TaskCardFieldResult> fields,
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
@@ -2122,6 +2226,38 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
Boolean rawReadonly,
|
||||
List<String> validationErrors,
|
||||
String controlHint) {
|
||||
return field(
|
||||
fieldPath,
|
||||
fieldPointer,
|
||||
displayName,
|
||||
value,
|
||||
editable,
|
||||
required,
|
||||
controlType,
|
||||
editScope,
|
||||
writeTarget,
|
||||
optionsSource,
|
||||
List.of(),
|
||||
rawReadonly,
|
||||
validationErrors,
|
||||
controlHint);
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardFieldResult field(
|
||||
String fieldPath,
|
||||
String fieldPointer,
|
||||
String displayName,
|
||||
JsonNode value,
|
||||
Boolean editable,
|
||||
Boolean required,
|
||||
String controlType,
|
||||
String editScope,
|
||||
String writeTarget,
|
||||
String optionsSource,
|
||||
List<ReservationV4TaskCardFieldOptionResult> fixedOptions,
|
||||
Boolean rawReadonly,
|
||||
List<String> validationErrors,
|
||||
String controlHint) {
|
||||
return new ReservationV4TaskCardFieldResult(
|
||||
fieldPath,
|
||||
fieldPointer,
|
||||
@@ -2133,6 +2269,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
editScope,
|
||||
writeTarget,
|
||||
optionsSource,
|
||||
fixedOptions == null ? List.of() : fixedOptions,
|
||||
rawReadonly,
|
||||
validationErrors == null ? List.of() : validationErrors,
|
||||
controlHint);
|
||||
|
||||
@@ -214,7 +214,9 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
Long orderTaskId,
|
||||
ReservationV4AcceptedEventDraft acceptedEvent,
|
||||
LocalDateTime now) {
|
||||
ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson());
|
||||
ObjectNode displayPayload = normalizedBusinessDisplayPayload(
|
||||
acceptedEvent.eventType(),
|
||||
objectJson(acceptedEvent.displayPayloadJson()));
|
||||
List<String> validationDetails = validateBusinessCardDisplayPayload(sourceMessage.hotelId(), displayPayload);
|
||||
boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty();
|
||||
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
|
||||
@@ -235,6 +237,22 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化业务卡展示快照;Trace 卡只保留正式字段,避免 content 或原始定位字段进入后端存储展示层。
|
||||
*/
|
||||
private ObjectNode normalizedBusinessDisplayPayload(String eventType, ObjectNode displayPayload) {
|
||||
if (!"TRACE_RESERVATION_NOTES".equals(eventType)) {
|
||||
return displayPayload;
|
||||
}
|
||||
ObjectNode safePayload = objectMapper.createObjectNode();
|
||||
safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
|
||||
safePayload.put("event_type", eventType);
|
||||
safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
|
||||
objectMapper,
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
|
||||
return safePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 order_ref 保留 V4 event 原始数组顺序。
|
||||
*/
|
||||
@@ -399,6 +417,14 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
*/
|
||||
private List<String> validateBusinessCardDisplayPayload(String hotelId, ObjectNode displayPayload) {
|
||||
List<String> details = new ArrayList<>();
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(textAt(displayPayload, "card_type"))) {
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload),
|
||||
"trace_items",
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
return details;
|
||||
}
|
||||
JsonNode businessFields = displayPayload.path("business_fields");
|
||||
JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload;
|
||||
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
|
||||
@@ -420,6 +446,12 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("room_items".equals(field.getKey())) {
|
||||
validateRoomItems(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("trace_items".equals(field.getKey())) {
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
field.getValue(),
|
||||
childPath,
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
} else {
|
||||
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.NullNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Reservation V4 Trace 卡字段策略。统一查询展示、确认写入、复核指针和字段校验的白名单。
|
||||
*/
|
||||
final class ReservationV4TraceFieldPolicy {
|
||||
|
||||
static final String OPTIONS_SOURCE_DEPARTMENT = "reservation_v4_trace_department_fixed";
|
||||
static final String OPTIONS_SOURCE_ROOM_TYPE = "reservation_v4_room_type_catalog";
|
||||
static final String ITEM_TYPE_GENERAL = "GENERAL";
|
||||
static final String ITEM_TYPE_EXTRA_BED = "EXTRA_BED";
|
||||
static final String FIELD_TEXT = "text";
|
||||
static final String FIELD_DEPARTMENT_CODE = "department_code";
|
||||
static final String FIELD_TARGET_ROOM_TYPE_CODE = "target_room_type_code";
|
||||
static final String FIELD_EXTRA_BED_ROOM_COUNT = "extra_bed_room_count";
|
||||
private static final String FIELD_ITEM_TYPE = "item_type";
|
||||
private static final Set<String> ALLOWED_DEPARTMENT_CODES = Set.of("FO", "HSK", "FO+HSK");
|
||||
|
||||
private ReservationV4TraceFieldPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前 payload 中提取 Trace 事项数组,兼容历史 business_fields 包裹结构。
|
||||
*/
|
||||
static JsonNode traceItems(JsonNode payload) {
|
||||
if (payload == null || payload.isMissingNode() || payload.isNull()) {
|
||||
return NullNode.getInstance();
|
||||
}
|
||||
JsonNode businessFields = payload.path("business_fields");
|
||||
if (businessFields.isObject()) {
|
||||
return businessFields.path("trace_items");
|
||||
}
|
||||
return payload.path("trace_items");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成安全 Trace 事项数组,只保留当前后端正式字段,不透出 content、target_order 或原始证据。
|
||||
*/
|
||||
static ArrayNode sanitizedTraceItems(ObjectMapper objectMapper, JsonNode traceItems) {
|
||||
ArrayNode sanitizedItems = objectMapper.createArrayNode();
|
||||
if (traceItems == null || !traceItems.isArray()) {
|
||||
return sanitizedItems;
|
||||
}
|
||||
for (JsonNode traceItem : traceItems) {
|
||||
if (traceItem == null || !traceItem.isObject()) {
|
||||
continue;
|
||||
}
|
||||
ObjectNode sanitizedItem = objectMapper.createObjectNode();
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_ITEM_TYPE);
|
||||
String itemType = textAt(traceItem, FIELD_ITEM_TYPE);
|
||||
if (ITEM_TYPE_GENERAL.equals(itemType)) {
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TEXT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
|
||||
} else if (ITEM_TYPE_EXTRA_BED.equals(itemType)) {
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
|
||||
} else {
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TEXT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
|
||||
}
|
||||
sanitizedItems.add(sanitizedItem);
|
||||
}
|
||||
return sanitizedItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回指定 Trace item 当前允许前端编辑的字段清单。
|
||||
*/
|
||||
static List<TraceEditableField> editableFields(JsonNode traceItem) {
|
||||
String itemType = textAt(traceItem, FIELD_ITEM_TYPE);
|
||||
if (ITEM_TYPE_EXTRA_BED.equals(itemType)) {
|
||||
return List.of(
|
||||
new TraceEditableField(FIELD_TARGET_ROOM_TYPE_CODE, "目标房型", "select",
|
||||
OPTIONS_SOURCE_ROOM_TYPE, "Room Type 使用酒店目录 lookup,不能自由输入。"),
|
||||
new TraceEditableField(FIELD_EXTRA_BED_ROOM_COUNT, "加床房间数", "number",
|
||||
null, "加床房间数必须是正整数。"),
|
||||
new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select",
|
||||
OPTIONS_SOURCE_DEPARTMENT, "固定选项:FO / HSK / FO+HSK。"));
|
||||
}
|
||||
return List.of(
|
||||
new TraceEditableField(FIELD_TEXT, "Trace 内容", "textarea",
|
||||
null, "普通 Trace 事项内容,以 trace_items[].text 为准。"),
|
||||
new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select",
|
||||
OPTIONS_SOURCE_DEPARTMENT, "固定选项:FO / HSK / FO+HSK。"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断复核或确认写入 pointer 是否属于 Trace 卡当前正式字段白名单。
|
||||
*/
|
||||
static boolean isWritablePointer(List<String> segments, JsonNode payload) {
|
||||
if (segments == null || segments.size() != 3 || !"trace_items".equals(segments.get(0))) {
|
||||
return false;
|
||||
}
|
||||
Integer index = parseArrayIndex(segments.get(1));
|
||||
if (index == null) {
|
||||
return false;
|
||||
}
|
||||
JsonNode traceItems = traceItems(payload);
|
||||
if (!traceItems.isArray() || index >= traceItems.size()) {
|
||||
return false;
|
||||
}
|
||||
String fieldName = segments.get(2);
|
||||
for (TraceEditableField field : editableFields(traceItems.get(index))) {
|
||||
if (field.fieldName().equals(fieldName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Trace 字段值,字段路径由调用方决定是否带 business_fields 前缀。
|
||||
*/
|
||||
static void validateTraceItems(
|
||||
JsonNode traceItems,
|
||||
String fieldPath,
|
||||
List<String> details,
|
||||
Predicate<String> roomTypeExists) {
|
||||
if (traceItems == null || traceItems.isMissingNode() || traceItems.isNull()) {
|
||||
details.add(fieldPath + ": Trace 事项不能为空。");
|
||||
return;
|
||||
}
|
||||
if (!traceItems.isArray() || traceItems.isEmpty()) {
|
||||
details.add(fieldPath + ": Trace 事项必须是非空数组。");
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < traceItems.size(); index++) {
|
||||
JsonNode item = traceItems.get(index);
|
||||
String itemPath = fieldPath + "." + index;
|
||||
if (item == null || !item.isObject()) {
|
||||
details.add(itemPath + ": Trace 事项必须是对象。");
|
||||
continue;
|
||||
}
|
||||
String itemType = textAt(item, FIELD_ITEM_TYPE);
|
||||
if (!ITEM_TYPE_GENERAL.equals(itemType) && !ITEM_TYPE_EXTRA_BED.equals(itemType)) {
|
||||
details.add(itemPath + ".item_type: Trace item_type 只能为 GENERAL 或 EXTRA_BED。");
|
||||
continue;
|
||||
}
|
||||
validateDepartmentCode(item, itemPath, details);
|
||||
if (ITEM_TYPE_GENERAL.equals(itemType)) {
|
||||
validateRequiredText(item, FIELD_TEXT, itemPath + "." + FIELD_TEXT, "Trace 内容", details);
|
||||
} else {
|
||||
validateTargetRoomTypeCode(item, itemPath, details, roomTypeExists);
|
||||
validateExtraBedRoomCount(item, itemPath, details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Department Code 是否属于第一版固定三值。
|
||||
*/
|
||||
static boolean isAllowedDepartmentCode(String departmentCode) {
|
||||
return departmentCode != null && ALLOWED_DEPARTMENT_CODES.contains(departmentCode);
|
||||
}
|
||||
|
||||
private static void validateDepartmentCode(JsonNode item, String itemPath, List<String> details) {
|
||||
String departmentCode = textAt(item, FIELD_DEPARTMENT_CODE);
|
||||
if (!isAllowedDepartmentCode(departmentCode)) {
|
||||
details.add(itemPath + "." + FIELD_DEPARTMENT_CODE + ": Department Code 只允许 FO、HSK、FO+HSK。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateTargetRoomTypeCode(
|
||||
JsonNode item,
|
||||
String itemPath,
|
||||
List<String> details,
|
||||
Predicate<String> roomTypeExists) {
|
||||
String code = textAt(item, FIELD_TARGET_ROOM_TYPE_CODE);
|
||||
if (code == null) {
|
||||
details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 加床目标房型不能为空。");
|
||||
return;
|
||||
}
|
||||
if (roomTypeExists != null && !roomTypeExists.test(code)) {
|
||||
details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 房型代码不在第一版目录中。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateExtraBedRoomCount(JsonNode item, String itemPath, List<String> details) {
|
||||
JsonNode value = item.get(FIELD_EXTRA_BED_ROOM_COUNT);
|
||||
if (value == null
|
||||
|| value.isNull()
|
||||
|| !value.isIntegralNumber()
|
||||
|| value.bigIntegerValue().compareTo(BigInteger.ZERO) <= 0
|
||||
|| value.bigIntegerValue().compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) {
|
||||
details.add(itemPath + "." + FIELD_EXTRA_BED_ROOM_COUNT + ": 加床房间数必须为正整数。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRequiredText(
|
||||
JsonNode item,
|
||||
String fieldName,
|
||||
String fieldPath,
|
||||
String displayName,
|
||||
List<String> details) {
|
||||
if (textAt(item, fieldName) == null) {
|
||||
details.add(fieldPath + ": " + displayName + "不能为空。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyScalarField(JsonNode source, ObjectNode target, String fieldName) {
|
||||
JsonNode value = source.get(fieldName);
|
||||
JsonNode normalizedValue = normalizedScalarValue(fieldName, value);
|
||||
if (normalizedValue == null) {
|
||||
return;
|
||||
}
|
||||
target.set(fieldName, normalizedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 Trace 稳定代码字段,避免查询、确认和复核因为前后空格产生目录校验或展示差异。
|
||||
*/
|
||||
static JsonNode normalizedScalarValue(String fieldName, JsonNode value) {
|
||||
if (value == null || value.isMissingNode() || value.isContainerNode()) {
|
||||
return null;
|
||||
}
|
||||
if (value.isTextual() && isStableCodeField(fieldName)) {
|
||||
return TextNode.valueOf(value.asText().trim());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean isStableCodeField(String fieldName) {
|
||||
return FIELD_ITEM_TYPE.equals(fieldName)
|
||||
|| FIELD_DEPARTMENT_CODE.equals(fieldName)
|
||||
|| FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName);
|
||||
}
|
||||
|
||||
private static String textAt(JsonNode node, String fieldName) {
|
||||
if (node == null || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode value = node.get(fieldName);
|
||||
if (value == null || value.isNull() || !value.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
String text = value.asText().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private static Integer parseArrayIndex(String segment) {
|
||||
if (segment == null || segment.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (segment.length() > 1 && segment.startsWith("0")) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < segment.length(); i++) {
|
||||
if (!Character.isDigit(segment.charAt(i))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(segment);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace 前端可编辑字段元数据。
|
||||
*
|
||||
* @param fieldName 字段名
|
||||
* @param displayName 中文展示名
|
||||
* @param controlType 前端控件类型
|
||||
* @param optionsSource 选项来源
|
||||
* @param controlHint 控件提示
|
||||
*/
|
||||
record TraceEditableField(
|
||||
String fieldName,
|
||||
String displayName,
|
||||
String controlType,
|
||||
String optionsSource,
|
||||
String controlHint) {
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,11 @@ class ReservationV4CommandControllerTest {
|
||||
"mail-v4-command-confirm-unknown-trace-target-room-001",
|
||||
Instant.parse("2026-07-19T01:13:05Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-UNKNOWN-ROOM-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","text":"Please add one extra bed.","target_room_type_code":"UNKNOWN_ROOM","extra_bed_room_count":1,"department_code":"FO"}]}}
|
||||
""");
|
||||
@@ -572,7 +577,217 @@ class ReservationV4CommandControllerTest {
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("business_fields.trace_items.0.target_room_type_code: 房型代码不在第一版目录中。"));
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.target_room_type_code: 房型代码不在第一版目录中。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmWhenTraceDepartmentCodeInvalid() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-invalid-trace-department-001",
|
||||
Instant.parse("2026-07-19T01:13:06Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-DEPARTMENT-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"GENERAL","text":"Guest requests quiet rooms.","department_code":"SALES"}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountNotPositive() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-invalid-trace-extra-bed-count-001",
|
||||
Instant.parse("2026-07-19T01:13:07Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":0,"department_code":"FO+HSK"}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountDecimal() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-decimal-trace-extra-bed-count-001",
|
||||
Instant.parse("2026-07-19T01:13:07Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-DECIMAL-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1.5,"department_code":"FO+HSK"}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeTraceStableCodesWhenConfirming() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-trace-normalize-codes-001",
|
||||
Instant.parse("2026-07-19T01:13:07Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-NORMALIZE-CODES-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":" TWN ","extra_bed_room_count":1,"department_code":" FO+HSK "}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"trace_items": [
|
||||
{
|
||||
"item_type": "EXTRA_BED",
|
||||
"target_room_type_code": " DBL ",
|
||||
"extra_bed_room_count": 2,
|
||||
"department_code": " FO "
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].target_room_type_code")
|
||||
.value("DBL"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
|
||||
.value("FO"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmTraceCardWithOnlyWhitelistedFields() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-trace-whitelist-001",
|
||||
Instant.parse("2026-07-19T01:13:08Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-WHITELIST-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{
|
||||
"card_type":"TRACE_RESERVATION_NOTES",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE-TARGET"},
|
||||
"business_fields":{
|
||||
"trace_items":[
|
||||
{"item_type":"GENERAL","text":"Original trace","department_code":"FO","content":"SHOULD-NOT-PERSIST-CONTENT"},
|
||||
{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1,"department_code":"FO+HSK","content":"SHOULD-NOT-PERSIST-EXTRA-CONTENT","attachment_url":"https://oss.example.test/trace-confirm-secret.png"}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"target_order": {"locator_value": "SHOULD-NOT-PERSIST-SUBMITTED-TARGET"},
|
||||
"trace_items": [
|
||||
{
|
||||
"item_type": "GENERAL",
|
||||
"text": "Updated trace",
|
||||
"department_code": "HSK",
|
||||
"content": "SHOULD-NOT-PERSIST-SUBMITTED-CONTENT"
|
||||
},
|
||||
{
|
||||
"item_type": "EXTRA_BED",
|
||||
"target_room_type_code": "DBL",
|
||||
"extra_bed_room_count": 2,
|
||||
"department_code": "FO",
|
||||
"content": "SHOULD-NOT-PERSIST-SUBMITTED-EXTRA-CONTENT",
|
||||
"attachment_url": "https://oss.example.test/trace-submitted-secret.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].text")
|
||||
.value("Updated trace"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
|
||||
.value("HSK"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].target_room_type_code")
|
||||
.value("DBL"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].extra_bed_room_count")
|
||||
.value(2))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].department_code")
|
||||
.value("FO"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].attachment_url").doesNotExist())
|
||||
.andExpect(content().string(not(containsString("SHOULD-NOT-PERSIST"))))
|
||||
.andExpect(content().string(not(containsString("https://oss.example.test"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1266,6 +1481,95 @@ class ReservationV4CommandControllerTest {
|
||||
.value("RM2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptEditableTraceReviewFieldPointersReturnedByTaskDetail() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-review-trace-pointer-runtime-001",
|
||||
Instant.parse("2026-07-19T01:22:28Z"),
|
||||
990000000000070126L,
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{
|
||||
"card_type":"TRACE_RESERVATION_NOTES",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE"},
|
||||
"business_fields":{
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"trace_items":[
|
||||
{
|
||||
"item_type":"GENERAL",
|
||||
"text":"Guest requests quiet rooms.",
|
||||
"department_code":"SALES",
|
||||
"content":"SHOULD-NOT-PERSIST-CONTENT"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""",
|
||||
"""
|
||||
[
|
||||
{
|
||||
"field_path": "trace_items.0.department_code",
|
||||
"field_pointer": "/trace_items/0/department_code",
|
||||
"message": "Department Code 只允许 FO、HSK、FO+HSK。",
|
||||
"detail": "trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。"
|
||||
}
|
||||
]
|
||||
""");
|
||||
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());
|
||||
ArrayNode overrides = objectMapper.createArrayNode();
|
||||
for (JsonNode field : detail.path("business_cards").get(0).path("fields")) {
|
||||
if (!field.path("editable").asBoolean(false)
|
||||
|| !"review_resolution.field_overrides".equals(field.path("write_target").asText())) {
|
||||
continue;
|
||||
}
|
||||
String pointer = field.path("field_pointer").asText();
|
||||
ObjectNode override = objectMapper.createObjectNode();
|
||||
override.put("field_pointer", pointer);
|
||||
override.set("value", reviewOverrideValue(pointer));
|
||||
overrides.add(override);
|
||||
}
|
||||
org.assertj.core.api.Assertions.assertThat(overrides)
|
||||
.as("Trace 详情返回的可复核字段必须能直接提交给 review-resolution")
|
||||
.isNotEmpty();
|
||||
org.assertj.core.api.Assertions.assertThat(overrides.toString())
|
||||
.contains("/trace_items/0/text")
|
||||
.contains("/trace_items/0/department_code")
|
||||
.doesNotContain("/business_fields/");
|
||||
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("version", 0);
|
||||
request.put("reason", "回归验证 Trace 详情页可编辑 pointer 与命令侧白名单一致");
|
||||
request.set("field_overrides", overrides);
|
||||
|
||||
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.trace_items[0].text")
|
||||
.value("UPDATED"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
|
||||
.value("FO"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
@@ -2111,6 +2415,12 @@ class ReservationV4CommandControllerTest {
|
||||
if (pointer.endsWith("/room_count")) {
|
||||
return objectMapper.getNodeFactory().numberNode(2);
|
||||
}
|
||||
if (pointer.endsWith("/extra_bed_room_count")) {
|
||||
return objectMapper.getNodeFactory().numberNode(1);
|
||||
}
|
||||
if (pointer.endsWith("/department_code")) {
|
||||
return objectMapper.getNodeFactory().textNode("FO");
|
||||
}
|
||||
if (pointer.endsWith("/group_booking_status")) {
|
||||
return objectMapper.getNodeFactory().textNode("TEN");
|
||||
}
|
||||
|
||||
@@ -789,6 +789,77 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(content().string(not(containsString("att-payment-real-external-001"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnStableTraceFieldsAndSafeDisplayPayload() throws Exception {
|
||||
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType(
|
||||
"mail-v4-query-trace-fields-001",
|
||||
Instant.parse("2026-07-18T02:45:00Z"),
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-TRACE-FIELDS-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
ReservationV4CardStatus.CONFIRMED.name(),
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
"""
|
||||
{
|
||||
"card_type":"TRACE_RESERVATION_NOTES",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"target_order":{"locator_value":"SHOULD-NOT-LEAK-TRACE"},
|
||||
"business_fields":{
|
||||
"order_ref":"ORDER-TRACE",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"trace_items":[
|
||||
{
|
||||
"item_type":"GENERAL",
|
||||
"text":"Guest requests quiet rooms.",
|
||||
"content":"SHOULD-NOT-LEAK-CONTENT",
|
||||
"department_code":"FO"
|
||||
},
|
||||
{
|
||||
"item_type":"EXTRA_BED",
|
||||
"content":"SHOULD-NOT-LEAK-EXTRA-BED-CONTENT",
|
||||
"target_room_type_code":"TWN",
|
||||
"extra_bed_room_count":1,
|
||||
"department_code":"FO+HSK",
|
||||
"raw_evidence":"SHOULD-NOT-LEAK-RAW",
|
||||
"attachment_url":"https://oss.example.test/trace-secret.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""",
|
||||
null);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
|
||||
.param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].text")
|
||||
.value("Guest requests quiet rooms."))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].target_room_type_code")
|
||||
.value("TWN"))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].extra_bed_room_count")
|
||||
.value(1))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.business_fields").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.target_order").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].editable")
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].control_type")
|
||||
.value(contains("textarea")))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/department_code')].options_source")
|
||||
.value(contains("reservation_v4_trace_department_fixed")))
|
||||
.andExpect(content().string(containsString(
|
||||
"\"fixed_options\":[{\"value\":\"FO\",\"label\":\"FO\"},{\"value\":\"HSK\",\"label\":\"HSK\"},{\"value\":\"FO+HSK\",\"label\":\"FO+HSK\"}]")))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/target_room_type_code')].options_source")
|
||||
.value(contains("reservation_v4_room_type_catalog")))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/extra_bed_room_count')].control_type")
|
||||
.value(contains("number")))
|
||||
.andExpect(content().string(not(containsString("SHOULD-NOT-LEAK"))))
|
||||
.andExpect(content().string(not(containsString("https://oss.example.test"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception {
|
||||
Long orderId = 990000000000777002L;
|
||||
|
||||
@@ -1383,10 +1383,64 @@ class SuperAgentTaskResultControllerTest {
|
||||
LIMIT 1
|
||||
""", String.class, source.inboxId());
|
||||
assertThat(traceValidationErrors)
|
||||
.contains("business_fields.trace_items.0.target_room_type_code")
|
||||
.contains("trace_items.0.target_room_type_code")
|
||||
.contains("房型代码不在第一版目录中");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkV4TraceReviewRequiredWhenDepartmentCodeInvalid() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-department-001");
|
||||
String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-department-001")
|
||||
.replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-DEPARTMENT-001")
|
||||
.replace("\"department_code\": \"FO\"", "\"department_code\": \"SALES\"");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-department-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(3));
|
||||
|
||||
String traceValidationErrors = jdbcTemplate.queryForObject("""
|
||||
SELECT validation_errors_json
|
||||
FROM workflow_reservation_v4_task_card
|
||||
WHERE source_message_id = ?
|
||||
AND card_type = 'TRACE_RESERVATION_NOTES'
|
||||
AND card_status = 'REVIEW_REQUIRED'
|
||||
AND review_status = 'PENDING'
|
||||
LIMIT 1
|
||||
""", String.class, source.inboxId());
|
||||
assertThat(traceValidationErrors)
|
||||
.contains("trace_items.0.department_code")
|
||||
.contains("Department Code 只允许 FO、HSK、FO+HSK。");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkV4TraceReviewRequiredWhenExtraBedRoomCountInvalid() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-extra-bed-count-001");
|
||||
String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-extra-bed-count-001")
|
||||
.replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-EXTRA-BED-COUNT-001")
|
||||
.replace("\"item_type\": \"GENERAL\"", "\"item_type\": \"EXTRA_BED\"")
|
||||
.replace("\"text\": \"Guest requests quiet rooms.\"", "\"text\": \"Please add one extra bed.\"")
|
||||
.replace("\"department_code\": \"FO\"", "\"target_room_type_code\": \"TWN\",\n"
|
||||
+ " \"extra_bed_room_count\": 0,\n"
|
||||
+ " \"department_code\": \"FO\"");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-extra-bed-count-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(3));
|
||||
|
||||
String traceValidationErrors = jdbcTemplate.queryForObject("""
|
||||
SELECT validation_errors_json
|
||||
FROM workflow_reservation_v4_task_card
|
||||
WHERE source_message_id = ?
|
||||
AND card_type = 'TRACE_RESERVATION_NOTES'
|
||||
AND card_status = 'REVIEW_REQUIRED'
|
||||
AND review_status = 'PENDING'
|
||||
LIMIT 1
|
||||
""", String.class, source.inboxId());
|
||||
assertThat(traceValidationErrors)
|
||||
.contains("trace_items.0.extra_bed_room_count")
|
||||
.contains("加床房间数必须为正整数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");
|
||||
|
||||
Reference in New Issue
Block a user