diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index ba4ab29..9bee525 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -12,14 +12,21 @@ - `GET /api/source-messages/{id}/original`:受控读取邮件原文、HTML 和媒体 URL,并记录访问审计。 - `GET /api/system/agentbus-probe`:查看 AgentBus WebSocket 连接状态和安全计数器。 - AgentBus WebSocket 入站链路:默认关闭,开启后只把业务 frame 写入 SourceMessage Inbox。 +- SuperAgent 任务结果接收接口:接收一个 `source_message_id` 下的 AI 任务结果,写入 AI 过渡层、订单、任务和任务卡。 +- Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。 +- Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`。 +- Reservation OPERA 模拟骨架:已确认任务固定生成两条模拟操作,支持执行、失败重试、attempt 记录和任务审计列表。 当前不要把以下能力当作已上线: - SourceMessage Replay 到 MessageEvent / Evidence。 -- AI 识别、Case 匹配、Task 创建、Operation、Receipt。 +- 真实 AI 识别服务实现、Case 完整模型、Operation、Receipt。 - 自动 ACK、`task.result` 或客户回复。 - 业务前端页面展示邮件原文。 -- OHIP 或其他业务系统写操作。 +- OHIP / OPERA 或其他业务系统真实写操作。 +- SuperAgent 查询上下文接口。 +- 普通任务切换订单接口。 +- 用户身份、权限和真实审计 actor。 ## 2. 上线前必须确认 @@ -86,6 +93,8 @@ - `server/src/main/resources/db/migration/V3__create_reservation_ai_task_workflow.sql` - `server/src/main/resources/db/migration/V4__harden_reservation_task_queue_and_manual_conversion.sql` +- `server/src/main/resources/db/migration/V5__add_reservation_task_draft_and_confirmation.sql` +- `server/src/main/resources/db/migration/V6__create_reservation_opera_simulation_tables.sql` 上线前确认: @@ -95,6 +104,7 @@ - 表和字段中文注释能正常创建。 - 数据库时间按 UTC 写入,接口层负责返回 ISO 8601。 - 执行 V4 前,如果目标库已有 M002 试运行数据,必须先检查 ACTIVE 订单业务号重复和同订单任务队列序号重复。 +- 执行 V5 / V6 前,如果目标库已有 M002 试运行数据,必须确认任务草稿、确认 payload 和 OPERA 模拟操作表允许从空数据开始补齐;不要手工伪造已确认 payload 或 attempt 历史。 V4 前置检查 SQL: @@ -119,6 +129,7 @@ HAVING COUNT(*) > 1; - ACTIVE 订单业务号重复时,先由业务确认保留哪一条 ACTIVE,其他订单应转为 `LOGIC_DELETED`、`ENDED` 或完成任务迁移后再上线。 - 同订单任务队列序号重复时,先按来源顺序和审计证据重新分配 `execution_order`,确认前置任务关系正确后再上线。 - 不要为了让唯一索引创建成功而随意删除订单、任务或 AI 原始记录。 +- OPERA 模拟操作失败只代表单条模拟操作失败,当前任务不会因此自动进入 `FAILED`;上线验证时不能把失败操作当作真实 OPERA 失败处理。 禁止事项: diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index d687868..bceefd3 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -191,9 +191,11 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity 建议范围: - 任务详情 Response 包含 AI 原始摘要、任务卡字段、可处理状态和阻塞原因。 -- 第一版任务卡字段矩阵写在代码 Provider 中,避免 Controller / Service 直接硬编码。 +- 第一版任务卡字段矩阵由 `reservation-task-card/field-matrix-v20260706.json` 资源和 Provider 提供,避免 Controller 直接硬编码。 - 保存 `draft_payload_json`。 - 确认时生成 `confirmed_payload_json`。 +- `field_values` 第一版使用矩阵 `field_path` 作为 key,不按 `write_path` 生成 OPERA 参数;后续真实 OPERA 接入时必须在 adapter / 转换层重新组装参数。 +- 确认时按 `task_subtype`、展示条件、必填、枚举、日期、数字等矩阵规则做第一版后端校验。 - 审计用户字段修改和确认。 验收标准: @@ -203,6 +205,8 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity - 用户确认后任务进入 `READY`。 - OPERA 参数不得从 `ai_payload_json` 读取。 - 用户修改不覆盖 AI 原始 JSON。 +- 保存草稿接口:`PUT /api/reservation/tasks/{taskId}/draft`。 +- 最终确认接口:`POST /api/reservation/tasks/{taskId}/confirm`。 不做: @@ -253,12 +257,11 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity 建议范围: - 从 `confirmed_payload_json` 生成模拟操作。 -- 每个任务可生成多条操作。 +- 第一版每个已确认任务固定生成两条 OPERA 模拟操作,后续接真实 OPERA 时再按任务类型扩展。 - 每次执行或重试新增 attempt。 -- 失败任务进入 `FAILED`。 +- 模拟操作失败时只将该 `operation` 标记为 `FAILED`,任务不进入 `FAILED`,避免按队列规则误判为已结束并跳过失败 OPERA 操作。 - 全部必要操作成功后任务进入 `COMPLETED`。 -- 从 `business_key_candidates_json` 回填订单业务号。 -- 第一版先在订单表设计 `business_key_source`、`business_key_backfilled_at` 等字段,OPERA 模拟执行模块后续再写入。 +- 第一版只保存模拟请求/响应摘要,不做 New Booking 业务号回填;订单表已预留 `business_key_source`、`business_key_backfilled_at` 等字段,后续真实 OPERA 或明确模拟返回结构后再写入。 验收标准: @@ -267,12 +270,17 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity - 失败操作不能跳过。 - 用户不能强制完成任务。 - 重试不覆盖历史 attempt。 -- New Booking 无业务号时,模拟成功后可回填 Confirmation Number 或 Group Code 含义字段。 +- 执行接口:`POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute`。 +- 重试接口:`POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry`。 +- 审计列表接口:`GET /api/reservation/tasks/{taskId}/audits`。 不做: - 不接真实 OPERA 接口。 - 不写死真实 OPERA 返回字段路径。 +- 不做普通任务切换订单。 +- 不做 SuperAgent 查询上下文接口。 +- 不接用户身份权限,审计 actor 第一版仍使用本地占位。 ## 11. Checkpoint 8:审计、查询和收口 diff --git a/docs/project/requirements/M002-backend-data-model-design.md b/docs/project/requirements/M002-backend-data-model-design.md index b37ac97..8665dcf 100644 --- a/docs/project/requirements/M002-backend-data-model-design.md +++ b/docs/project/requirements/M002-backend-data-model-design.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 0.1 | | 日期 | 2026-07-07 | -| 状态 | 后端数据模型草稿 | +| 状态 | 后端数据模型与阶段实现记录 | | 适用范围 | AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果 | | 主要读者 | 后端、数据库、测试、后续协作 agent | @@ -14,7 +14,7 @@ 本文定义 M002 第一阶段后端数据模型草案,用于支撑 SuperAgent 任务结果入站、订单挂靠、任务卡确认、队列顺序、审计和 OPERA 模拟结果。 -本文不是最终建表 SQL。后续写代码前,应按本项目后端规范补充 Flyway migration、Entity 中文注释、Mapper、Repository、Service 和测试。 +本文不是完整最终模型。当前后端已经按本模型落地第一阶段 Flyway migration、Entity、Mapper、Repository、Service 和测试;后续真实 OPERA、前端页面和 SuperAgent 查询上下文接口仍需继续补充。 ## 2. 设计原则 @@ -46,7 +46,7 @@ | `PENDING_CONFIRM` | 待用户确认订单归属和任务字段 | | `READY` | 已确认,等待执行 OPERA 模拟 | | `EXECUTING` | 正在执行 OPERA 模拟 | -| `FAILED` | OPERA 模拟失败,必须重试或修正后再执行,不能跳过 | +| `FAILED` | 任务级失败结束态;第一版 OPERA 单条模拟操作失败时不直接把任务改为该状态,避免按队列规则误放行 | | `COMPLETED` | 任务已完成 | 说明: @@ -314,19 +314,21 @@ | `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | | `order_id` | `BIGINT` | 订单 ID | | `task_id` | `BIGINT` | 任务 ID | -| `operation_type` | `VARCHAR(128)` | 操作类型,例如创建预订、更新字段、取消订单 | -| `operation_status` | `VARCHAR(32)` | 操作状态:`PENDING`、`EXECUTING`、`SUCCESS`、`FAILED` | -| `operation_order` | `INT` | 同任务下操作顺序 | -| `confirmed_payload_json` | `LONGTEXT` | 生成该操作时使用的确认 payload 快照 | -| `business_key_candidates_json` | `LONGTEXT` | 从模拟结果解析出的订单业务号候选 | -| `last_failure_reason` | `VARCHAR(512)` | 最近失败原因 | -| `retry_count` | `INT` | 已重试次数 | +| `operation_sequence` | `INT` | 同任务下操作顺序,从 1 开始 | +| `operation_code` | `VARCHAR(64)` | 模拟操作代码,第一版固定 `SIMULATE_PRECHECK` 和 `SIMULATE_WRITE` | +| `operation_name` | `VARCHAR(128)` | 模拟操作展示名称 | +| `operation_status` | `VARCHAR(32)` | 操作状态:`PENDING`、`SUCCEEDED`、`FAILED` | +| `request_payload_json` | `LONGTEXT` | 生成该操作时使用的模拟请求摘要,不保存完整邮件原文 | +| `attempt_count` | `INT` | 已执行 attempt 次数 | +| `last_attempt_id` | `BIGINT` | 最近一次 attempt ID | +| `last_error_message` | `VARCHAR(512)` | 最近一次失败原因摘要 | | `created_at` / `updated_at` | `DATETIME(6)` | 创建和更新时间 | 索引建议: -- 普通索引:`hotel_id + task_id + operation_order` -- 普通索引:`hotel_id + operation_status + updated_at` +- 唯一索引:`hotel_id + task_id + operation_sequence` +- 普通索引:`hotel_id + task_id + operation_status` +- 普通索引:`hotel_id + order_id + operation_sequence` ## 12. OPERA 模拟尝试记录表 @@ -338,35 +340,36 @@ | --- | --- | --- | | `id` | `BIGINT` | 尝试记录 ID | | `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | -| `operation_id` | `BIGINT` | 所属逻辑操作 | +| `order_id` | `BIGINT` | 订单 ID | | `task_id` | `BIGINT` | 所属任务 | -| `attempt_no` | `INT` | 第几次尝试,从 1 开始 | -| `attempt_status` | `VARCHAR(32)` | 尝试状态:`SUCCESS`、`FAILED` | +| `operation_id` | `BIGINT` | 所属逻辑操作 | +| `attempt_number` | `INT` | 第几次尝试,从 1 开始 | +| `attempt_status` | `VARCHAR(32)` | 尝试状态:`SUCCEEDED`、`FAILED` | | `request_payload_json` | `LONGTEXT` | 本次模拟请求 payload | | `response_payload_json` | `LONGTEXT` | 本次模拟响应 payload | -| `business_key_candidates_json` | `LONGTEXT` | 本次响应中的业务号候选 | -| `failure_reason` | `VARCHAR(512)` | 失败原因 | +| `error_message` | `VARCHAR(512)` | 失败原因摘要 | | `started_at` | `DATETIME(6)` | 开始时间 | | `finished_at` | `DATETIME(6)` | 结束时间 | | `created_at` | `DATETIME(6)` | 记录创建时间 | 索引建议: -- 唯一索引:`hotel_id + operation_id + attempt_no` +- 唯一索引:`hotel_id + operation_id + attempt_number` - 普通索引:`hotel_id + task_id + created_at` +- 普通索引:`hotel_id + operation_id + attempt_number` 规则: - 用户不能跳过失败的 OPERA 模拟操作。 -- 失败后任务进入 `FAILED`,只能通过修正 payload 后重试或重新执行使其成功。 +- 失败后操作进入 `FAILED`,任务保持未完成;因为队列规则里任务 `FAILED` 视为已结束,第一版不能把 OPERA 操作失败直接落成任务 `FAILED`,避免后续任务被错误放行。 - 不允许用户强制把失败任务改成 `COMPLETED`。 -- 后续真实 OPERA 接入时,应通过 adapter 把真实响应映射到 `response_payload_json` 和 `business_key_candidates_json`。 +- 后续真实 OPERA 接入时,应通过 adapter 把真实响应映射到 `response_payload_json`,业务号候选字段需在拿到真实结构后再补稳定字段或 JSON 结构。 ## 13. 订单业务号回填 -只有 `NEW_BOOKING` 且 `confirmed_payload_json` 没有可用业务号时,才从 OPERA 模拟成功结果回填订单业务号。 +只有 `NEW_BOOKING` 且 `confirmed_payload_json` 没有可用业务号时,才需要从 OPERA 成功结果回填订单业务号。 -回填来源暂定为 `business_key_candidates_json`,示例: +当前第一版后端不做业务号回填,也没有落地 `business_key_candidates_json` 字段。后续拿到真实 OPERA 返回结构后,再通过低耦合 adapter 解析候选业务号并补充稳定字段或 JSON 结构。候选结构可以参考: ```json { diff --git a/docs/project/requirements/M002-order-task-workflow-v2.md b/docs/project/requirements/M002-order-task-workflow-v2.md index 37f3218..bc6b551 100644 --- a/docs/project/requirements/M002-order-task-workflow-v2.md +++ b/docs/project/requirements/M002-order-task-workflow-v2.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 0.2 | | 日期 | 2026-07-07 | -| 状态 | 第二版需求草稿 | +| 状态 | 第二版需求与后端阶段实现记录 | | 适用范围 | SourceMessage 之后的 AI 过渡层、订单挂靠、任务卡、人工确认、OPERA 模拟操作主流程 | | 主要读者 | 产品、后端、前端、测试、后续协作 agent | @@ -14,7 +14,7 @@ 本文是 `M002-order-task-workflow-v1.md` 的第二版修正,目标是把本项目已经讨论确认的订单任务主流程,与 2026-07-06 导入的 AI 任务卡契约对齐。 -本文只定义业务边界、数据语义和后续实现约束,不代表已经开始写代码。后续开发前仍需要拆分 checkpoint,并补充接口契约、表结构、状态机、前端页面和测试验收标准。 +本文定义业务边界、数据语义和后续实现约束。当前后端已经按拆分 checkpoint 实现了 AI 结果接收、订单任务基础流转、任务草稿保存、最终确认、审计列表和 OPERA 模拟骨架;前端页面、真实 OPERA、普通任务切换订单和 SuperAgent 查询上下文接口仍未实现。 ## 2. 本版核心修正 @@ -427,6 +427,15 @@ Fallback 处理规则: 一个任务可能产生多条 OPERA 模拟操作。任务详情页应展示每条模拟操作的结果、状态、失败原因、重试次数和最近执行时间。重试必须保留历史记录,不能覆盖原始失败记录。 +第一版后端实现中,任务最终确认后固定生成两条 OPERA 模拟操作: + +| 顺序 | 操作代码 | 中文说明 | +| --- | --- | --- | +| 1 | `SIMULATE_PRECHECK` | OPERA 模拟预检查 | +| 2 | `SIMULATE_WRITE` | OPERA 模拟写入 | + +当前模拟操作只保存请求/响应摘要和 attempt 记录,不接真实 OPERA,也不从模拟结果回填订单业务号。失败时只把对应操作标记为 `FAILED`,任务保持未完成,避免用户绕过失败操作。 + ## 16. 审计要求 以下行为必须记录审计: @@ -462,28 +471,26 @@ Fallback 处理规则: 本版暂不定义: -- SuperAgent 调本系统的正式 HTTP 接口契约。 -- 幂等键到底由 SuperAgent 生成还是本系统生成。 -- 订单完整状态机。 -- 任务完整状态机。 +- SuperAgent 查询上下文接口。 - OPERA 模拟结果 JSON 字段名。 - 真实 OHIP / OPERA 接口地址、鉴权和返回结构。 - 前端具体页面布局和交互细节。 - 全量 158 条任务卡字段配置复制版。 - Rate Code 和房型规则的代码实现。 +- 普通任务切换订单接口和前端交互。 +- 用户身份、权限和真实 actor 注入。 ## 18. 待确认问题 - SuperAgent 创建任务接口的 URL、Method、Header、鉴权和错误响应格式。 - SuperAgent 查询上下文接口暂不在本 checkpoint 实现,但 SuperAgent 侧已经在整理,后续梳理未完成事项时必须持续提醒。 - `source_event_index`、批次 item index、`execution_order` 的最终编号规则是否都从 1 开始。 -- `idempotency_key` 的来源和冲突处理策略。 -- 订单状态机有哪些稳定状态。 -- 任务状态机有哪些稳定状态。 +- SuperAgent 查询上下文接口的 URL、入参、返回字段和鉴权方式。 - `Message Notification` 是否需要在前端订单列表上单独标识为只读提醒。 - OPERA 模拟结果中 Confirmation No.、Group Code、Block Code、Allotment Code 的具体字段路径。 - 临时订单在无任务后是否立即逻辑删除,还是保留一段时间便于追溯。 -- 用户是否允许强制完成任务;如果允许,需要什么权限和审计原因。 +- 普通任务切换订单接口何时纳入实现。 +- 用户身份和权限体系何时接入,审计 `actor` 如何从登录态获取。 ## 19. 后续建议 checkpoint @@ -494,7 +501,7 @@ Fallback 处理规则: 3. 定义系统主任务类型、任务卡类型、`result_type`、任务状态和订单状态枚举。 4. 实现 AI 结果接收、幂等、顺序保存和任务卡创建。 5. 实现订单自动挂靠、临时订单创建和人工订单切换。 -6. 实现任务详情字段展示、编辑、确认和 `confirmed_payload_json`。 -7. 实现任务队列阻塞规则和 `Message Notification` 只读归档规则。 -8. 实现 OPERA 模拟操作结果底表、重试和订单业务号回填。 +6. 实现任务详情字段展示、编辑、确认和 `confirmed_payload_json`。当前后端已实现保存草稿和最终确认接口,前端页面待定。 +7. 实现任务队列阻塞规则和 `Message Notification` 只读归档规则。当前后端已实现阻塞规则和只读提醒归档的基础能力,前端页面待定。 +8. 实现 OPERA 模拟操作结果底表、重试和订单业务号回填。当前后端已实现固定两条模拟操作、attempt、执行、重试和审计列表;订单业务号回填待真实 OPERA 返回结构确认后再做。 9. 根据前端页面范围实现列表、详情和只读/可处理状态展示。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAuditLogSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAuditLogSnapshot.java new file mode 100644 index 0000000..ffa32fe --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAuditLogSnapshot.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * 审计日志快照。用于后端审计列表接口,不暴露数据库 Entity。 + */ +public record ReservationAuditLogSnapshot( + Long id, + String hotelId, + Long orderId, + Long taskId, + Long operationId, + String actorType, + String actorId, + String action, + String reason, + String beforeSnapshotJson, + String afterSnapshotJson, + LocalDateTime occurredAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationAttemptDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationAttemptDraft.java new file mode 100644 index 0000000..58e2ff4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationAttemptDraft.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * OPERA 模拟操作 attempt 入库草稿。每次执行或重试生成一条。 + */ +public record ReservationOperaOperationAttemptDraft( + String hotelId, + Long orderId, + Long taskId, + Long operationId, + Integer attemptNumber, + String attemptStatus, + String requestPayloadJson, + String responsePayloadJson, + String errorMessage, + LocalDateTime startedAt, + LocalDateTime finishedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationAttemptSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationAttemptSnapshot.java new file mode 100644 index 0000000..38d1da7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationAttemptSnapshot.java @@ -0,0 +1,22 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * OPERA 模拟操作 attempt 快照。用于执行结果和重试历史展示。 + */ +public record ReservationOperaOperationAttemptSnapshot( + Long id, + String hotelId, + Long orderId, + Long taskId, + Long operationId, + Integer attemptNumber, + String attemptStatus, + String requestPayloadJson, + String responsePayloadJson, + String errorMessage, + LocalDateTime startedAt, + LocalDateTime finishedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationDraft.java new file mode 100644 index 0000000..d9cc01c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationDraft.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * OPERA 模拟操作入库草稿。任务确认后由系统固定生成两条。 + */ +public record ReservationOperaOperationDraft( + String hotelId, + Long orderId, + Long taskId, + Integer operationSequence, + String operationCode, + String operationName, + String operationStatus, + String requestPayloadJson, + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationSnapshot.java new file mode 100644 index 0000000..03a7d4a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationOperaOperationSnapshot.java @@ -0,0 +1,24 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * OPERA 模拟操作快照。Service 使用该对象展示操作状态,不暴露 Entity。 + */ +public record ReservationOperaOperationSnapshot( + Long id, + String hotelId, + Long orderId, + Long taskId, + Integer operationSequence, + String operationCode, + String operationName, + String operationStatus, + String requestPayloadJson, + Integer attemptCount, + Long lastAttemptId, + String lastErrorMessage, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java index 14b1f05..8f12b71 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskCardSnapshot.java @@ -1,7 +1,9 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; +import java.time.LocalDateTime; + /** - * 任务卡快照。保存字段契约版本和 AI 原始快照,供详情接口按矩阵组装展示字段。 + * 任务卡快照。保存字段契约版本、AI 原始快照、用户草稿和最终确认 payload。 */ public record ReservationTaskCardSnapshot( Long id, @@ -9,6 +11,10 @@ public record ReservationTaskCardSnapshot( Long taskId, String taskCardType, String fieldContractVersion, - String aiPayloadJson + String aiPayloadJson, + String draftPayloadJson, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskSnapshot.java index ef8904a..2fef820 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskSnapshot.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationTaskSnapshot.java @@ -1,7 +1,9 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; +import java.time.LocalDateTime; + /** - * 任务快照。用于任务详情、队列可处理状态和人工复核转换,不向上暴露 Entity。 + * 任务快照。用于任务详情、队列可处理状态、人工复核转换和任务确认,不向上暴露 Entity。 */ public record ReservationTaskSnapshot( Long id, @@ -20,6 +22,7 @@ public record ReservationTaskSnapshot( Long parentTaskId, Integer parentSourceEventIndex, String linkedTaskGroupId, - Boolean blockedUntilParentCompleted + Boolean blockedUntilParentCompleted, + LocalDateTime confirmedAt ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOperaAttemptStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOperaAttemptStatus.java new file mode 100644 index 0000000..a6c3e18 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOperaAttemptStatus.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * OPERA 模拟 attempt 状态。每次执行或重试只能成功或失败。 + */ +public enum ReservationOperaAttemptStatus { + SUCCEEDED, + FAILED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOperaOperationStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOperaOperationStatus.java new file mode 100644 index 0000000..b79263e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationOperaOperationStatus.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * OPERA 模拟操作状态。第一版只表达待执行、成功和失败。 + */ +public enum ReservationOperaOperationStatus { + PENDING, + SUCCEEDED, + FAILED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationOperaSimulationRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationOperaSimulationRequest.java new file mode 100644 index 0000000..26dc064 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationOperaSimulationRequest.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * OPERA 模拟执行请求。第一版只用开关控制模拟成功或失败,不接真实 OPERA。 + */ +public record ReservationOperaSimulationRequest( + @JsonProperty("simulate_success") + Boolean simulateSuccess, + @JsonProperty("failure_message") + String failureMessage +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskPayloadMutationRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskPayloadMutationRequest.java new file mode 100644 index 0000000..d8a2805 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskPayloadMutationRequest.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * 任务卡草稿保存和最终确认请求。第一版 field_values 使用矩阵 field_path 作为 key,不使用 write_path 生成 OPERA 参数。 + */ +public record ReservationTaskPayloadMutationRequest( + @JsonProperty("field_values") + Map fieldValues +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOperaOperationAttemptResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOperaOperationAttemptResult.java new file mode 100644 index 0000000..9493b40 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOperaOperationAttemptResult.java @@ -0,0 +1,24 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * OPERA 模拟操作 attempt 响应。用于展示每次执行或重试的结果。 + */ +public record ReservationOperaOperationAttemptResult( + @JsonProperty("attempt_id") + String attemptId, + @JsonProperty("attempt_number") + Integer attemptNumber, + @JsonProperty("attempt_status") + String attemptStatus, + @JsonProperty("response_payload") + Object responsePayload, + @JsonProperty("error_message") + String errorMessage, + @JsonProperty("started_at") + String startedAt, + @JsonProperty("finished_at") + String finishedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOperaOperationResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOperaOperationResult.java new file mode 100644 index 0000000..a6f6201 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOperaOperationResult.java @@ -0,0 +1,28 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * OPERA 模拟操作响应。包含当前操作状态和 attempt 历史。 + */ +public record ReservationOperaOperationResult( + @JsonProperty("operation_id") + String operationId, + @JsonProperty("task_id") + String taskId, + @JsonProperty("operation_sequence") + Integer operationSequence, + @JsonProperty("operation_code") + String operationCode, + @JsonProperty("operation_name") + String operationName, + @JsonProperty("operation_status") + String operationStatus, + @JsonProperty("attempt_count") + Integer attemptCount, + @JsonProperty("last_error_message") + String lastErrorMessage, + List attempts +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAuditListResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAuditListResult.java new file mode 100644 index 0000000..ab0250f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAuditListResult.java @@ -0,0 +1,14 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 任务审计列表响应。第一版按任务维度返回审计流水。 + */ +public record ReservationTaskAuditListResult( + @JsonProperty("task_id") + String taskId, + List items +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAuditLogResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAuditLogResult.java new file mode 100644 index 0000000..311ce0f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskAuditLogResult.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 任务审计列表单条响应。只返回审计摘要,不返回完整客户原始报文。 + */ +public record ReservationTaskAuditLogResult( + @JsonProperty("audit_id") + String auditId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("task_id") + String taskId, + @JsonProperty("operation_id") + String operationId, + @JsonProperty("actor_type") + String actorType, + @JsonProperty("actor_id") + String actorId, + String action, + String reason, + @JsonProperty("before_snapshot") + Object beforeSnapshot, + @JsonProperty("after_snapshot") + Object afterSnapshot, + @JsonProperty("occurred_at") + String occurredAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java index 8e47126..47c0922 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskDetailResult.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; /** - * 任务详情结果。第一版提供队列可处理状态和按矩阵生成的字段列表。 + * 任务详情结果。提供队列可处理状态、任务卡 payload 和按矩阵生成的字段列表。 */ public record ReservationTaskDetailResult( @JsonProperty("task_id") @@ -21,7 +21,13 @@ public record ReservationTaskDetailResult( String taskStatus, @JsonProperty("field_contract_version") String fieldContractVersion, + @JsonProperty("draft_payload") + Object draftPayload, + @JsonProperty("confirmed_payload") + Object confirmedPayload, ReservationTaskAvailabilityResult availability, - List fields + List fields, + @JsonProperty("opera_operations") + List operaOperations ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskPayloadMutationResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskPayloadMutationResult.java new file mode 100644 index 0000000..e0bd869 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskPayloadMutationResult.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 任务卡草稿保存或最终确认结果。payload 为第一版 field_path 简单结构,供前端回显和后续 OPERA 转换使用。 + */ +public record ReservationTaskPayloadMutationResult( + @JsonProperty("task_id") + String taskId, + @JsonProperty("order_id") + String orderId, + @JsonProperty("task_status") + String taskStatus, + @JsonProperty("draft_payload") + Object draftPayload, + @JsonProperty("confirmed_payload") + Object confirmedPayload, + @JsonProperty("opera_operations") + List operaOperations +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java index efc748c..faa9949 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskController.java @@ -1,13 +1,19 @@ package cn.nianxx.thhotel.workflows.reservation.control; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskPayloadMutationResult; import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskWorkflowService; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -48,4 +54,66 @@ public class ReservationTaskController { @RequestBody(required = false) ManualReviewConversionRequest request) { return taskWorkflowService.convertManualReviewTask(taskId, request); } + + /** + * 保存任务卡草稿。第一版 field_values 使用矩阵 field_path,不直接生成 OPERA 参数。 + */ + @PutMapping( + value = "/{taskId}/draft", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationTaskPayloadMutationResult saveDraft( + @PathVariable Long taskId, + @RequestBody(required = false) ReservationTaskPayloadMutationRequest request) { + return taskWorkflowService.saveTaskDraft(taskId, request); + } + + /** + * 最终确认任务卡字段;校验通过后任务进入 READY,等待后续 OPERA 阶段处理。 + */ + @PostMapping( + value = "/{taskId}/confirm", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationTaskPayloadMutationResult confirm( + @PathVariable Long taskId, + @RequestBody(required = false) ReservationTaskPayloadMutationRequest request) { + return taskWorkflowService.confirmTask(taskId, request); + } + + /** + * 执行一条 OPERA 模拟操作;第一版只写入模拟 attempt,不调用真实 OPERA。 + */ + @PostMapping( + value = "/{taskId}/opera-operations/{operationId}/execute", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationOperaOperationResult executeOperaOperation( + @PathVariable Long taskId, + @PathVariable Long operationId, + @RequestBody(required = false) ReservationOperaSimulationRequest request) { + return taskWorkflowService.executeOperaOperation(taskId, operationId, request); + } + + /** + * 重试失败的 OPERA 模拟操作;重试会追加 attempt 历史。 + */ + @PostMapping( + value = "/{taskId}/opera-operations/{operationId}/retry", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationOperaOperationResult retryOperaOperation( + @PathVariable Long taskId, + @PathVariable Long operationId, + @RequestBody(required = false) ReservationOperaSimulationRequest request) { + return taskWorkflowService.retryOperaOperation(taskId, operationId, request); + } + + /** + * 查询任务审计流水,供前端后续展示人工确认和 OPERA 模拟操作轨迹。 + */ + @GetMapping(value = "/{taskId}/audits", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationTaskAuditListResult listAudits(@PathVariable Long taskId) { + return taskWorkflowService.listTaskAudits(taskId); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java index 0d72443..6bda4a5 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -2,7 +2,6 @@ package cn.nianxx.thhotel.workflows.reservation.control; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse; import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException; -import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -20,6 +19,9 @@ public class ReservationTaskControllerAdvice { public ResponseEntity handleTaskWorkflowException( ReservationTaskWorkflowException exception) { return ResponseEntity.status(exception.getStatus()) - .body(new ReservationWorkflowErrorResponse(exception.getErrorCode(), exception.getMessage(), List.of())); + .body(new ReservationWorkflowErrorResponse( + exception.getErrorCode(), + exception.getMessage(), + exception.getDetails())); } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOperaOperationAttemptEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOperaOperationAttemptEntity.java new file mode 100644 index 0000000..709d703 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOperaOperationAttemptEntity.java @@ -0,0 +1,68 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation OPERA 模拟操作 attempt 实体。记录每次执行或重试的请求、响应和结果。 + */ +@TableName("workflow_reservation_opera_operation_attempt") +public class ReservationOperaOperationAttemptEntity { + + /** OPERA 模拟操作 attempt ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 关联订单 ID。 */ + private Long orderId; + /** 关联任务 ID。 */ + private Long taskId; + /** 关联 OPERA 模拟操作 ID。 */ + private Long operationId; + /** 同一操作内第几次 attempt,从 1 开始。 */ + private Integer attemptNumber; + /** attempt 状态:SUCCEEDED、FAILED。 */ + private String attemptStatus; + /** 本次模拟请求 JSON。 */ + private String requestPayloadJson; + /** 本次模拟响应 JSON。 */ + private String responsePayloadJson; + /** 失败原因摘要。 */ + private String errorMessage; + /** attempt 开始 UTC 时间。 */ + private LocalDateTime startedAt; + /** attempt 结束 UTC 时间。 */ + private LocalDateTime finishedAt; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long orderId) { this.orderId = orderId; } + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + public Long getOperationId() { return operationId; } + public void setOperationId(Long operationId) { this.operationId = operationId; } + public Integer getAttemptNumber() { return attemptNumber; } + public void setAttemptNumber(Integer attemptNumber) { this.attemptNumber = attemptNumber; } + public String getAttemptStatus() { return attemptStatus; } + public void setAttemptStatus(String attemptStatus) { this.attemptStatus = attemptStatus; } + public String getRequestPayloadJson() { return requestPayloadJson; } + public void setRequestPayloadJson(String requestPayloadJson) { this.requestPayloadJson = requestPayloadJson; } + public String getResponsePayloadJson() { return responsePayloadJson; } + public void setResponsePayloadJson(String responsePayloadJson) { this.responsePayloadJson = responsePayloadJson; } + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + public LocalDateTime getStartedAt() { return startedAt; } + public void setStartedAt(LocalDateTime startedAt) { this.startedAt = startedAt; } + public LocalDateTime getFinishedAt() { return finishedAt; } + public void setFinishedAt(LocalDateTime finishedAt) { this.finishedAt = finishedAt; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOperaOperationEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOperaOperationEntity.java new file mode 100644 index 0000000..55a8118 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOperaOperationEntity.java @@ -0,0 +1,72 @@ +package cn.nianxx.thhotel.workflows.reservation.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * Reservation OPERA 模拟操作实体。保存任务确认后生成的固定模拟操作及当前状态。 + */ +@TableName("workflow_reservation_opera_operation") +public class ReservationOperaOperationEntity { + + /** OPERA 模拟操作 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店或业务上下文 ID。 */ + private String hotelId; + /** 关联订单 ID。 */ + private Long orderId; + /** 关联任务 ID。 */ + private Long taskId; + /** 任务内模拟操作顺序,从 1 开始。 */ + private Integer operationSequence; + /** 模拟操作代码,第一版固定两类。 */ + private String operationCode; + /** 模拟操作展示名称。 */ + private String operationName; + /** 操作状态:PENDING、SUCCEEDED、FAILED。 */ + private String operationStatus; + /** 模拟请求 JSON,来源于确认 payload 的低耦合摘要。 */ + private String requestPayloadJson; + /** 已执行 attempt 次数。 */ + private Integer attemptCount; + /** 最近一次 attempt ID。 */ + private Long lastAttemptId; + /** 最近一次失败原因摘要。 */ + private String lastErrorMessage; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getHotelId() { return hotelId; } + public void setHotelId(String hotelId) { this.hotelId = hotelId; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long orderId) { this.orderId = orderId; } + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + public Integer getOperationSequence() { return operationSequence; } + public void setOperationSequence(Integer operationSequence) { this.operationSequence = operationSequence; } + public String getOperationCode() { return operationCode; } + public void setOperationCode(String operationCode) { this.operationCode = operationCode; } + public String getOperationName() { return operationName; } + public void setOperationName(String operationName) { this.operationName = operationName; } + public String getOperationStatus() { return operationStatus; } + public void setOperationStatus(String operationStatus) { this.operationStatus = operationStatus; } + public String getRequestPayloadJson() { return requestPayloadJson; } + public void setRequestPayloadJson(String requestPayloadJson) { this.requestPayloadJson = requestPayloadJson; } + public Integer getAttemptCount() { return attemptCount; } + public void setAttemptCount(Integer attemptCount) { this.attemptCount = attemptCount; } + public Long getLastAttemptId() { return lastAttemptId; } + public void setLastAttemptId(Long lastAttemptId) { this.lastAttemptId = lastAttemptId; } + public String getLastErrorMessage() { return lastErrorMessage; } + public void setLastErrorMessage(String lastErrorMessage) { this.lastErrorMessage = lastErrorMessage; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java index 022a3f7..d634c64 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java @@ -6,7 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime; /** - * Reservation 任务卡实体。CP1-3 只保存 AI 原始快照,不承载用户编辑确认 payload。 + * Reservation 任务卡实体。保存 AI 原始快照、用户草稿和最终确认 payload。 */ @TableName("workflow_reservation_task_card") public class ReservationTaskCardEntity { @@ -24,6 +24,14 @@ public class ReservationTaskCardEntity { private String fieldContractVersion; /** 任务卡使用的 AI 原始 JSON 快照。 */ private String aiPayloadJson; + /** 用户保存的任务卡草稿 JSON,第一版按矩阵 field_path 保存。 */ + private String draftPayloadJson; + /** 用户最终确认后的任务卡 JSON,第一版按矩阵 field_path 合并保存。 */ + private String confirmedPayloadJson; + /** 确认人标识,用户身份权限接入前使用本地占位。 */ + private String confirmedBy; + /** 任务卡最终确认 UTC 时间。 */ + private LocalDateTime confirmedAt; /** 记录创建 UTC 时间。 */ private LocalDateTime createdAt; /** 记录更新 UTC 时间。 */ @@ -41,6 +49,14 @@ public class ReservationTaskCardEntity { public void setFieldContractVersion(String fieldContractVersion) { this.fieldContractVersion = fieldContractVersion; } public String getAiPayloadJson() { return aiPayloadJson; } public void setAiPayloadJson(String aiPayloadJson) { this.aiPayloadJson = aiPayloadJson; } + public String getDraftPayloadJson() { return draftPayloadJson; } + public void setDraftPayloadJson(String draftPayloadJson) { this.draftPayloadJson = draftPayloadJson; } + public String getConfirmedPayloadJson() { return confirmedPayloadJson; } + public void setConfirmedPayloadJson(String confirmedPayloadJson) { this.confirmedPayloadJson = confirmedPayloadJson; } + public String getConfirmedBy() { return confirmedBy; } + public void setConfirmedBy(String confirmedBy) { this.confirmedBy = confirmedBy; } + public LocalDateTime getConfirmedAt() { return confirmedAt; } + public void setConfirmedAt(LocalDateTime confirmedAt) { this.confirmedAt = confirmedAt; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskEntity.java index d2f1de9..bfdd969 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskEntity.java @@ -48,6 +48,8 @@ public class ReservationTaskEntity { private Boolean blockedUntilParentCompleted; /** 最近失败原因摘要。 */ private String lastFailureReason; + /** 用户最终确认任务字段和订单归属的 UTC 时间。 */ + private LocalDateTime confirmedAt; /** 任务完成 UTC 时间。 */ private LocalDateTime completedAt; /** 乐观锁版本。 */ @@ -93,6 +95,8 @@ public class ReservationTaskEntity { public void setBlockedUntilParentCompleted(Boolean blockedUntilParentCompleted) { this.blockedUntilParentCompleted = blockedUntilParentCompleted; } public String getLastFailureReason() { return lastFailureReason; } public void setLastFailureReason(String lastFailureReason) { this.lastFailureReason = lastFailureReason; } + public LocalDateTime getConfirmedAt() { return confirmedAt; } + public void setConfirmedAt(LocalDateTime confirmedAt) { this.confirmedAt = confirmedAt; } public LocalDateTime getCompletedAt() { return completedAt; } public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; } public Long getVersion() { return version; } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOperaOperationAttemptMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOperaOperationAttemptMapper.java new file mode 100644 index 0000000..741b859 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOperaOperationAttemptMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOperaOperationAttemptEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation OPERA 模拟操作 attempt Mapper,只负责 attempt 表持久化访问。 + */ +@Mapper +public interface ReservationOperaOperationAttemptMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOperaOperationMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOperaOperationMapper.java new file mode 100644 index 0000000..1e81cc2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOperaOperationMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOperaOperationEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation OPERA 模拟操作 Mapper,只负责操作表持久化访问。 + */ +@Mapper +public interface ReservationOperaOperationMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index 1b25c8a..8db1cd2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -4,6 +4,11 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraf import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft; @@ -11,15 +16,20 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSna import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiBatchEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAiTransitionEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationAuditLogEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOperaOperationAttemptEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOperaOperationEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOrderEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskCardEntity; import cn.nianxx.thhotel.workflows.reservation.domain.ReservationTaskEntity; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationAiBatchMapper; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationAiTransitionMapper; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationAuditLogMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationOperaOperationAttemptMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationOperaOperationMapper; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationOrderMapper; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskCardMapper; import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper; @@ -40,6 +50,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork private final ReservationOrderMapper orderMapper; private final ReservationTaskMapper taskMapper; private final ReservationTaskCardMapper taskCardMapper; + private final ReservationOperaOperationMapper operaOperationMapper; + private final ReservationOperaOperationAttemptMapper operaOperationAttemptMapper; private final ReservationAuditLogMapper auditLogMapper; /** @@ -51,12 +63,16 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork ReservationOrderMapper orderMapper, ReservationTaskMapper taskMapper, ReservationTaskCardMapper taskCardMapper, + ReservationOperaOperationMapper operaOperationMapper, + ReservationOperaOperationAttemptMapper operaOperationAttemptMapper, ReservationAuditLogMapper auditLogMapper) { this.batchMapper = batchMapper; this.transitionMapper = transitionMapper; this.orderMapper = orderMapper; this.taskMapper = taskMapper; this.taskCardMapper = taskCardMapper; + this.operaOperationMapper = operaOperationMapper; + this.operaOperationAttemptMapper = operaOperationAttemptMapper; this.auditLogMapper = auditLogMapper; } @@ -339,6 +355,169 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork .eq(ReservationTaskCardEntity::getTaskId, taskId)); } + /** + * 保存任务卡草稿 payload。 + */ + @Override + public boolean updateTaskDraftPayload(String hotelId, Long taskId, String draftPayloadJson, LocalDateTime now) { + return taskCardMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationTaskCardEntity::getDraftPayloadJson, draftPayloadJson) + .set(ReservationTaskCardEntity::getUpdatedAt, now) + .eq(ReservationTaskCardEntity::getHotelId, hotelId) + .eq(ReservationTaskCardEntity::getTaskId, taskId)) > 0; + } + + /** + * 保存最终确认 payload,并将任务状态从 PENDING_CONFIRM 流转为 READY。 + */ + @Override + public boolean confirmTaskPayload( + String hotelId, + Long taskId, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt) { + int updatedTaskCount = taskMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationTaskEntity::getTaskStatus, ReservationTaskStatus.READY.name()) + .set(ReservationTaskEntity::getConfirmedAt, confirmedAt) + .set(ReservationTaskEntity::getUpdatedAt, confirmedAt) + .setSql("version = version + 1") + .eq(ReservationTaskEntity::getHotelId, hotelId) + .eq(ReservationTaskEntity::getId, taskId) + .eq(ReservationTaskEntity::getTaskStatus, ReservationTaskStatus.PENDING_CONFIRM.name())); + if (updatedTaskCount == 0) { + return false; + } + int updatedCardCount = taskCardMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationTaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson) + .set(ReservationTaskCardEntity::getConfirmedBy, confirmedBy) + .set(ReservationTaskCardEntity::getConfirmedAt, confirmedAt) + .set(ReservationTaskCardEntity::getUpdatedAt, confirmedAt) + .eq(ReservationTaskCardEntity::getHotelId, hotelId) + .eq(ReservationTaskCardEntity::getTaskId, taskId)); + return updatedCardCount > 0; + } + + /** + * 插入 OPERA 模拟操作。 + */ + @Override + public Long insertOperaOperation(ReservationOperaOperationDraft draft) { + ReservationOperaOperationEntity entity = new ReservationOperaOperationEntity(); + entity.setHotelId(draft.hotelId()); + entity.setOrderId(draft.orderId()); + entity.setTaskId(draft.taskId()); + entity.setOperationSequence(draft.operationSequence()); + entity.setOperationCode(draft.operationCode()); + entity.setOperationName(draft.operationName()); + entity.setOperationStatus(draft.operationStatus()); + entity.setRequestPayloadJson(draft.requestPayloadJson()); + entity.setAttemptCount(0); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + operaOperationMapper.insert(entity); + return entity.getId(); + } + + /** + * 查询任务下 OPERA 模拟操作,按任务内顺序返回。 + */ + @Override + public List findOperaOperationsByTaskId(String hotelId, Long taskId) { + return operaOperationMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationOperaOperationEntity::getHotelId, hotelId) + .eq(ReservationOperaOperationEntity::getTaskId, taskId) + .orderByAsc(ReservationOperaOperationEntity::getOperationSequence)) + .stream() + .map(this::toOperaOperationSnapshot) + .toList(); + } + + /** + * 按操作 ID 查询 OPERA 模拟操作。 + */ + @Override + public Optional findOperaOperationById(String hotelId, Long operationId) { + ReservationOperaOperationEntity entity = operaOperationMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationOperaOperationEntity::getHotelId, hotelId) + .eq(ReservationOperaOperationEntity::getId, operationId) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toOperaOperationSnapshot); + } + + /** + * 查询 OPERA 模拟操作 attempt 历史。 + */ + @Override + public List findOperaOperationAttempts(String hotelId, Long operationId) { + return operaOperationAttemptMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationOperaOperationAttemptEntity::getHotelId, hotelId) + .eq(ReservationOperaOperationAttemptEntity::getOperationId, operationId) + .orderByAsc(ReservationOperaOperationAttemptEntity::getAttemptNumber)) + .stream() + .map(this::toOperaOperationAttemptSnapshot) + .toList(); + } + + /** + * 插入 OPERA 模拟操作 attempt。 + */ + @Override + public Long insertOperaOperationAttempt(ReservationOperaOperationAttemptDraft draft) { + ReservationOperaOperationAttemptEntity entity = new ReservationOperaOperationAttemptEntity(); + entity.setHotelId(draft.hotelId()); + entity.setOrderId(draft.orderId()); + entity.setTaskId(draft.taskId()); + entity.setOperationId(draft.operationId()); + entity.setAttemptNumber(draft.attemptNumber()); + entity.setAttemptStatus(draft.attemptStatus()); + entity.setRequestPayloadJson(draft.requestPayloadJson()); + entity.setResponsePayloadJson(draft.responsePayloadJson()); + entity.setErrorMessage(draft.errorMessage()); + entity.setStartedAt(draft.startedAt()); + entity.setFinishedAt(draft.finishedAt()); + entity.setCreatedAt(draft.startedAt()); + operaOperationAttemptMapper.insert(entity); + return entity.getId(); + } + + /** + * 更新 OPERA 模拟操作当前状态和最近 attempt 摘要。 + */ + @Override + public boolean updateOperaOperationAfterAttempt( + String hotelId, + Long operationId, + String operationStatus, + Long lastAttemptId, + String lastErrorMessage, + LocalDateTime now) { + return operaOperationMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationOperaOperationEntity::getOperationStatus, operationStatus) + .set(ReservationOperaOperationEntity::getLastAttemptId, lastAttemptId) + .set(ReservationOperaOperationEntity::getLastErrorMessage, lastErrorMessage) + .set(ReservationOperaOperationEntity::getUpdatedAt, now) + .setSql("attempt_count = attempt_count + 1") + .eq(ReservationOperaOperationEntity::getHotelId, hotelId) + .eq(ReservationOperaOperationEntity::getId, operationId)) > 0; + } + + /** + * 更新任务状态。 + */ + @Override + public boolean updateTaskStatus(String hotelId, Long taskId, String taskStatus, LocalDateTime now) { + return taskMapper.update(null, Wrappers.lambdaUpdate() + .set(ReservationTaskEntity::getTaskStatus, taskStatus) + .set(ReservationTaskEntity::getCompletedAt, + ReservationTaskStatus.COMPLETED.name().equals(taskStatus) ? now : null) + .set(ReservationTaskEntity::getUpdatedAt, now) + .setSql("version = version + 1") + .eq(ReservationTaskEntity::getHotelId, hotelId) + .eq(ReservationTaskEntity::getId, taskId)) > 0; + } + /** * 统计订单下全部任务数量。 */ @@ -387,6 +566,20 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork return entity.getId(); } + /** + * 查询任务审计流水,按发生时间正序返回。 + */ + @Override + public List findAuditLogsByTaskId(String hotelId, Long taskId) { + return auditLogMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationAuditLogEntity::getHotelId, hotelId) + .eq(ReservationAuditLogEntity::getTaskId, taskId) + .orderByAsc(ReservationAuditLogEntity::getOccurredAt)) + .stream() + .map(this::toAuditLogSnapshot) + .toList(); + } + /** * 转换批次实体为快照。 */ @@ -435,7 +628,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.getParentTaskId(), entity.getParentSourceEventIndex(), entity.getLinkedTaskGroupId(), - entity.getBlockedUntilParentCompleted()); + entity.getBlockedUntilParentCompleted(), + entity.getConfirmedAt()); } /** @@ -448,6 +642,70 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork entity.getTaskId(), entity.getTaskCardType(), entity.getFieldContractVersion(), - entity.getAiPayloadJson()); + entity.getAiPayloadJson(), + entity.getDraftPayloadJson(), + entity.getConfirmedPayloadJson(), + entity.getConfirmedBy(), + entity.getConfirmedAt()); + } + + /** + * 转换 OPERA 模拟操作实体为快照。 + */ + private ReservationOperaOperationSnapshot toOperaOperationSnapshot(ReservationOperaOperationEntity entity) { + return new ReservationOperaOperationSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getOrderId(), + entity.getTaskId(), + entity.getOperationSequence(), + entity.getOperationCode(), + entity.getOperationName(), + entity.getOperationStatus(), + entity.getRequestPayloadJson(), + entity.getAttemptCount(), + entity.getLastAttemptId(), + entity.getLastErrorMessage(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } + + /** + * 转换 OPERA 模拟操作 attempt 实体为快照。 + */ + private ReservationOperaOperationAttemptSnapshot toOperaOperationAttemptSnapshot( + ReservationOperaOperationAttemptEntity entity) { + return new ReservationOperaOperationAttemptSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getOrderId(), + entity.getTaskId(), + entity.getOperationId(), + entity.getAttemptNumber(), + entity.getAttemptStatus(), + entity.getRequestPayloadJson(), + entity.getResponsePayloadJson(), + entity.getErrorMessage(), + entity.getStartedAt(), + entity.getFinishedAt()); + } + + /** + * 转换审计实体为快照。 + */ + private ReservationAuditLogSnapshot toAuditLogSnapshot(ReservationAuditLogEntity entity) { + return new ReservationAuditLogSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getOrderId(), + entity.getTaskId(), + entity.getOperationId(), + entity.getActorType(), + entity.getActorId(), + entity.getAction(), + entity.getReason(), + entity.getBeforeSnapshotJson(), + entity.getAfterSnapshotJson(), + entity.getOccurredAt()); } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java index d5a113f..171dd1a 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -4,6 +4,11 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchDraf import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiBatchSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft; @@ -106,6 +111,62 @@ public interface ReservationAiWorkflowRepository { Integer executionOrder, LocalDateTime now); + /** + * 保存任务卡草稿 payload。 + */ + boolean updateTaskDraftPayload(String hotelId, Long taskId, String draftPayloadJson, LocalDateTime now); + + /** + * 保存最终确认 payload,并将任务状态从 PENDING_CONFIRM 流转为 READY。 + */ + boolean confirmTaskPayload( + String hotelId, + Long taskId, + String confirmedPayloadJson, + String confirmedBy, + LocalDateTime confirmedAt); + + /** + * 新增 OPERA 模拟操作,并返回操作 ID。 + */ + Long insertOperaOperation(ReservationOperaOperationDraft draft); + + /** + * 查询任务下全部 OPERA 模拟操作。 + */ + List findOperaOperationsByTaskId(String hotelId, Long taskId); + + /** + * 按操作 ID 查询 OPERA 模拟操作。 + */ + Optional findOperaOperationById(String hotelId, Long operationId); + + /** + * 查询 OPERA 模拟操作的 attempt 历史。 + */ + List findOperaOperationAttempts(String hotelId, Long operationId); + + /** + * 新增 OPERA 模拟操作 attempt,并返回 attempt ID。 + */ + Long insertOperaOperationAttempt(ReservationOperaOperationAttemptDraft draft); + + /** + * 更新 OPERA 模拟操作的当前状态和最近 attempt 摘要。 + */ + boolean updateOperaOperationAfterAttempt( + String hotelId, + Long operationId, + String operationStatus, + Long lastAttemptId, + String lastErrorMessage, + LocalDateTime now); + + /** + * 更新任务状态,用于 OPERA 模拟操作全部成功后完成任务。 + */ + boolean updateTaskStatus(String hotelId, Long taskId, String taskStatus, LocalDateTime now); + /** * 统计订单当前剩余任务数量,用于判断临时订单能否逻辑删除。 */ @@ -120,4 +181,9 @@ public interface ReservationAiWorkflowRepository { * 新增订单任务审计记录。 */ Long insertAuditLog(ReservationAuditLogDraft draft); + + /** + * 查询任务审计流水。 + */ + List findAuditLogsByTaskId(String hotelId, Long taskId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java index ae6e653..b925a67 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationTaskWorkflowService.java @@ -1,11 +1,16 @@ package cn.nianxx.thhotel.workflows.reservation.service; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskPayloadMutationResult; /** - * Reservation 任务工作流服务。提供任务详情、队列可处理状态和人工复核转换能力。 + * Reservation 任务工作流服务。提供任务详情、队列可处理状态、人工复核转换和任务确认能力。 */ public interface ReservationTaskWorkflowService { @@ -18,4 +23,35 @@ public interface ReservationTaskWorkflowService { * 将 Fallback / manual_review 任务人工转换为 New、Update 或 Cancel。 */ ManualReviewConversionResult convertManualReviewTask(Long taskId, ManualReviewConversionRequest request); + + /** + * 保存任务卡草稿;只允许当前可编辑的 PENDING_CONFIRM 任务调用。 + */ + ReservationTaskPayloadMutationResult saveTaskDraft(Long taskId, ReservationTaskPayloadMutationRequest request); + + /** + * 最终确认任务卡字段;校验通过后任务从 PENDING_CONFIRM 流转为 READY。 + */ + ReservationTaskPayloadMutationResult confirmTask(Long taskId, ReservationTaskPayloadMutationRequest request); + + /** + * 执行一条 OPERA 模拟操作;只允许前置模拟操作已成功的操作执行。 + */ + ReservationOperaOperationResult executeOperaOperation( + Long taskId, + Long operationId, + ReservationOperaSimulationRequest request); + + /** + * 重试失败的 OPERA 模拟操作;成功后继续保留 attempt 历史。 + */ + ReservationOperaOperationResult retryOperaOperation( + Long taskId, + Long operationId, + ReservationOperaSimulationRequest request); + + /** + * 查询任务审计流水。 + */ + ReservationTaskAuditListResult listTaskAudits(Long taskId); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowException.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowException.java index 032c6d4..f9cfd50 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowException.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowException.java @@ -1,19 +1,30 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; +import java.util.List; import org.springframework.http.HttpStatus; /** - * Reservation 任务工作流受控异常。用于详情、队列状态和人工转换接口。 + * Reservation 任务工作流受控异常。用于详情、队列状态、人工转换和任务确认接口。 */ public class ReservationTaskWorkflowException extends RuntimeException { private final HttpStatus status; private final String errorCode; + private final List details; public ReservationTaskWorkflowException(HttpStatus status, String errorCode, String message) { + this(status, errorCode, message, List.of()); + } + + public ReservationTaskWorkflowException( + HttpStatus status, + String errorCode, + String message, + List details) { super(message); this.status = status; this.errorCode = errorCode; + this.details = details == null ? List.of() : List.copyOf(details); } public HttpStatus getStatus() { @@ -23,4 +34,8 @@ public class ReservationTaskWorkflowException extends RuntimeException { public String getErrorCode() { return errorCode; } + + public List getDetails() { + return details; + } } 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 43e2414..3d09e5d 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 @@ -1,30 +1,51 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationAttemptSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardFieldDefinition; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessKeySource; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOperaAttemptStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOperaOperationStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus; import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationAttemptResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditLogResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskFieldResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskPayloadMutationResult; import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository; import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskCardFieldDefinitionProvider; import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskWorkflowService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -37,8 +58,17 @@ import org.springframework.transaction.annotation.Transactional; public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkflowService { private static final String ACTION_MANUAL_REVIEW_CONVERT = "MANUAL_REVIEW_CONVERT"; + private static final String ACTION_TASK_DRAFT_SAVE = "TASK_DRAFT_SAVE"; + private static final String ACTION_TASK_CONFIRM = "TASK_CONFIRM"; + private static final String ACTION_OPERA_OPERATION_EXECUTE = "OPERA_OPERATION_EXECUTE"; + private static final String ACTION_OPERA_OPERATION_RETRY = "OPERA_OPERATION_RETRY"; private static final String ACTOR_TYPE_USER = "USER"; private static final String ACTOR_ID_LOCAL = "local-user"; + private static final String PAYLOAD_SCHEMA_VERSION = "field_path-v1"; + private static final String PAYLOAD_SOURCE_DRAFT = "TASK_DRAFT"; + private static final String PAYLOAD_SOURCE_CONFIRMATION = "TASK_CONFIRMATION"; + private static final String OPERA_OPERATION_CODE_PRECHECK = "SIMULATE_PRECHECK"; + private static final String OPERA_OPERATION_CODE_WRITE = "SIMULATE_WRITE"; private static final int MAX_QUEUE_ORDER_RETRY = 5; private final ReservationAiWorkflowRepository workflowRepository; @@ -69,6 +99,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。")); ReservationTaskAvailabilityResult availability = calculateAvailability(task); List fields = buildFieldResults(task, taskCard); + List operaOperations = findOperaOperationResults(task); return new ReservationTaskDetailResult( task.id().toString(), task.orderId().toString(), @@ -77,8 +108,11 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl task.taskCardType(), task.taskStatus(), taskCard.fieldContractVersion(), + jsonPayloadToObject(taskCard.draftPayloadJson()), + jsonPayloadToObject(taskCard.confirmedPayloadJson()), availability, - fields); + fields, + operaOperations); } /** @@ -109,6 +143,427 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl originalOrderLogicDeleted); } + /** + * 保存任务卡草稿。草稿允许不完整,但提交字段必须属于当前任务矩阵且符合基础校验。 + */ + @Override + @Transactional + public ReservationTaskPayloadMutationResult saveTaskDraft( + Long taskId, + ReservationTaskPayloadMutationRequest request) { + ReservationTaskSnapshot task = findTaskOrThrow(taskId); + ReservationTaskCardSnapshot taskCard = findTaskCardOrThrow(task); + ensureTaskEditable(task); + + JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); + Map submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues()); + Map draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson()); + Map mergedDraftValues = mergeFieldValues(draftValues, submittedValues); + List activeDefinitions = + activeDefinitions(task, aiPayload, mergedDraftValues); + List validationErrors = validateSubmittedFields(activeDefinitions, submittedValues); + validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, mergedDraftValues, + submittedValues.keySet(), false)); + if (!validationErrors.isEmpty()) { + throw validationError(validationErrors); + } + + LocalDateTime now = nowUtc(); + String draftPayloadJson = toPayloadJson(PAYLOAD_SOURCE_DRAFT, mergedDraftValues, now); + boolean updated = workflowRepository.updateTaskDraftPayload(task.hotelId(), task.id(), draftPayloadJson, now); + if (!updated) { + throw error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。"); + } + writePayloadMutationAudit(task, ACTION_TASK_DRAFT_SAVE, taskCard.draftPayloadJson(), draftPayloadJson, now); + return new ReservationTaskPayloadMutationResult( + task.id().toString(), + task.orderId().toString(), + task.taskStatus(), + jsonPayloadToObject(draftPayloadJson), + jsonPayloadToObject(taskCard.confirmedPayloadJson()), + List.of()); + } + + /** + * 最终确认任务字段。确认会按矩阵做必填、枚举和基础规则校验,并流转为 READY。 + */ + @Override + @Transactional + public ReservationTaskPayloadMutationResult confirmTask( + Long taskId, + ReservationTaskPayloadMutationRequest request) { + ReservationTaskSnapshot task = findTaskOrThrow(taskId); + ReservationTaskCardSnapshot taskCard = findTaskCardOrThrow(task); + ensureTaskEditable(task); + + JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); + Map submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues()); + Map draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson()); + Map editedValues = mergeFieldValues(draftValues, submittedValues); + List activeDefinitions = activeDefinitions(task, aiPayload, editedValues); + List validationErrors = validateSubmittedFields(activeDefinitions, submittedValues); + validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues, + definitionFieldPaths(activeDefinitions), true)); + validationErrors.addAll(validateDateRange(activeDefinitions, aiPayload, editedValues)); + if (!validationErrors.isEmpty()) { + throw validationError(validationErrors); + } + + LocalDateTime now = nowUtc(); + Map confirmedValues = buildConfirmedFieldValues(activeDefinitions, aiPayload, editedValues); + String confirmedPayloadJson = toPayloadJson(PAYLOAD_SOURCE_CONFIRMATION, confirmedValues, now); + boolean confirmed = workflowRepository.confirmTaskPayload( + task.hotelId(), + task.id(), + confirmedPayloadJson, + ACTOR_ID_LOCAL, + now); + if (!confirmed) { + throw error(HttpStatus.CONFLICT, "TASK_CONFIRM_CONFLICT", "任务状态已变化,无法确认。"); + } + writePayloadMutationAudit(task, ACTION_TASK_CONFIRM, taskCard.confirmedPayloadJson(), confirmedPayloadJson, now); + ReservationTaskSnapshot confirmedTask = findTaskOrThrow(taskId); + List operaOperations = + createDefaultOperaOperations(confirmedTask, confirmedPayloadJson, now); + return new ReservationTaskPayloadMutationResult( + confirmedTask.id().toString(), + confirmedTask.orderId().toString(), + confirmedTask.taskStatus(), + jsonPayloadToObject(taskCard.draftPayloadJson()), + jsonPayloadToObject(confirmedPayloadJson), + operaOperations); + } + + /** + * 执行 OPERA 模拟操作。第一版不接真实 OPERA,只根据请求开关生成模拟结果和 attempt。 + */ + @Override + @Transactional + public ReservationOperaOperationResult executeOperaOperation( + Long taskId, + Long operationId, + ReservationOperaSimulationRequest request) { + return executeOperaOperationInternal(taskId, operationId, request, false); + } + + /** + * 重试失败的 OPERA 模拟操作。重试会新增 attempt,不覆盖历史结果。 + */ + @Override + @Transactional + public ReservationOperaOperationResult retryOperaOperation( + Long taskId, + Long operationId, + ReservationOperaSimulationRequest request) { + return executeOperaOperationInternal(taskId, operationId, request, true); + } + + /** + * 查询任务审计流水。第一版只按任务维度返回后端审计列表。 + */ + @Override + @Transactional(readOnly = true) + public ReservationTaskAuditListResult listTaskAudits(Long taskId) { + ReservationTaskSnapshot task = findTaskOrThrow(taskId); + List items = workflowRepository + .findAuditLogsByTaskId(task.hotelId(), task.id()) + .stream() + .map(this::toAuditLogResult) + .toList(); + return new ReservationTaskAuditListResult(task.id().toString(), items); + } + + /** + * 为已确认任务生成第一版固定两条 OPERA 模拟操作。 + */ + private List createDefaultOperaOperations( + ReservationTaskSnapshot task, + String confirmedPayloadJson, + LocalDateTime now) { + List drafts = List.of( + new ReservationOperaOperationDraft( + task.hotelId(), + task.orderId(), + task.id(), + 1, + OPERA_OPERATION_CODE_PRECHECK, + "OPERA 模拟预检查", + ReservationOperaOperationStatus.PENDING.name(), + operaOperationRequestPayload(task, confirmedPayloadJson, OPERA_OPERATION_CODE_PRECHECK), + now), + new ReservationOperaOperationDraft( + task.hotelId(), + task.orderId(), + task.id(), + 2, + OPERA_OPERATION_CODE_WRITE, + "OPERA 模拟写入", + ReservationOperaOperationStatus.PENDING.name(), + operaOperationRequestPayload(task, confirmedPayloadJson, OPERA_OPERATION_CODE_WRITE), + now)); + for (ReservationOperaOperationDraft draft : drafts) { + workflowRepository.insertOperaOperation(draft); + } + return workflowRepository.findOperaOperationsByTaskId(task.hotelId(), task.id()) + .stream() + .map(operation -> toOperaOperationResult(operation, List.of())) + .toList(); + } + + /** + * 执行或重试 OPERA 模拟操作,并记录 attempt 和审计。 + */ + private ReservationOperaOperationResult executeOperaOperationInternal( + Long taskId, + Long operationId, + ReservationOperaSimulationRequest request, + boolean retry) { + ReservationTaskSnapshot task = findTaskOrThrow(taskId); + ReservationOperaOperationSnapshot operation = workflowRepository + .findOperaOperationById(task.hotelId(), operationId) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "OPERA_OPERATION_NOT_FOUND", "OPERA 模拟操作不存在。")); + if (!task.id().equals(operation.taskId())) { + throw error(HttpStatus.NOT_FOUND, "OPERA_OPERATION_NOT_FOUND", "OPERA 模拟操作不存在。"); + } + validateOperaOperationExecutable(task, operation, retry); + + boolean simulateSuccess = request == null || !Boolean.FALSE.equals(request.simulateSuccess()); + String attemptStatus = simulateSuccess + ? ReservationOperaAttemptStatus.SUCCEEDED.name() + : ReservationOperaAttemptStatus.FAILED.name(); + String operationStatus = simulateSuccess + ? ReservationOperaOperationStatus.SUCCEEDED.name() + : ReservationOperaOperationStatus.FAILED.name(); + String errorMessage = simulateSuccess + ? null + : defaultFailureMessage(request == null ? null : request.failureMessage()); + LocalDateTime now = nowUtc(); + Integer attemptNumber = (operation.attemptCount() == null ? 0 : operation.attemptCount()) + 1; + String responsePayloadJson = operaAttemptResponsePayload(operation, attemptNumber, simulateSuccess, errorMessage, now); + Long attemptId = workflowRepository.insertOperaOperationAttempt(new ReservationOperaOperationAttemptDraft( + task.hotelId(), + task.orderId(), + task.id(), + operation.id(), + attemptNumber, + attemptStatus, + operation.requestPayloadJson(), + responsePayloadJson, + errorMessage, + now, + now)); + workflowRepository.updateOperaOperationAfterAttempt( + task.hotelId(), + operation.id(), + operationStatus, + attemptId, + errorMessage, + now); + writeOperaOperationAudit(task, operation, retry, operationStatus, attemptNumber, errorMessage, now); + completeTaskWhenAllOperaOperationsSucceeded(task, operation.id(), operationStatus, now); + + ReservationOperaOperationSnapshot updatedOperation = workflowRepository + .findOperaOperationById(task.hotelId(), operation.id()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "OPERA_OPERATION_NOT_FOUND", "OPERA 模拟操作不存在。")); + List attempts = + workflowRepository.findOperaOperationAttempts(task.hotelId(), operation.id()); + return toOperaOperationResult(updatedOperation, attempts); + } + + /** + * 校验 OPERA 模拟操作执行顺序和重试状态。 + */ + private void validateOperaOperationExecutable( + ReservationTaskSnapshot task, + ReservationOperaOperationSnapshot operation, + boolean retry) { + if (!ReservationTaskStatus.READY.name().equals(task.taskStatus())) { + throw error(HttpStatus.CONFLICT, "TASK_STATUS_NOT_EXECUTABLE", "只有 READY 任务允许执行 OPERA 模拟操作。"); + } + ReservationTaskAvailabilityResult availability = calculateAvailability(task); + if (availability.readOnly() || !availability.executable()) { + throw error(HttpStatus.CONFLICT, "TASK_READ_ONLY", "任务当前只读,不能执行 OPERA 模拟操作。"); + } + if (retry) { + if (!ReservationOperaOperationStatus.FAILED.name().equals(operation.operationStatus())) { + throw error(HttpStatus.CONFLICT, "OPERA_OPERATION_NOT_RETRYABLE", "只有失败的 OPERA 模拟操作允许重试。"); + } + } else if (!ReservationOperaOperationStatus.PENDING.name().equals(operation.operationStatus())) { + throw error(HttpStatus.CONFLICT, "OPERA_OPERATION_NOT_EXECUTABLE", "只有待执行的 OPERA 模拟操作允许执行。"); + } + boolean previousSucceeded = workflowRepository.findOperaOperationsByTaskId(task.hotelId(), task.id()) + .stream() + .filter(item -> item.operationSequence() < operation.operationSequence()) + .allMatch(item -> ReservationOperaOperationStatus.SUCCEEDED.name().equals(item.operationStatus())); + if (!previousSucceeded) { + throw error( + HttpStatus.CONFLICT, + "OPERA_PREVIOUS_OPERATION_NOT_SUCCEEDED", + "前置 OPERA 模拟操作未成功,不能执行当前操作。"); + } + } + + /** + * 所有 OPERA 模拟操作成功后,将任务标记为 COMPLETED。 + */ + private void completeTaskWhenAllOperaOperationsSucceeded( + ReservationTaskSnapshot task, + Long updatedOperationId, + String updatedOperationStatus, + LocalDateTime now) { + boolean allSucceeded = workflowRepository.findOperaOperationsByTaskId(task.hotelId(), task.id()) + .stream() + .map(operation -> operation.id().equals(updatedOperationId) + ? ReservationOperaOperationStatus.SUCCEEDED.name().equals(updatedOperationStatus) + : ReservationOperaOperationStatus.SUCCEEDED.name().equals(operation.operationStatus())) + .allMatch(Boolean::booleanValue); + if (allSucceeded) { + workflowRepository.updateTaskStatus( + task.hotelId(), + task.id(), + ReservationTaskStatus.COMPLETED.name(), + now); + } + } + + /** + * 构建 OPERA 模拟操作请求 JSON。第一版只保存 confirmed payload 的摘要和操作代码。 + */ + private String operaOperationRequestPayload( + ReservationTaskSnapshot task, + String confirmedPayloadJson, + String operationCode) { + Map fieldValues = fieldValuesFromPayloadJson(confirmedPayloadJson); + return toJson(Map.of( + "schema_version", "opera-simulation-v1", + "task_id", task.id().toString(), + "operation_code", operationCode, + "confirmed_payload_schema_version", PAYLOAD_SCHEMA_VERSION, + "confirmed_field_count", fieldValues.size())); + } + + /** + * 构建 OPERA 模拟 attempt 响应 JSON。 + */ + private String operaAttemptResponsePayload( + ReservationOperaOperationSnapshot operation, + Integer attemptNumber, + boolean simulateSuccess, + String errorMessage, + LocalDateTime now) { + Map response = new LinkedHashMap<>(); + response.put("schema_version", "opera-simulation-response-v1"); + response.put("operation_id", operation.id().toString()); + response.put("operation_code", operation.operationCode()); + response.put("attempt_number", attemptNumber); + response.put("success", simulateSuccess); + response.put("simulated_at", now.toString()); + response.put("error_message", errorMessage); + return toJson(response); + } + + /** + * 写入 OPERA 模拟操作执行或重试审计。 + */ + private void writeOperaOperationAudit( + ReservationTaskSnapshot task, + ReservationOperaOperationSnapshot operation, + boolean retry, + String operationStatus, + Integer attemptNumber, + String errorMessage, + LocalDateTime now) { + Map afterSnapshot = new LinkedHashMap<>(); + afterSnapshot.put("operation_status", operationStatus); + afterSnapshot.put("attempt_number", attemptNumber); + afterSnapshot.put("error_message", errorMessage); + workflowRepository.insertAuditLog(new ReservationAuditLogDraft( + task.hotelId(), + task.orderId(), + task.id(), + operation.id(), + ACTOR_TYPE_USER, + ACTOR_ID_LOCAL, + retry ? ACTION_OPERA_OPERATION_RETRY : ACTION_OPERA_OPERATION_EXECUTE, + errorMessage, + toJson(Map.of( + "operation_status", operation.operationStatus(), + "attempt_count", operation.attemptCount())), + toJson(afterSnapshot), + now)); + } + + /** + * 转换 OPERA 模拟操作快照为响应。 + */ + private ReservationOperaOperationResult toOperaOperationResult( + ReservationOperaOperationSnapshot operation, + List attempts) { + return new ReservationOperaOperationResult( + operation.id().toString(), + operation.taskId().toString(), + operation.operationSequence(), + operation.operationCode(), + operation.operationName(), + operation.operationStatus(), + operation.attemptCount(), + operation.lastErrorMessage(), + attempts.stream().map(this::toOperaOperationAttemptResult).toList()); + } + + /** + * 查询任务下 OPERA 模拟操作及 attempt 历史,用于任务详情页一次性展示执行轨迹。 + */ + private List findOperaOperationResults(ReservationTaskSnapshot task) { + return workflowRepository.findOperaOperationsByTaskId(task.hotelId(), task.id()) + .stream() + .map(operation -> toOperaOperationResult( + operation, + workflowRepository.findOperaOperationAttempts(task.hotelId(), operation.id()))) + .toList(); + } + + /** + * 转换 OPERA 模拟操作 attempt 快照为响应。 + */ + private ReservationOperaOperationAttemptResult toOperaOperationAttemptResult( + ReservationOperaOperationAttemptSnapshot attempt) { + return new ReservationOperaOperationAttemptResult( + attempt.id().toString(), + attempt.attemptNumber(), + attempt.attemptStatus(), + jsonPayloadToObject(attempt.responsePayloadJson()), + attempt.errorMessage(), + attempt.startedAt().toString(), + attempt.finishedAt().toString()); + } + + /** + * 转换任务审计快照为响应。 + */ + private ReservationTaskAuditLogResult toAuditLogResult(ReservationAuditLogSnapshot snapshot) { + return new ReservationTaskAuditLogResult( + snapshot.id().toString(), + snapshot.orderId() == null ? null : snapshot.orderId().toString(), + snapshot.taskId() == null ? null : snapshot.taskId().toString(), + snapshot.operationId() == null ? null : snapshot.operationId().toString(), + snapshot.actorType(), + snapshot.actorId(), + snapshot.action(), + snapshot.reason(), + jsonPayloadToObject(snapshot.beforeSnapshotJson()), + jsonPayloadToObject(snapshot.afterSnapshotJson()), + snapshot.occurredAt().toString()); + } + + /** + * 默认模拟失败原因,避免空错误影响前端展示。 + */ + private String defaultFailureMessage(String rawFailureMessage) { + String failureMessage = trimToNull(rawFailureMessage); + return failureMessage == null ? "OPERA 模拟失败。" : failureMessage; + } + /** * 更新人工复核任务;迁移到其他订单时,如果并发撞到队列序号则重新取号重试。 */ @@ -152,6 +607,613 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_NOT_FOUND", "任务不存在。")); } + /** + * 查询任务卡,不存在时抛出受控 404。 + */ + private ReservationTaskCardSnapshot findTaskCardOrThrow(ReservationTaskSnapshot task) { + return workflowRepository.findTaskCardByTaskId(task.hotelId(), task.id()) + .orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。")); + } + + /** + * 校验任务当前是否允许人工编辑或最终确认。 + */ + private void ensureTaskEditable(ReservationTaskSnapshot task) { + if (!ReservationTaskStatus.PENDING_CONFIRM.name().equals(task.taskStatus())) { + throw error(HttpStatus.CONFLICT, "TASK_STATUS_NOT_EDITABLE", "只有 PENDING_CONFIRM 任务允许编辑或确认。"); + } + ReservationTaskAvailabilityResult availability = calculateAvailability(task); + if (availability.readOnly() || !availability.editable() || !availability.confirmable()) { + throw error(HttpStatus.CONFLICT, "TASK_READ_ONLY", "任务当前只读,不能编辑或确认。"); + } + } + + /** + * 标准化请求中的 field_values。空请求按空草稿处理。 + */ + private Map normalizeFieldValues(Map rawFieldValues) { + Map normalized = new LinkedHashMap<>(); + if (rawFieldValues == null) { + return normalized; + } + rawFieldValues.forEach((fieldPath, value) -> normalized.put(fieldPath == null ? "" : fieldPath.trim(), value)); + return normalized; + } + + /** + * 合并已有草稿和本次提交字段。本次提交优先,用于草稿增量保存和最终确认。 + */ + private Map mergeFieldValues( + Map existingValues, + Map submittedValues) { + Map merged = new LinkedHashMap<>(existingValues); + merged.putAll(submittedValues); + return merged; + } + + /** + * 根据当前任务类型、subtype 和展示条件筛出本次需要参与后端校验的字段。 + */ + private List activeDefinitions( + ReservationTaskSnapshot task, + JsonNode aiPayload, + Map editedValues) { + return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()) + .stream() + .filter(definition -> isYes(definition.visible())) + .filter(definition -> matchesMatrixExpression(definition.resultType(), task.resultType())) + .filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType())) + .filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype())) + .filter(definition -> displayConditionMatches(task, definition, aiPayload, editedValues)) + .toList(); + } + + /** + * 校验本次提交的字段是否属于当前任务卡且允许编辑。 + */ + private List validateSubmittedFields( + List activeDefinitions, + Map submittedValues) { + Map definitionByPath = new LinkedHashMap<>(); + activeDefinitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition)); + List errors = new ArrayList<>(); + for (String fieldPath : submittedValues.keySet()) { + ReservationTaskCardFieldDefinition definition = definitionByPath.get(fieldPath); + if (definition == null) { + errors.add(fieldPath + ": 不属于当前任务卡、task_subtype 或展示条件。"); + continue; + } + if (!isYes(definition.editable())) { + errors.add(fieldPath + ": 当前字段为只读,不允许人工修改。"); + } + } + return errors; + } + + /** + * 按字段矩阵执行必填、枚举、日期和数字规则校验。 + */ + private List validateDefinitionValues( + ReservationTaskSnapshot task, + List activeDefinitions, + JsonNode aiPayload, + Map editedValues, + Set fieldPathsToValidate, + boolean validateRequired) { + List errors = new ArrayList<>(); + for (ReservationTaskCardFieldDefinition definition : activeDefinitions) { + Object value = valueForField(aiPayload, editedValues, definition.fieldPath()); + if (validateRequired && isMissingRequired(task, definition, aiPayload, editedValues, value)) { + errors.add(definition.fieldPath() + ": 必填字段缺失。"); + continue; + } + if (!fieldPathsToValidate.contains(definition.fieldPath()) || isEmptyValue(value)) { + continue; + } + errors.addAll(validateEnumOptions(definition, value)); + errors.addAll(validateBasicRule(definition, value)); + } + return errors; + } + + /** + * 生成字段定义路径集合,用于最终确认时校验全部展示字段。 + */ + private Set definitionFieldPaths(List activeDefinitions) { + Set fieldPaths = new LinkedHashSet<>(); + activeDefinitions.forEach(definition -> fieldPaths.add(definition.fieldPath())); + return fieldPaths; + } + + /** + * 校验入住日期和离店日期的相对顺序。 + */ + private List validateDateRange( + List activeDefinitions, + JsonNode aiPayload, + Map editedValues) { + if (!hasDefinition(activeDefinitions, "extracted_fields.arrival_date") + || !hasDefinition(activeDefinitions, "extracted_fields.departure_date")) { + return List.of(); + } + Object arrivalValue = valueForField(aiPayload, editedValues, "extracted_fields.arrival_date"); + Object departureValue = valueForField(aiPayload, editedValues, "extracted_fields.departure_date"); + if (isEmptyValue(arrivalValue) || isEmptyValue(departureValue)) { + return List.of(); + } + try { + LocalDate arrivalDate = parseLocalDate(arrivalValue); + LocalDate departureDate = parseLocalDate(departureValue); + if (!arrivalDate.isBefore(departureDate)) { + return List.of("extracted_fields.arrival_date: 入住日期必须早于离店日期。"); + } + } catch (DateTimeParseException exception) { + return List.of(); + } + return List.of(); + } + + /** + * 判断定义列表里是否包含指定字段。 + */ + private boolean hasDefinition(List definitions, String fieldPath) { + return definitions.stream().anyMatch(definition -> fieldPath.equals(definition.fieldPath())); + } + + /** + * 组装最终确认 payload 的 field_values。用户值优先,缺省时回落到 AI 原始快照。 + */ + private Map buildConfirmedFieldValues( + List activeDefinitions, + JsonNode aiPayload, + Map editedValues) { + Map confirmedValues = new LinkedHashMap<>(); + for (ReservationTaskCardFieldDefinition definition : activeDefinitions) { + confirmedValues.put(definition.fieldPath(), valueForField(aiPayload, editedValues, definition.fieldPath())); + } + return confirmedValues; + } + + /** + * 从草稿或确认 payload 中读取 field_values。 + */ + private Map fieldValuesFromPayloadJson(String payloadJson) { + Map fieldValues = new LinkedHashMap<>(); + if (trimToNull(payloadJson) == null) { + return fieldValues; + } + JsonNode fieldValuesNode = parseJson(payloadJson).path("field_values"); + if (!fieldValuesNode.isObject()) { + return fieldValues; + } + fieldValuesNode.fields().forEachRemaining(entry -> + fieldValues.put(entry.getKey(), objectMapper.convertValue(entry.getValue(), Object.class))); + return fieldValues; + } + + /** + * 生成第一版任务卡 payload JSON。当前按 field_path 保存,后续 OPERA 转换层再消费。 + */ + private String toPayloadJson(String generatedFrom, Map fieldValues, LocalDateTime now) { + Map payload = new LinkedHashMap<>(); + payload.put("schema_version", PAYLOAD_SCHEMA_VERSION); + payload.put("generated_from", generatedFrom); + payload.put("generated_at", now.toString()); + payload.put("field_values", new LinkedHashMap<>(fieldValues)); + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception exception) { + throw error(HttpStatus.INTERNAL_SERVER_ERROR, "TASK_PAYLOAD_JSON_INVALID", "任务卡 payload 生成失败。"); + } + } + + /** + * 将已保存 payload JSON 转为响应对象。空 payload 返回 null,避免前端误认为已有内容。 + */ + private Object jsonPayloadToObject(String payloadJson) { + if (trimToNull(payloadJson) == null) { + return null; + } + try { + return objectMapper.readValue(payloadJson, Object.class); + } catch (Exception exception) { + throw error(HttpStatus.INTERNAL_SERVER_ERROR, "TASK_PAYLOAD_JSON_INVALID", "任务卡 payload JSON 无效。"); + } + } + + /** + * 写入草稿保存或最终确认审计。审计只记录 payload 摘要,不保存完整客户字段。 + */ + private void writePayloadMutationAudit( + ReservationTaskSnapshot task, + String action, + String beforePayloadJson, + String afterPayloadJson, + LocalDateTime now) { + workflowRepository.insertAuditLog(new ReservationAuditLogDraft( + task.hotelId(), + task.orderId(), + task.id(), + null, + ACTOR_TYPE_USER, + ACTOR_ID_LOCAL, + action, + null, + payloadAuditSnapshotJson(beforePayloadJson), + payloadAuditSnapshotJson(afterPayloadJson), + now)); + } + + /** + * 生成 payload 审计摘要,避免审计表保存完整用户字段值。 + */ + private String payloadAuditSnapshotJson(String payloadJson) { + Map snapshot = new LinkedHashMap<>(); + Map fieldValues = fieldValuesFromPayloadJson(payloadJson); + snapshot.put("has_payload", trimToNull(payloadJson) != null); + snapshot.put("schema_version", trimToNull(payloadJson) == null ? null : PAYLOAD_SCHEMA_VERSION); + snapshot.put("field_count", fieldValues.size()); + return toJson(snapshot); + } + + /** + * 判断矩阵中的“是/否”开关。 + */ + private boolean isYes(String value) { + return "是".equals(trimToNull(value)); + } + + /** + * 判断矩阵枚举式文本是否匹配当前任务值,支持斜杠分隔的多个 subtype。 + */ + private boolean matchesMatrixExpression(String matrixValue, String actualValue) { + String normalizedMatrixValue = trimToNull(matrixValue); + if (normalizedMatrixValue == null || "-".equals(normalizedMatrixValue)) { + return true; + } + String normalizedActualValue = trimToNull(actualValue); + if (normalizedActualValue == null) { + return false; + } + String[] candidates = normalizedMatrixValue + .replace("/", "/") + .replace(",", "/") + .replace(",", "/") + .replace("、", "/") + .replace("\n", "/") + .split("/"); + for (String candidate : candidates) { + String normalizedCandidate = trimToNull(candidate); + if (normalizedCandidate == null) { + continue; + } + if (normalizedCandidate.equals(normalizedActualValue) + || normalizedCandidate.contains(normalizedActualValue)) { + return true; + } + } + return false; + } + + /** + * 按当前已知矩阵展示条件判断字段是否参与本次后端校验。 + */ + private boolean displayConditionMatches( + ReservationTaskSnapshot task, + ReservationTaskCardFieldDefinition definition, + JsonNode aiPayload, + Map editedValues) { + String condition = trimToNull(definition.displayCondition()); + if (condition == null || "-".equals(condition) || condition.contains("始终展示")) { + return true; + } + Object currentValue = valueForField(aiPayload, editedValues, definition.fieldPath()); + if (condition.contains("有值时展示")) { + return !isEmptyValue(currentValue); + } + if (condition.contains("有附件")) { + return !isEmptyValue(valueForField(aiPayload, editedValues, "attachments")); + } + if (condition.contains("对象为FIT Reservation")) { + return isFitTarget(task, aiPayload, editedValues); + } + if (condition.contains("对象为Group Block或Allotment")) { + return isGroupOrAllotmentTarget(task, aiPayload, editedValues); + } + if (condition.contains("涉及房型")) { + return ReservationSystemTaskType.NEW_BOOKING.name().equals(task.systemTaskType()) + || !isEmptyValue(valueForField(aiPayload, editedValues, "extracted_fields.room_type")) + || !isEmptyValue(valueForField(aiPayload, editedValues, "extracted_fields.pms_room_type_code")); + } + if (condition.contains("涉及Rate Code") || condition.contains("涉及价格/Rate Code")) { + return !isEmptyValue(valueForField(aiPayload, editedValues, "extracted_fields.rate_code_result")) + || subtypeContains(task, "manual_rate_code_or_settlement_price"); + } + if (condition.contains("需要价格确认")) { + return !isEmptyValue(valueForField(aiPayload, editedValues, + "extracted_fields.rate_code_result.settlement_price")); + } + if (condition.contains("有复合单价") || condition.contains("fix_charge_required=true")) { + return isBooleanTrue(valueForField(aiPayload, editedValues, "extracted_fields.fix_charge_required")) + || !isEmptyValue(valueForField(aiPayload, editedValues, "extracted_fields.fix_charge_items[]")); + } + if (condition.contains("allocation_split_from_parent=true")) { + return isBooleanTrue(valueForField(aiPayload, editedValues, + "extracted_fields.allocation_split_from_parent")); + } + if (condition.contains("来自QBD/LianTai表格")) { + return !isEmptyValue(valueForField(aiPayload, editedValues, "extracted_fields.table_evidence")); + } + if (condition.contains("有Group目标")) { + return !isEmptyValue(valueForField(aiPayload, editedValues, "case_keys.group_code")) + || isGroupOrAllotmentTarget(task, aiPayload, editedValues); + } + if (condition.contains("有FIT目标") || condition.contains("FIT取消")) { + return !isEmptyValue(valueForField(aiPayload, editedValues, "case_keys.confirmation_number")) + || isFitTarget(task, aiPayload, editedValues); + } + if (condition.contains("有before/after修改")) { + return !isEmptyValue(valueForField(aiPayload, editedValues, "extracted_fields.before_after")); + } + if (condition.contains("update_fix_charge")) { + return subtypeContains(task, "update_fix_charge") + || isBooleanTrue(valueForField(aiPayload, editedValues, "extracted_fields.fix_charge_required")); + } + return true; + } + + /** + * 判断字段是否缺失必填值,并处理“可使用另一个业务号”和“New Booking 后续生成订单号”的例外。 + */ + private boolean isMissingRequired( + ReservationTaskSnapshot task, + ReservationTaskCardFieldDefinition definition, + JsonNode aiPayload, + Map editedValues, + Object value) { + if (!isRequired(definition)) { + return false; + } + if (!isEmptyValue(value)) { + return false; + } + if ("case_keys.group_code".equals(definition.fieldPath()) + && !isEmptyValue(valueForField(aiPayload, editedValues, "case_keys.confirmation_number"))) { + return false; + } + if ("case_keys.confirmation_number".equals(definition.fieldPath())) { + if (!isEmptyValue(valueForField(aiPayload, editedValues, "case_keys.group_code"))) { + return false; + } + if (ReservationSystemTaskType.NEW_BOOKING.name().equals(task.systemTaskType()) + && containsText(definition.validationRule(), "系统生成")) { + return false; + } + } + return true; + } + + /** + * 判断矩阵必填规则。条件必填是否生效已通过展示条件筛选决定。 + */ + private boolean isRequired(ReservationTaskCardFieldDefinition definition) { + String requiredRule = trimToNull(definition.requiredRule()); + if (requiredRule == null || "否".equals(requiredRule)) { + return false; + } + return "是".equals(requiredRule) + || requiredRule.contains("条件必填") + || requiredRule.contains("表格来源必填"); + } + + /** + * 校验字段枚举值。对象型字段和 key=value 复合枚举先不做强校验。 + */ + private List validateEnumOptions(ReservationTaskCardFieldDefinition definition, Object value) { + String enumOptions = trimToNull(definition.enumOptions()); + if (enumOptions == null || "-".equals(enumOptions) || enumOptions.contains("=") || value instanceof Map) { + return List.of(); + } + Set allowedOptions = parseEnumOptions(enumOptions); + if (allowedOptions.isEmpty()) { + return List.of(); + } + List errors = new ArrayList<>(); + if (value instanceof Iterable iterableValue) { + for (Object item : iterableValue) { + if (!allowedOptions.contains(String.valueOf(item))) { + errors.add(definition.fieldPath() + ": 枚举值不在矩阵允许范围内。"); + break; + } + } + return errors; + } + if (!allowedOptions.contains(String.valueOf(value))) { + errors.add(definition.fieldPath() + ": 枚举值不在矩阵允许范围内。"); + } + return errors; + } + + /** + * 解析矩阵枚举选项,兼容斜杠、分号、逗号和中文括号说明。 + */ + private Set parseEnumOptions(String enumOptions) { + Set allowedOptions = new LinkedHashSet<>(); + String[] tokens = enumOptions + .replace("/", "/") + .replace(";", "/") + .replace(";", "/") + .replace(",", "/") + .replace(",", "/") + .replace("\n", "/") + .split("/"); + for (String token : tokens) { + String normalizedToken = trimToNull(token); + if (normalizedToken == null) { + continue; + } + int chineseNoteIndex = normalizedToken.indexOf('('); + int englishNoteIndex = normalizedToken.indexOf('('); + int noteIndex = chineseNoteIndex >= 0 ? chineseNoteIndex : englishNoteIndex; + if (noteIndex >= 0) { + normalizedToken = trimToNull(normalizedToken.substring(0, noteIndex)); + } + if (normalizedToken != null) { + allowedOptions.add(normalizedToken); + } + } + return allowedOptions; + } + + /** + * 校验矩阵中的基础日期、数字和正整数规则。 + */ + private List validateBasicRule(ReservationTaskCardFieldDefinition definition, Object value) { + List errors = new ArrayList<>(); + String validationRule = trimToNull(definition.validationRule()); + String fieldPath = definition.fieldPath(); + if (isDateField(definition)) { + try { + parseLocalDate(value); + } catch (DateTimeParseException exception) { + errors.add(fieldPath + ": 日期格式必须为 yyyy-MM-dd。"); + } + } + if (isNumberField(definition)) { + BigDecimal numberValue = parseBigDecimal(value); + if (numberValue == null) { + errors.add(fieldPath + ": 必须是数字。"); + } else if (containsText(validationRule, "正整数") + && (numberValue.scale() > 0 || numberValue.compareTo(BigDecimal.ZERO) <= 0)) { + errors.add(fieldPath + ": 必须是正整数。"); + } else if (containsText(validationRule, "非负") && numberValue.compareTo(BigDecimal.ZERO) < 0) { + errors.add(fieldPath + ": 必须是非负数。"); + } + } + return errors; + } + + /** + * 判断字段是否是日期型字段。 + */ + private boolean isDateField(ReservationTaskCardFieldDefinition definition) { + return isYes(definition.datePicker()) + || containsText(definition.validationRule(), "日期") + || definition.fieldPath().endsWith("_date"); + } + + /** + * 判断字段是否是数字型字段。 + */ + private boolean isNumberField(ReservationTaskCardFieldDefinition definition) { + return isYes(definition.numberInput()) + || containsText(definition.validationRule(), "正整数") + || containsText(definition.validationRule(), "非负数"); + } + + /** + * 从用户编辑值或 AI 原始快照中读取字段值,用户编辑值优先。 + */ + private Object valueForField(JsonNode aiPayload, Map editedValues, String fieldPath) { + if (editedValues.containsKey(fieldPath)) { + return editedValues.get(fieldPath); + } + return valueAt(aiPayload, fieldPath); + } + + /** + * 判断值是否为空。false 和 0 是有效值,不当作空。 + */ + private boolean isEmptyValue(Object value) { + if (value == null) { + return true; + } + if (value instanceof String stringValue) { + return stringValue.trim().isEmpty(); + } + if (value instanceof Iterable iterableValue) { + return !iterableValue.iterator().hasNext(); + } + if (value instanceof Map mapValue) { + return mapValue.isEmpty(); + } + return false; + } + + /** + * 判断值是否是布尔 true。 + */ + private boolean isBooleanTrue(Object value) { + if (value instanceof Boolean booleanValue) { + return booleanValue; + } + if (value instanceof String stringValue) { + return "true".equalsIgnoreCase(stringValue.trim()); + } + return false; + } + + /** + * 判断当前任务是否是 FIT 目标。 + */ + private boolean isFitTarget( + ReservationTaskSnapshot task, + JsonNode aiPayload, + Map editedValues) { + Object bookingObjectType = valueForField(aiPayload, editedValues, "extracted_fields.booking_object_type"); + return "FIT Reservation".equals(String.valueOf(bookingObjectType)) || subtypeContains(task, "fit"); + } + + /** + * 判断当前任务是否是 Group 或 Allotment 目标。 + */ + private boolean isGroupOrAllotmentTarget( + ReservationTaskSnapshot task, + JsonNode aiPayload, + Map editedValues) { + Object bookingObjectType = valueForField(aiPayload, editedValues, "extracted_fields.booking_object_type"); + String objectTypeText = String.valueOf(bookingObjectType); + return objectTypeText.contains("Group") + || objectTypeText.contains("Allotment") + || objectTypeText.contains("Control") + || subtypeContains(task, "group") + || subtypeContains(task, "allotment") + || subtypeContains(task, "control"); + } + + /** + * 判断 task_subtype 是否包含指定片段。 + */ + private boolean subtypeContains(ReservationTaskSnapshot task, String keyword) { + String taskSubtype = trimToNull(task.taskSubtype()); + return taskSubtype != null && taskSubtype.contains(keyword); + } + + /** + * 解析日期值。 + */ + private LocalDate parseLocalDate(Object value) { + return LocalDate.parse(String.valueOf(value)); + } + + /** + * 解析数字值;无法解析时返回 null,让调用方生成字段错误。 + */ + private BigDecimal parseBigDecimal(Object value) { + try { + return new BigDecimal(String.valueOf(value)); + } catch (NumberFormatException exception) { + return null; + } + } + + /** + * 判断文本中是否包含片段,兼容空值。 + */ + private boolean containsText(String text, String keyword) { + return text != null && text.contains(keyword); + } + /** * 计算任务可处理状态。FAILED 和 COMPLETED 视为前置任务结束,不阻塞后续任务。 */ @@ -470,6 +1532,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl return new ReservationTaskWorkflowException(status, errorCode, message); } + /** + * 构建字段矩阵校验失败异常,details 只包含字段路径和规则原因。 + */ + private ReservationTaskWorkflowException validationError(List details) { + return new ReservationTaskWorkflowException( + HttpStatus.BAD_REQUEST, + "TASK_FIELD_VALIDATION_FAILED", + "任务字段校验失败。", + details); + } + /** * 人工转换目标类型映射。 */ diff --git a/server/src/main/resources/db/migration/V5__add_reservation_task_draft_and_confirmation.sql b/server/src/main/resources/db/migration/V5__add_reservation_task_draft_and_confirmation.sql new file mode 100644 index 0000000..50eda54 --- /dev/null +++ b/server/src/main/resources/db/migration/V5__add_reservation_task_draft_and_confirmation.sql @@ -0,0 +1,15 @@ +-- M002 CP5:任务草稿和最终确认 payload。第一版按矩阵 field_path 保存,不直接生成 OPERA 参数。 +ALTER TABLE workflow_reservation_task_card + ADD COLUMN draft_payload_json LONGTEXT NULL COMMENT '用户保存的任务卡草稿 JSON,第一版按矩阵 field_path 作为 key 保存'; + +ALTER TABLE workflow_reservation_task_card + ADD COLUMN confirmed_payload_json LONGTEXT NULL COMMENT '用户最终确认后的任务卡 JSON,第一版按矩阵 field_path 合并保存,后续 OPERA 参数组装时再转换'; + +ALTER TABLE workflow_reservation_task_card + ADD COLUMN confirmed_by VARCHAR(128) NULL COMMENT '确认人标识,用户身份权限接入前使用本地占位'; + +ALTER TABLE workflow_reservation_task_card + ADD COLUMN confirmed_at DATETIME(6) NULL COMMENT '任务卡最终确认 UTC 时间'; + +ALTER TABLE workflow_reservation_task + ADD COLUMN confirmed_at DATETIME(6) NULL COMMENT '任务最终确认 UTC 时间'; diff --git a/server/src/main/resources/db/migration/V6__create_reservation_opera_simulation_tables.sql b/server/src/main/resources/db/migration/V6__create_reservation_opera_simulation_tables.sql new file mode 100644 index 0000000..b217ce7 --- /dev/null +++ b/server/src/main/resources/db/migration/V6__create_reservation_opera_simulation_tables.sql @@ -0,0 +1,42 @@ +-- M002 OPERA 模拟骨架:每个已确认任务固定生成两条模拟操作。 +CREATE TABLE workflow_reservation_opera_operation ( + id BIGINT NOT NULL COMMENT 'OPERA 模拟操作 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + order_id BIGINT NOT NULL COMMENT '关联订单 ID', + task_id BIGINT NOT NULL COMMENT '关联任务 ID', + operation_sequence INT NOT NULL COMMENT '任务内模拟操作顺序,从 1 开始', + operation_code VARCHAR(64) NOT NULL COMMENT '模拟操作代码,第一版固定两类', + operation_name VARCHAR(128) NOT NULL COMMENT '模拟操作展示名称', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:PENDING、SUCCEEDED、FAILED', + request_payload_json LONGTEXT NULL COMMENT '模拟请求 JSON,来源于 confirmed_payload_json 的低耦合摘要', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已执行 attempt 次数', + last_attempt_id BIGINT NULL COMMENT '最近一次 attempt ID', + last_error_message VARCHAR(512) NULL COMMENT '最近一次失败原因摘要', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_opera_operation_sequence (hotel_id, task_id, operation_sequence), + KEY idx_reservation_opera_operation_task_status (hotel_id, task_id, operation_status), + KEY idx_reservation_opera_operation_order (hotel_id, order_id, operation_sequence) +) COMMENT='Reservation OPERA 模拟操作表,第一版每个任务固定两条操作'; + +-- M002 OPERA 模拟骨架:每次执行或重试都会生成一条 attempt。 +CREATE TABLE workflow_reservation_opera_operation_attempt ( + id BIGINT NOT NULL COMMENT 'OPERA 模拟操作 attempt ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + order_id BIGINT NOT NULL COMMENT '关联订单 ID', + task_id BIGINT NOT NULL COMMENT '关联任务 ID', + operation_id BIGINT NOT NULL COMMENT '关联 OPERA 模拟操作 ID', + attempt_number INT NOT NULL COMMENT '同一操作内第几次 attempt,从 1 开始', + attempt_status VARCHAR(32) NOT NULL COMMENT 'attempt 状态:SUCCEEDED、FAILED', + request_payload_json LONGTEXT NULL COMMENT '本次模拟请求 JSON', + response_payload_json LONGTEXT NULL COMMENT '本次模拟响应 JSON', + error_message VARCHAR(512) NULL COMMENT '失败原因摘要', + started_at DATETIME(6) NOT NULL COMMENT 'attempt 开始 UTC 时间', + finished_at DATETIME(6) NOT NULL COMMENT 'attempt 结束 UTC 时间', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_opera_attempt_number (hotel_id, operation_id, attempt_number), + KEY idx_reservation_opera_attempt_task (hotel_id, task_id, created_at), + KEY idx_reservation_opera_attempt_operation (hotel_id, operation_id, attempt_number) +) COMMENT='Reservation OPERA 模拟操作 attempt 表,记录执行和重试结果'; diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 744f48b..f62ab34 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -494,6 +495,368 @@ class SuperAgentTaskResultControllerTest { .value(org.hamcrest.Matchers.contains("CNF-MATRIX-001"))); } + @Test + void shouldSaveDraftPayloadForEditableTaskWithoutChangingAiSnapshot() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-draft-save-001"); + String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-DRAFT-001"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-draft-save-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.room_quantity": 3, + "extracted_fields.pms_room_type_code": "RM3" + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(taskId)) + .andExpect(jsonPath("$.task_status").value("PENDING_CONFIRM")) + .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_quantity']").value(3)) + .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.pms_room_type_code']").value("RM3")); + + String draftPayloadJson = jdbcTemplate.queryForObject(""" + SELECT draft_payload_json + FROM workflow_reservation_task_card + WHERE task_id = ? + """, String.class, Long.valueOf(taskId)); + String aiPayloadJson = jdbcTemplate.queryForObject(""" + SELECT ai_payload_json + FROM workflow_reservation_task_card + WHERE task_id = ? + """, String.class, Long.valueOf(taskId)); + assertThat(draftPayloadJson).contains("\"extracted_fields.room_quantity\":3"); + assertThat(aiPayloadJson).contains("\"room_quantity\":2"); + } + + @Test + void shouldConfirmTaskWithMergedPayloadAndMoveToReady() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-confirm-ready-001"); + String body = completeNewBookingBody(source.inboxId().toString(), null); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-confirm-ready-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.room_quantity": 4, + "extracted_fields.pms_room_type_code": "RM4" + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(taskId)) + .andExpect(jsonPath("$.task_status").value("READY")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_quantity']").value(4)) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']").value("RM4")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.arrival_date']").value("2026-08-01")) + .andExpect(jsonPath("$.opera_operations.length()").value(2)) + .andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1)) + .andExpect(jsonPath("$.opera_operations[0].operation_status").value("PENDING")) + .andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2)) + .andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING")); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.opera_operations.length()").value(2)) + .andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1)) + .andExpect(jsonPath("$.opera_operations[0].operation_status").value("PENDING")) + .andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2)) + .andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING")); + + String taskStatus = jdbcTemplate.queryForObject(""" + SELECT task_status + FROM workflow_reservation_task + WHERE id = ? + """, String.class, Long.valueOf(taskId)); + Long confirmedAtCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE id = ? + AND confirmed_at IS NOT NULL + """, Long.class, Long.valueOf(taskId)); + String confirmedPayloadJson = jdbcTemplate.queryForObject(""" + SELECT confirmed_payload_json + FROM workflow_reservation_task_card + WHERE task_id = ? + """, String.class, Long.valueOf(taskId)); + Long auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_audit_log + WHERE task_id = ? + AND action = 'TASK_CONFIRM' + """, Long.class, Long.valueOf(taskId)); + assertThat(taskStatus).isEqualTo("READY"); + assertThat(confirmedAtCount).isEqualTo(1L); + assertThat(confirmedPayloadJson).contains("\"extracted_fields.room_quantity\":4"); + assertThat(auditCount).isEqualTo(1L); + } + + @Test + void shouldExecuteFirstOperaSimulationOperationAndRecordAttempt() throws Exception { + String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations( + "mail-cp5-opera-execute-001", + "nonce-cp5-opera-execute-001", + "CNF-CP5-OPERA-EXEC-001"); + String taskId = taskAndOperationIds[0]; + String firstOperationId = taskAndOperationIds[1]; + + mockMvc.perform(post( + "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute", + taskId, + firstOperationId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "simulate_success": true + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.operation_id").value(firstOperationId)) + .andExpect(jsonPath("$.operation_status").value("SUCCEEDED")) + .andExpect(jsonPath("$.attempt_count").value(1)) + .andExpect(jsonPath("$.attempts[0].attempt_number").value(1)) + .andExpect(jsonPath("$.attempts[0].attempt_status").value("SUCCEEDED")); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.opera_operations[0].operation_id").value(firstOperationId)) + .andExpect(jsonPath("$.opera_operations[0].operation_status").value("SUCCEEDED")) + .andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1)) + .andExpect(jsonPath("$.opera_operations[0].attempts[0].attempt_status").value("SUCCEEDED")); + + Long attemptCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_opera_operation_attempt + WHERE operation_id = ? + AND attempt_status = 'SUCCEEDED' + """, Long.class, Long.valueOf(firstOperationId)); + Long auditCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_audit_log + WHERE task_id = ? + AND action = 'OPERA_OPERATION_EXECUTE' + """, Long.class, Long.valueOf(taskId)); + assertThat(attemptCount).isEqualTo(1L); + assertThat(auditCount).isEqualTo(1L); + } + + @Test + void shouldRetryFailedOperaSimulationOperationAndRecordSecondAttempt() throws Exception { + String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations( + "mail-cp5-opera-retry-001", + "nonce-cp5-opera-retry-001", + "CNF-CP5-OPERA-RETRY-001"); + String taskId = taskAndOperationIds[0]; + String firstOperationId = taskAndOperationIds[1]; + + mockMvc.perform(post( + "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute", + taskId, + firstOperationId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "simulate_success": false, + "failure_message": "模拟失败" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.operation_status").value("FAILED")) + .andExpect(jsonPath("$.attempt_count").value(1)) + .andExpect(jsonPath("$.attempts[0].attempt_status").value("FAILED")); + + mockMvc.perform(post( + "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry", + taskId, + firstOperationId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "simulate_success": true + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.operation_status").value("SUCCEEDED")) + .andExpect(jsonPath("$.attempt_count").value(2)) + .andExpect(jsonPath("$.attempts[1].attempt_number").value(2)) + .andExpect(jsonPath("$.attempts[1].attempt_status").value("SUCCEEDED")); + } + + @Test + void shouldRejectExecutingSecondOperaOperationBeforePreviousSucceeded() throws Exception { + String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations( + "mail-cp5-opera-order-001", + "nonce-cp5-opera-order-001", + "CNF-CP5-OPERA-ORDER-001"); + String taskId = taskAndOperationIds[0]; + String secondOperationId = taskAndOperationIds[2]; + + mockMvc.perform(post( + "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute", + taskId, + secondOperationId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "simulate_success": true + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("OPERA_PREVIOUS_OPERATION_NOT_SUCCEEDED")); + } + + @Test + void shouldListTaskAuditLogsForConfirmationAndOperaExecution() throws Exception { + String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations( + "mail-cp5-audit-list-001", + "nonce-cp5-audit-list-001", + "CNF-CP5-AUDIT-001"); + String taskId = taskAndOperationIds[0]; + String firstOperationId = taskAndOperationIds[1]; + + mockMvc.perform(post( + "/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute", + taskId, + firstOperationId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "simulate_success": true + } + """)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}/audits", taskId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_id").value(taskId)) + .andExpect(jsonPath("$.items[?(@.action=='TASK_CONFIRM')].action") + .value(org.hamcrest.Matchers.contains("TASK_CONFIRM"))) + .andExpect(jsonPath("$.items[?(@.action=='OPERA_OPERATION_EXECUTE')].action") + .value(org.hamcrest.Matchers.contains("OPERA_OPERATION_EXECUTE"))); + } + + @Test + void shouldRejectConfirmWhenRequiredMatrixFieldMissing() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-required-missing-001"); + String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": {}, + "extracted_fields": { + "booking_object_type": "FIT Reservation" + } + """); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-required-missing-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": {} + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.arrival_date"))); + } + + @Test + void shouldRejectConfirmWhenEnumValueIsOutsideMatrixOptions() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-enum-invalid-001"); + String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-ENUM-001"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-enum-invalid-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.pms_room_type_code": "BAD_ROOM" + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.pms_room_type_code"))); + } + + @Test + void shouldRejectConfirmWhenDateRuleIsInvalid() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-date-invalid-001"); + String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-DATE-001"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-date-invalid-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.arrival_date": "not-a-date" + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.arrival_date"))); + } + + @Test + void shouldRejectDraftSaveWhenPreviousQueueTaskBlocksCurrentTask() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-draft-blocked-001"); + String body = twoTaskBody(source.inboxId().toString(), "GRP-CP5-BLOCKED-001"); + MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-draft-blocked-001")) + .andExpect(status().isCreated()) + .andReturn(); + String secondTaskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[1].task_id" + ); + + mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", secondTaskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.update_subtypes[]": ["update_stay_dates"] + } + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("TASK_READ_ONLY")); + } + @Test void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception { SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001"); @@ -809,6 +1172,58 @@ class SuperAgentTaskResultControllerTest { """.formatted(sourceMessageId, groupCode, groupCode); } + private String completeNewBookingBody(String sourceMessageId, String confirmationNumber) { + String caseKeys = confirmationNumber == null || confirmationNumber.isBlank() + ? "{}" + : "{\"confirmation_number\": \"" + confirmationNumber + "\"}"; + return minimalBody(sourceMessageId, "New Booking", "normal_task", "new_fit_reservation", """ + "case_keys": %s, + "extracted_fields": { + "booking_object_type": "FIT Reservation", + "arrival_date": "2026-08-01", + "departure_date": "2026-08-02", + "room_quantity": 2, + "room_type": "Deluxe King", + "pms_room_type_code": "RM2" + } + """.formatted(caseKeys)); + } + + private String[] createReadyTaskWithTwoOperaOperations( + String externalMessageId, + String nonce, + String confirmationNumber) throws Exception { + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId); + String body = completeNewBookingBody(source.inboxId().toString(), confirmationNumber); + MvcResult createResult = mockMvc.perform(signedPost(body, nonce)) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + createResult.getResponse().getContentAsString(), + "$.items[0].task_id" + ); + + MvcResult confirmResult = mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": {} + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.opera_operations.length()").value(2)) + .andReturn(); + String firstOperationId = com.jayway.jsonpath.JsonPath.read( + confirmResult.getResponse().getContentAsString(), + "$.opera_operations[0].operation_id" + ); + String secondOperationId = com.jayway.jsonpath.JsonPath.read( + confirmResult.getResponse().getContentAsString(), + "$.opera_operations[1].operation_id" + ); + return new String[] {taskId, firstOperationId, secondOperationId}; + } + private String signature(String body, String nonce, String timestamp) throws Exception { return signature(body, nonce, timestamp, CLIENT_ID); }