diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7a0929a..b62e484 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-18 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站基线与多卡模型设计并行 | -| 当前重点 | M002 V4 CP2 订单任务与多卡领域模型设计决策已闭合,后续准备进入 V4 表结构和 Repository 落地 | +| 当前阶段 | M002 V4 入站基线、多卡模型与持久化基线并行 | +| 当前重点 | M002 V4 CP3 表结构和 Repository 基线已落地,后续准备做 V4 入站写入新模型 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP2-order-task-card-domain-model` -- 状态:Done,已完成设计文档并补齐 7 项业务决策,尚未写 Java、Flyway、接口或前端代码。 -- 目标:在 CP1 入站解析基线之后,明确 V4 订单任务、SourceMessage 邮件展示卡、Basic Information 独立卡、业务卡、状态、表结构草案、接口草案和后续开发 checkpoint。 -- 边界:只做文档设计;不做 OPERA / OHIP、不做前端页面、不做历史数据迁移。 +- 名称:`M002-V4-CP3-order-task-card-repository-baseline` +- 状态:Done,已新增 V4 订单任务表、任务卡表、来源通知表、Entity、Mapper、Repository 和 Repository 集成测试;已覆盖 `order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 event 卡 index 校验。 +- 目标:在 CP1 入站解析和 CP2 多卡模型设计之后,落地 V4 订单任务、多卡和 S10 来源通知的持久化基线。 +- 边界:不改 SuperAgent 入站写入逻辑;不实现 V4 前端查询接口、卡片确认 / 复核 / S10 ack 接口;不做 OPERA / OHIP、不做前端页面、不做历史数据迁移。 ## 2. 当前优先级 @@ -34,12 +34,12 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计。Basic Information 独立卡、V4 订单任务多卡表结构、查询 / 写接口和 V4 前端页面仍未完成代码实现。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10 来源通知表结构与 Repository 基线。Basic Information 独立卡的入站写入、V4 查询 / 写接口和 V4 前端页面仍未完成代码实现。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP3`:新增 V4 订单任务表、V4 任务卡表、V4 来源通知表、Entity、Mapper、Repository 和基础测试。 +- 后续如继续做 M002 V4,应优先进入 `M002-V4-CP4`:将 SuperAgent V4 入站结果写入新模型,真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10 来源通知。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/docs/project/README.md b/docs/project/README.md index 3d8fa68..ebbe24d 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -45,8 +45,8 @@ | `requirements/M002-order-task-workflow-v2.md` | 阶段记录 | M002 订单任务主流程 V2,记录当前已阶段实现的 AI 过渡层、S000/S999 兼容、订单任务流转、任务确认和 OPERA 模拟骨架。 | | `requirements/M002-order-task-workflow-v3.md` | 当前有效 | M002 订单任务主流程 V3,基于 2026-07-11 P0 冻结基线和 2026-07-12 P0.1 Parent Group 修订,记录 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed 边界。 | | `requirements/M002-task-field-control-contract-v1.md` | 当前有效 | M002 任务卡字段控件契约 V1,记录任务详情 `fields[]` 控件元数据、人工复核控件复用和前后端开发边界。 | -| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10 和校验口径;后端已完成 V4 入站解析 CP1,完整 V4 多卡主流程仍待后续实现。 | -| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,记录 SourceMessage 展示卡、来源通知模型、订单任务、Basic Information 卡、业务卡、状态、表设计草案、已确认业务决策和后续开发 checkpoint。 | +| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10 和校验口径;后端已完成 V4 入站解析 CP1 和 CP3 持久化基线,完整 V4 多卡主流程仍待后续实现。 | +| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3 表结构、Entity、Mapper、Repository 已落地状态;后续仍需实现 V4 入站写入新模型、查询接口、卡片确认 / 复核和前端页面。 | | `requirements/M002-superagent-task-result-api-contract.md` | 阶段记录 | M002 SuperAgent 任务结果入站接口契约阶段记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-ai-query-minimal-fields.md` | 阶段记录 | M002 SuperAgent 查询上下文接口 1、2 最小字段落地记录;对外总契约以 `integrations/superagent-api-contract.md` 为准。 | | `requirements/M002-backend-data-model-design.md` | 阶段记录 | M002 后端数据模型设计,记录 AI 过渡层、订单、任务、任务卡、审计和 OPERA 模拟结果表。 | @@ -91,6 +91,6 @@ - 接口暴露、权限、酒店隔离和审计边界以 `security-access-control-boundary.md` 为总检查清单;具体 SuperAgent / MCP / AgentBus 请求响应契约仍以 `integrations/` 下对应文档为准。 - AI-NSES 的通用标准以 `../import/reusable/ai-native-software-engineering-standard.md` 为复用来源;本项目采用方式以 `ai-native-adoption.md` 为准。 - M002 V1 只作为历史参考;V2 记录当前阶段实现;后续 M002 新开发以 `requirements/M002-order-task-workflow-v3.md` 为开发基线。 -- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`,后续仍需实现表结构、入站落库、查询接口、卡片确认 / 复核和前端页面模型。 +- 2026-07-18 导入的业务基线已形成 `requirements/M002-v4-agent-callback-field-contract.md` 字段契约;M002 V4 入站解析 CP1 已落地,V4 订单任务 + 多卡领域模型设计和关键业务决策见 `requirements/M002-v4-order-task-card-domain-model-cp2.md`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,后续仍需实现入站写入新模型、查询接口、卡片确认 / 复核和前端页面模型。 - 前端展示 / 编辑字段以 2026-07-11 P0 冻结基线中的前端字段表、0712 字段控件说明和 `requirements/M002-task-field-control-contract-v1.md` 为白名单和控件契约基线;后端完整校验和 OPERA 映射仍以任务卡完整矩阵、0711 runtime 契约和后端规则为准。 - 时间点语义以 `backend-time-design.md` 为准;数据库时间点按 UTC 理解,API 返回带 `Z` 的 UTC 时间,页面再按酒店或用户时区展示。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 864c53e..9a9f0b4 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -458,6 +458,7 @@ RESERVATION_ROOMING_LIST_GENERATE - V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。 - V4 包级契约错误只会保存在 AI transition 中,不会出现在普通任务列表;V4 event 级契约错误如果同批次存在其它业务任务,前端仍按任务详情里的 `adapter_contract_errors[]` 只读展示诊断信息。 - M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表草案为 `GET /api/reservation/workbench-items`,业务订单任务草案为 `/api/reservation/order-tasks/**`,S10 来源通知详情草案为 `GET /api/reservation/source-notifications/{notificationId}`,但当前还没有实现,不要提前接入草案路径。 +- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10 来源通知三张表和 Repository 基线;这只是后端持久层准备,不代表 V4 工作台、订单任务详情、卡片确认或 S10 ack API 已经可用。前端当前仍不要调用 CP2 草案路径。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 - V4 S10 后续采用来源通知模型:任务列表 / 工作台展示,进入纯通知详情页后只显示邮件展示卡和确认按钮;不再挂隐藏技术订单,不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。当前代码里旧 `SOURCE_MESSAGE_ONLY` 只读任务仍属于过渡实现。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 6ebcc17..3da894f 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -478,7 +478,7 @@ M002 V4 CP2 设计文档已落地: 仍需后续 checkpoint 实现: -- V4 表结构和 Repository 落地,包括 V4 订单任务表、V4 任务卡表和 V4 来源通知表。 +- V4 表结构和 Repository 落地已完成第一版:新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,并提供 Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 version 乐观锁更新基础方法。 - V4 入站写入新模型,真正创建 SourceMessage 展示卡、Basic Information 卡和业务卡。 - V4 查询接口、卡片确认 / 复核 / 锁定、同订单阻塞和业务审计。 - V4 前端页面模型、任务详情字段矩阵和目录校验完全切换。 diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index b64e757..66774e6 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 1.4 | | 日期 | 2026-07-18 | -| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线,CP2 订单任务与多卡领域模型设计及关键决策已落文档,完整 V4 多卡代码仍需后续 checkpoint | +| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计和 CP3 持久化基线,完整 V4 多卡入站写入与接口仍需后续 checkpoint | | 适用范围 | 0718 业务基线下,Agent → Adapter / MCP → 信息系统的业务回调字段 | | 不适用范围 | 数据库表设计、前端视觉细节、真实 PMS API、技术失败后台重试、旧 M002 V3 数据兼容 | @@ -16,7 +16,7 @@ 本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前后端已按本文完成 V4 入站解析基线:能识别 V4 包、校验关键契约、保存 AI transition / 任务卡原始 payload,并把可映射的六类 event 先接入现有订单任务链路。 -V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。该文档只代表后续开发方案,不代表表结构、接口或前端页面已经实现。 +V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP3,表结构、Entity、Mapper、Repository 基线已经实现;SuperAgent 入站写入新模型、V4 前端查询接口和卡片写操作接口仍未实现。 当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。 @@ -735,6 +735,6 @@ AI 回调包 - V4 新数据不再保存后端草稿;用户只提交最终确认或复核解阻。 - 卡片确认后锁定,错误修正后续通过审计和未来纠错流程表达,不覆盖原确认。 - 技术异常只进入 AI transition / 技术运行记录,不进入用户可处理卡。 -- 后续建议新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,继续复用 SourceMessage、AI batch、AI transition、Reservation Order 和业务审计表。 +- CP3 已新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,继续复用 SourceMessage、AI batch、AI transition、Reservation Order 和业务审计表。 -CP2 尚未实现代码、接口、Flyway 或前端页面。后续开发应从 CP3 表结构和 Repository 开始。 +CP3 只完成表结构、Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和基础乐观锁更新。后续开发应从 CP4 入站写入新模型开始,并继续补 V4 查询接口、卡片确认 / 复核、S10 ack 和前端页面。 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index 0bcb6a6..e227334 100644 --- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md +++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md @@ -4,17 +4,19 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.1 | +| 文档版本 | 0.2 | | 日期 | 2026-07-18 | -| 状态 | CP2 设计文档,尚未写代码 | +| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | -| 不适用范围 | Java 实现、Flyway migration、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | +| 不适用范围 | V4 入站写入新模型、V4 前端查询接口、卡片确认 / 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | ## 1. 文档定位 M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路由适配和 AI transition 最小落库。CP1 仍然把可映射的 V4 event 临时接入 M002 V3 的订单 / 任务 / 任务卡链路。 -本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。本文只描述设计,不代表代码已经实现。 +本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 + +截至 CP3,后端已实现本文第 10、11 节中的持久化基线:新增 `workflow_reservation_v4_order_task`、`workflow_reservation_v4_task_card`、`workflow_reservation_v4_source_notification` 三张表,以及对应 Entity、Mapper、Repository 和基础测试。CP3 仍未把 SuperAgent V4 入站结果写入这些新表,也未开放 V4 前端查询或写操作接口。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -266,7 +268,7 @@ V4 当前不做 OPERA / PMS 执行,但仍需要保留同订单处理顺序, | `workflow_reservation_order` | 继续作为本地订单投影和订单列表数据源,但需要补 V4 所需 `BOOKING_CODE` 或 locator 相关能力 | | `workflow_reservation_audit_log` | 继续保存卡片确认、复核、订单归属确认等业务审计 | -### 10.2 建议新增表:`workflow_reservation_v4_order_task` +### 10.2 已新增表:`workflow_reservation_v4_order_task` 一条记录表示一个 `source_message_id + order_ref` 形成的 V4 订单任务。 @@ -277,6 +279,7 @@ V4 当前不做 OPERA / PMS 执行,但仍需要保留同订单处理顺序, | `source_message_id` | SourceMessage Inbox 内部 ID | | `ai_batch_id` | AI 回调批次 ID | | `order_ref` | V4 包内订单引用 | +| `order_context_index` | `order_contexts[]` 中的一基序号,用于同一 SourceMessage 下稳定排序 | | `order_id` | 绑定的本地订单投影 ID | | `target_booking_type` | `GROUP` / `FIT` | | `target_locator_type` | `GROUP_CODE` / `BOOKING_CODE` / `CONFIRMATION_NUMBER` | @@ -284,15 +287,18 @@ V4 当前不做 OPERA / PMS 执行,但仍需要保留同订单处理顺序, | `target_resolution_status` | `RESOLVED` / `UNRESOLVED` / `CONFLICT` | | `order_task_status` | 落库快照,仅保存 `OPEN` / `COMPLETED`;`BLOCKED` 由查询响应实时派生 | | `source_received_at` | 来源邮件接收 UTC 时间,用于排序 | +| `version` | 乐观锁版本 | | `created_at` / `updated_at` | UTC 创建和更新时间 | +| `logic_deleted_at` / `logic_deleted_reason` | 逻辑删除时间和原因,CP3 第一版只预留字段,Repository 默认排除逻辑删除记录 | 建议唯一约束: ```text -uk_v4_order_task_source_ref(hotel_id, source_message_id, order_ref) +uk_reservation_v4_order_task_source_ref(hotel_id, source_message_id, order_ref) +uk_reservation_v4_order_task_source_index(hotel_id, source_message_id, order_context_index) ``` -### 10.3 建议新增表:`workflow_reservation_v4_task_card` +### 10.3 已新增表:`workflow_reservation_v4_task_card` 一条记录表示 V4 订单任务下的一张卡。 @@ -317,11 +323,12 @@ uk_v4_order_task_source_ref(hotel_id, source_message_id, order_ref) | `confirmed_by` / `confirmed_at` | 确认人和确认 UTC 时间 | | `version` | 乐观锁版本 | | `created_at` / `updated_at` | UTC 创建和更新时间 | +| `logic_deleted_at` / `logic_deleted_reason` | 逻辑删除时间和原因,CP3 第一版只预留字段,Repository 默认排除逻辑删除记录 | 建议唯一约束: ```text -uk_v4_task_card_order_task_sort(hotel_id, v4_order_task_id, card_sort_order, source_event_index) +uk_reservation_v4_task_card_slot(hotel_id, v4_order_task_id, card_sort_order, source_event_index) ``` 约束说明: @@ -330,7 +337,7 @@ uk_v4_task_card_order_task_sort(hotel_id, v4_order_task_id, card_sort_order, sou - event 业务卡使用一基 `source_event_index`。 - 同一个订单任务下,固定卡片顺序 + `source_event_index` 必须唯一,防止入站重试或幂等异常重复创建同一张卡。 -### 10.4 建议新增表:`workflow_reservation_v4_source_notification` +### 10.4 已新增表:`workflow_reservation_v4_source_notification` 一条记录表示一个 S10 来源通知。它不挂订单任务、不创建订单、不进入订单列表,只用于任务列表 / 工作台和纯通知详情页。 @@ -348,11 +355,12 @@ uk_v4_task_card_order_task_sort(hotel_id, v4_order_task_id, card_sort_order, sou | `source_received_at` | 来源邮件接收 UTC 时间,用于任务列表 / 工作台排序 | | `version` | 乐观锁版本,用于确认按钮并发控制 | | `created_at` / `updated_at` | UTC 创建和更新时间 | +| `logic_deleted_at` / `logic_deleted_reason` | 逻辑删除时间和原因,CP3 第一版只预留字段,Repository 默认排除逻辑删除记录 | 建议唯一约束: ```text -uk_v4_source_notification_message_batch(hotel_id, source_message_id, ai_batch_id) +uk_reservation_v4_notification_message_batch(hotel_id, source_message_id, ai_batch_id) ``` ### 10.5 暂不新增的表 @@ -370,7 +378,7 @@ uk_v4_source_notification_message_batch(hotel_id, source_message_id, ai_batch_id ### 11.1 domain -建议新增: +已新增: - `ReservationV4OrderTaskEntity`:映射 `workflow_reservation_v4_order_task`。 - `ReservationV4TaskCardEntity`:映射 `workflow_reservation_v4_task_card`。 @@ -380,7 +388,7 @@ Entity 字段必须有中文注释,说明字段业务含义、来源和状态 ### 11.2 mapper -建议新增: +已新增: - `ReservationV4OrderTaskMapper` - `ReservationV4TaskCardMapper` @@ -390,22 +398,26 @@ Mapper 只放 MyBatis-Plus 基础访问和必要语义化查询。自定义方 ### 11.3 repository -建议新增: +已新增: - `ReservationV4WorkflowRepository` - `MybatisReservationV4WorkflowRepository` - `ReservationV4SourceNotificationRepository` - `MybatisReservationV4SourceNotificationRepository` -Repository 负责封装: +CP3 Repository 已封装: - 按 SourceMessage + order_ref 幂等创建订单任务。 -- 创建 Basic Information / SourceMessage / Event 卡。 +- 按 `order_context_index` 保留同一 SourceMessage 下多个 `order_contexts[]` 的稳定顺序。 +- 创建 Basic Information / SourceMessage / Event 卡的基础插入方法;非 event 卡 `source_event_index` 固定写入 `0`。 - 按 SourceMessage + AI batch 幂等创建 S10 来源通知。 - 查询订单任务详情和卡片列表。 - 查询来源通知列表和详情。 -- 卡片确认、锁定、复核解阻的乐观锁更新。 -- 来源通知确认的乐观锁更新。 +- 卡片状态的 version 乐观锁更新基础方法。 +- 来源通知状态的 version 乐观锁更新基础方法。 +- event 卡必须传入正数一基 `source_event_index`;漏传会拒绝写入,避免被误当成非 event 卡。 + +CP3 尚未实现卡片确认、复核解阻、订单归属确认、来源通知 ack 的业务 Service 和 API;这些仍属于后续 checkpoint。 Service 不直接访问 Mapper。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4OrderTaskDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4OrderTaskDraft.java new file mode 100644 index 0000000..309e92f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4OrderTaskDraft.java @@ -0,0 +1,36 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 订单任务创建草稿。用于按 source_message_id + order_ref 幂等创建订单任务聚合。 + */ +public record ReservationV4OrderTaskDraft( + /** 酒店 ID。 */ + String hotelId, + /** SourceMessage Inbox 内部 ID。 */ + Long sourceMessageId, + /** AI 回调批次 ID。 */ + Long aiBatchId, + /** V4 包内订单引用。 */ + String orderRef, + /** order_contexts[] 中的一基序号。 */ + Integer orderContextIndex, + /** 绑定的本地订单投影 ID,归属未解决时可为空。 */ + Long orderId, + /** 目标订单业务类型,例如 GROUP、FIT。 */ + String targetBookingType, + /** 目标订单定位类型。 */ + String targetLocatorType, + /** 目标订单定位值。 */ + String targetLocatorValue, + /** 订单归属解析状态。 */ + String targetResolutionStatus, + /** 订单任务状态快照。 */ + String orderTaskStatus, + /** 来源邮件接收 UTC 时间。 */ + LocalDateTime sourceReceivedAt, + /** 当前写入 UTC 时间。 */ + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4OrderTaskSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4OrderTaskSnapshot.java new file mode 100644 index 0000000..f68014a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4OrderTaskSnapshot.java @@ -0,0 +1,42 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 订单任务快照。屏蔽 Entity 细节供 Service 和后续查询接口使用。 + */ +public record ReservationV4OrderTaskSnapshot( + /** V4 订单任务 ID。 */ + Long id, + /** 酒店 ID。 */ + String hotelId, + /** SourceMessage Inbox 内部 ID。 */ + Long sourceMessageId, + /** AI 回调批次 ID。 */ + Long aiBatchId, + /** V4 包内订单引用。 */ + String orderRef, + /** order_contexts[] 中的一基序号。 */ + Integer orderContextIndex, + /** 绑定的本地订单投影 ID,归属未解决时可为空。 */ + Long orderId, + /** 目标订单业务类型。 */ + String targetBookingType, + /** 目标订单定位类型。 */ + String targetLocatorType, + /** 目标订单定位值。 */ + String targetLocatorValue, + /** 订单归属解析状态。 */ + String targetResolutionStatus, + /** 订单任务状态快照。 */ + String orderTaskStatus, + /** 来源邮件接收 UTC 时间。 */ + LocalDateTime sourceReceivedAt, + /** 乐观锁版本。 */ + Long version, + /** 记录创建 UTC 时间。 */ + LocalDateTime createdAt, + /** 记录更新 UTC 时间。 */ + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationDraft.java new file mode 100644 index 0000000..8202013 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationDraft.java @@ -0,0 +1,28 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 来源通知创建草稿。第一版用于 S10 纯通知入库。 + */ +public record ReservationV4SourceNotificationDraft( + /** 酒店 ID。 */ + String hotelId, + /** SourceMessage Inbox 内部 ID。 */ + Long sourceMessageId, + /** AI 回调批次 ID。 */ + Long aiBatchId, + /** AI transition ID,S10 第一版可为空。 */ + Long aiTransitionId, + /** 包级路由码,第一版用于 S10。 */ + String routeCode, + /** 通知状态。 */ + String notificationStatus, + /** 来源通知原始 AI 片段或包级摘要 JSON。 */ + String rawPayloadJson, + /** 来源邮件接收 UTC 时间。 */ + LocalDateTime sourceReceivedAt, + /** 当前写入 UTC 时间。 */ + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationSnapshot.java new file mode 100644 index 0000000..587a17b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4SourceNotificationSnapshot.java @@ -0,0 +1,38 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 来源通知快照。用于后续 S10 通知详情和确认接口。 + */ +public record ReservationV4SourceNotificationSnapshot( + /** V4 来源通知 ID。 */ + Long id, + /** 酒店 ID。 */ + String hotelId, + /** SourceMessage Inbox 内部 ID。 */ + Long sourceMessageId, + /** AI 回调批次 ID。 */ + Long aiBatchId, + /** AI transition ID,S10 第一版可为空。 */ + Long aiTransitionId, + /** 包级路由码。 */ + String routeCode, + /** 通知状态。 */ + String notificationStatus, + /** 来源通知原始 AI 片段或包级摘要 JSON。 */ + String rawPayloadJson, + /** 确认处理人标识。 */ + String ackBy, + /** 确认处理 UTC 时间。 */ + LocalDateTime ackAt, + /** 来源邮件接收 UTC 时间。 */ + LocalDateTime sourceReceivedAt, + /** 乐观锁版本。 */ + Long version, + /** 记录创建 UTC 时间。 */ + LocalDateTime createdAt, + /** 记录更新 UTC 时间。 */ + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4TaskCardDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4TaskCardDraft.java new file mode 100644 index 0000000..7704737 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4TaskCardDraft.java @@ -0,0 +1,38 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 任务卡创建草稿。非 event 卡 source_event_index 允许为空,由 Repository 固定写入 0。 + */ +public record ReservationV4TaskCardDraft( + /** 酒店 ID。 */ + String hotelId, + /** V4 订单任务 ID。 */ + Long v4OrderTaskId, + /** SourceMessage Inbox 内部 ID。 */ + Long sourceMessageId, + /** AI transition ID,非 event 卡可为空。 */ + Long aiTransitionId, + /** 卡片类型。 */ + String cardType, + /** 业务事件类型,非 event 卡为空。 */ + String eventType, + /** message_events[] 中的一基序号;非 event 卡为空并写入 0。 */ + Integer sourceEventIndex, + /** 同一订单任务内卡片排序。 */ + Integer cardSortOrder, + /** 卡片状态。 */ + String cardStatus, + /** 人工复核状态,非复核卡为空。 */ + String reviewStatus, + /** AI 原始卡片 payload JSON 快照。 */ + String aiPayloadJson, + /** 前端展示用 payload JSON 快照。 */ + String displayPayloadJson, + /** 第一版后端校验错误 JSON。 */ + String validationErrorsJson, + /** 当前写入 UTC 时间。 */ + LocalDateTime now +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4TaskCardSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4TaskCardSnapshot.java new file mode 100644 index 0000000..8b7a6b1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4TaskCardSnapshot.java @@ -0,0 +1,52 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 任务卡快照。保存 AI 原始卡、展示卡、确认卡和复核结果的当前数据库视图。 + */ +public record ReservationV4TaskCardSnapshot( + /** V4 任务卡 ID。 */ + Long id, + /** 酒店 ID。 */ + String hotelId, + /** V4 订单任务 ID。 */ + Long v4OrderTaskId, + /** SourceMessage Inbox 内部 ID。 */ + Long sourceMessageId, + /** AI transition ID,非 event 卡可为空。 */ + Long aiTransitionId, + /** 卡片类型。 */ + String cardType, + /** 业务事件类型,非 event 卡为空。 */ + String eventType, + /** message_events[] 中的一基序号;非 event 卡固定为 0。 */ + Integer sourceEventIndex, + /** 同一订单任务内卡片排序。 */ + Integer cardSortOrder, + /** 卡片状态。 */ + String cardStatus, + /** 人工复核状态,非复核卡为空。 */ + String reviewStatus, + /** AI 原始卡片 payload JSON 快照。 */ + String aiPayloadJson, + /** 前端展示用 payload JSON 快照。 */ + String displayPayloadJson, + /** 用户最终确认 payload JSON。 */ + String confirmedPayloadJson, + /** 人工复核解阻结果 JSON。 */ + String reviewResolutionJson, + /** 第一版后端校验错误 JSON。 */ + String validationErrorsJson, + /** 最终确认人标识。 */ + String confirmedBy, + /** 最终确认 UTC 时间。 */ + LocalDateTime confirmedAt, + /** 乐观锁版本。 */ + Long version, + /** 记录创建 UTC 时间。 */ + LocalDateTime createdAt, + /** 记录更新 UTC 时间。 */ + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardStatus.java new file mode 100644 index 0000000..40a9d8a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardStatus.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 任务卡状态。 + */ +public enum ReservationV4CardStatus { + /** 只读展示卡,不允许用户编辑或确认。 */ + READONLY, + /** 待用户确认的业务卡。 */ + PENDING_CONFIRM, + /** 待人工复核解阻的业务卡。 */ + REVIEW_REQUIRED, + /** 已由用户确认完成的业务卡。 */ + CONFIRMED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardType.java new file mode 100644 index 0000000..47edd0f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CardType.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 任务卡类型。 + */ +public enum ReservationV4CardType { + /** 来源邮件展示卡。 */ + SOURCE_MESSAGE_DISPLAY, + /** 订单级 Basic Information 卡。 */ + BASIC_INFORMATION, + /** 房型、房晚和房数等客房信息卡。 */ + ROOM_INFORMATION, + /** 预订备注追踪卡。 */ + TRACE_RESERVATION_NOTES, + /** Rooming List 业务卡。 */ + ROOMING_LIST, + /** Payment 业务卡。 */ + PAYMENT +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4NotificationStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4NotificationStatus.java new file mode 100644 index 0000000..c5c58a1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4NotificationStatus.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 来源通知状态。 + */ +public enum ReservationV4NotificationStatus { + /** 需要用户确认已读或已处理。 */ + ACK_REQUIRED, + /** 用户已经确认处理。 */ + ACKED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4OrderTaskStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4OrderTaskStatus.java new file mode 100644 index 0000000..2d938bf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4OrderTaskStatus.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 订单任务状态快照。 + */ +public enum ReservationV4OrderTaskStatus { + /** 订单任务仍有待处理卡片。 */ + OPEN, + /** 订单任务下所有需要处理的卡片均已完成。 */ + COMPLETED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4TargetResolutionStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4TargetResolutionStatus.java new file mode 100644 index 0000000..9033bb3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4TargetResolutionStatus.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 订单归属解析状态。 + */ +public enum ReservationV4TargetResolutionStatus { + /** 已明确匹配到目标订单或可创建的新订单上下文。 */ + RESOLVED, + /** 缺少足够信息,等待人工确认订单归属。 */ + UNRESOLVED, + /** 匹配到多个候选订单,需要人工处理冲突。 */ + CONFLICT +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4OrderTaskEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4OrderTaskEntity.java new file mode 100644 index 0000000..367be0f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4OrderTaskEntity.java @@ -0,0 +1,88 @@ +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 V4 订单任务实体。承载同一 SourceMessage + order_ref 下的订单任务聚合。 + */ +@TableName("workflow_reservation_v4_order_task") +public class ReservationV4OrderTaskEntity { + + /** V4 订单任务 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店 ID。 */ + private String hotelId; + /** SourceMessage Inbox 内部 ID。 */ + private Long sourceMessageId; + /** AI 回调批次 ID。 */ + private Long aiBatchId; + /** V4 包内订单引用。 */ + private String orderRef; + /** order_contexts[] 中的一基序号。 */ + private Integer orderContextIndex; + /** 绑定的本地订单投影 ID,归属未解决时可为空。 */ + private Long orderId; + /** 目标订单业务类型,例如 GROUP、FIT。 */ + private String targetBookingType; + /** 目标订单定位类型。 */ + private String targetLocatorType; + /** 目标订单定位值。 */ + private String targetLocatorValue; + /** 订单归属解析状态。 */ + private String targetResolutionStatus; + /** 订单任务状态快照。 */ + private String orderTaskStatus; + /** 来源邮件接收 UTC 时间。 */ + private LocalDateTime sourceReceivedAt; + /** 乐观锁版本。 */ + private Long version; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + /** 逻辑删除 UTC 时间。 */ + private LocalDateTime logicDeletedAt; + /** 逻辑删除原因。 */ + private String logicDeletedReason; + + 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 getSourceMessageId() { return sourceMessageId; } + public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; } + public Long getAiBatchId() { return aiBatchId; } + public void setAiBatchId(Long aiBatchId) { this.aiBatchId = aiBatchId; } + public String getOrderRef() { return orderRef; } + public void setOrderRef(String orderRef) { this.orderRef = orderRef; } + public Integer getOrderContextIndex() { return orderContextIndex; } + public void setOrderContextIndex(Integer orderContextIndex) { this.orderContextIndex = orderContextIndex; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long orderId) { this.orderId = orderId; } + public String getTargetBookingType() { return targetBookingType; } + public void setTargetBookingType(String targetBookingType) { this.targetBookingType = targetBookingType; } + public String getTargetLocatorType() { return targetLocatorType; } + public void setTargetLocatorType(String targetLocatorType) { this.targetLocatorType = targetLocatorType; } + public String getTargetLocatorValue() { return targetLocatorValue; } + public void setTargetLocatorValue(String targetLocatorValue) { this.targetLocatorValue = targetLocatorValue; } + public String getTargetResolutionStatus() { return targetResolutionStatus; } + public void setTargetResolutionStatus(String targetResolutionStatus) { this.targetResolutionStatus = targetResolutionStatus; } + public String getOrderTaskStatus() { return orderTaskStatus; } + public void setOrderTaskStatus(String orderTaskStatus) { this.orderTaskStatus = orderTaskStatus; } + public LocalDateTime getSourceReceivedAt() { return sourceReceivedAt; } + public void setSourceReceivedAt(LocalDateTime sourceReceivedAt) { this.sourceReceivedAt = sourceReceivedAt; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + 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; } + public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; } + public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; } + public String getLogicDeletedReason() { return logicDeletedReason; } + public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4SourceNotificationEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4SourceNotificationEntity.java new file mode 100644 index 0000000..7e3e76d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4SourceNotificationEntity.java @@ -0,0 +1,80 @@ +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 V4 来源通知实体。第一版用于保存 S10 纯通知,不挂订单任务。 + */ +@TableName("workflow_reservation_v4_source_notification") +public class ReservationV4SourceNotificationEntity { + + /** V4 来源通知 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店 ID。 */ + private String hotelId; + /** SourceMessage Inbox 内部 ID。 */ + private Long sourceMessageId; + /** AI 回调批次 ID。 */ + private Long aiBatchId; + /** AI transition ID,S10 第一版可为空。 */ + private Long aiTransitionId; + /** 包级路由码。 */ + private String routeCode; + /** 通知状态。 */ + private String notificationStatus; + /** 来源通知原始 AI 片段或包级摘要 JSON。 */ + private String rawPayloadJson; + /** 确认处理人标识。 */ + private String ackBy; + /** 确认处理 UTC 时间。 */ + private LocalDateTime ackAt; + /** 来源邮件接收 UTC 时间。 */ + private LocalDateTime sourceReceivedAt; + /** 乐观锁版本。 */ + private Long version; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + /** 逻辑删除 UTC 时间。 */ + private LocalDateTime logicDeletedAt; + /** 逻辑删除原因。 */ + private String logicDeletedReason; + + 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 getSourceMessageId() { return sourceMessageId; } + public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; } + public Long getAiBatchId() { return aiBatchId; } + public void setAiBatchId(Long aiBatchId) { this.aiBatchId = aiBatchId; } + public Long getAiTransitionId() { return aiTransitionId; } + public void setAiTransitionId(Long aiTransitionId) { this.aiTransitionId = aiTransitionId; } + public String getRouteCode() { return routeCode; } + public void setRouteCode(String routeCode) { this.routeCode = routeCode; } + public String getNotificationStatus() { return notificationStatus; } + public void setNotificationStatus(String notificationStatus) { this.notificationStatus = notificationStatus; } + public String getRawPayloadJson() { return rawPayloadJson; } + public void setRawPayloadJson(String rawPayloadJson) { this.rawPayloadJson = rawPayloadJson; } + public String getAckBy() { return ackBy; } + public void setAckBy(String ackBy) { this.ackBy = ackBy; } + public LocalDateTime getAckAt() { return ackAt; } + public void setAckAt(LocalDateTime ackAt) { this.ackAt = ackAt; } + public LocalDateTime getSourceReceivedAt() { return sourceReceivedAt; } + public void setSourceReceivedAt(LocalDateTime sourceReceivedAt) { this.sourceReceivedAt = sourceReceivedAt; } + public Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + 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; } + public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; } + public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; } + public String getLogicDeletedReason() { return logicDeletedReason; } + public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4TaskCardEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4TaskCardEntity.java new file mode 100644 index 0000000..c87ef2f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationV4TaskCardEntity.java @@ -0,0 +1,108 @@ +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 V4 任务卡实体。承载来源邮件展示卡、Basic Information 卡和具体业务卡。 + */ +@TableName("workflow_reservation_v4_task_card") +public class ReservationV4TaskCardEntity { + + /** V4 任务卡 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店 ID。 */ + private String hotelId; + /** V4 订单任务 ID。 */ + private Long v4OrderTaskId; + /** SourceMessage Inbox 内部 ID。 */ + private Long sourceMessageId; + /** AI transition ID,非 event 卡可为空。 */ + private Long aiTransitionId; + /** 卡片类型。 */ + private String cardType; + /** 业务事件类型,非 event 卡为空。 */ + private String eventType; + /** message_events[] 中的一基序号;非 event 卡固定为 0。 */ + private Integer sourceEventIndex; + /** 同一订单任务内卡片排序。 */ + private Integer cardSortOrder; + /** 卡片状态。 */ + private String cardStatus; + /** 人工复核状态,非复核卡为空。 */ + private String reviewStatus; + /** AI 原始卡片 payload JSON 快照。 */ + private String aiPayloadJson; + /** 前端展示用 payload JSON 快照。 */ + private String displayPayloadJson; + /** 用户最终确认 payload JSON。 */ + private String confirmedPayloadJson; + /** 人工复核解阻结果 JSON。 */ + private String reviewResolutionJson; + /** 第一版后端校验错误 JSON。 */ + private String validationErrorsJson; + /** 最终确认人标识。 */ + private String confirmedBy; + /** 最终确认 UTC 时间。 */ + private LocalDateTime confirmedAt; + /** 乐观锁版本。 */ + private Long version; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + /** 逻辑删除 UTC 时间。 */ + private LocalDateTime logicDeletedAt; + /** 逻辑删除原因。 */ + private String logicDeletedReason; + + 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 getV4OrderTaskId() { return v4OrderTaskId; } + public void setV4OrderTaskId(Long v4OrderTaskId) { this.v4OrderTaskId = v4OrderTaskId; } + public Long getSourceMessageId() { return sourceMessageId; } + public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; } + public Long getAiTransitionId() { return aiTransitionId; } + public void setAiTransitionId(Long aiTransitionId) { this.aiTransitionId = aiTransitionId; } + public String getCardType() { return cardType; } + public void setCardType(String cardType) { this.cardType = cardType; } + public String getEventType() { return eventType; } + public void setEventType(String eventType) { this.eventType = eventType; } + public Integer getSourceEventIndex() { return sourceEventIndex; } + public void setSourceEventIndex(Integer sourceEventIndex) { this.sourceEventIndex = sourceEventIndex; } + public Integer getCardSortOrder() { return cardSortOrder; } + public void setCardSortOrder(Integer cardSortOrder) { this.cardSortOrder = cardSortOrder; } + public String getCardStatus() { return cardStatus; } + public void setCardStatus(String cardStatus) { this.cardStatus = cardStatus; } + public String getReviewStatus() { return reviewStatus; } + public void setReviewStatus(String reviewStatus) { this.reviewStatus = reviewStatus; } + public String getAiPayloadJson() { return aiPayloadJson; } + public void setAiPayloadJson(String aiPayloadJson) { this.aiPayloadJson = aiPayloadJson; } + public String getDisplayPayloadJson() { return displayPayloadJson; } + public void setDisplayPayloadJson(String displayPayloadJson) { this.displayPayloadJson = displayPayloadJson; } + public String getConfirmedPayloadJson() { return confirmedPayloadJson; } + public void setConfirmedPayloadJson(String confirmedPayloadJson) { this.confirmedPayloadJson = confirmedPayloadJson; } + public String getReviewResolutionJson() { return reviewResolutionJson; } + public void setReviewResolutionJson(String reviewResolutionJson) { this.reviewResolutionJson = reviewResolutionJson; } + public String getValidationErrorsJson() { return validationErrorsJson; } + public void setValidationErrorsJson(String validationErrorsJson) { this.validationErrorsJson = validationErrorsJson; } + 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 Long getVersion() { return version; } + public void setVersion(Long version) { this.version = version; } + 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; } + public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; } + public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; } + public String getLogicDeletedReason() { return logicDeletedReason; } + public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java new file mode 100644 index 0000000..637ebff --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4OrderTaskMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation V4 订单任务 Mapper。自带 CRUD 由 MyBatis-Plus 提供。 + */ +@Mapper +public interface ReservationV4OrderTaskMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4SourceNotificationMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4SourceNotificationMapper.java new file mode 100644 index 0000000..4c8d660 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4SourceNotificationMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation V4 来源通知 Mapper。自带 CRUD 由 MyBatis-Plus 提供。 + */ +@Mapper +public interface ReservationV4SourceNotificationMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4TaskCardMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4TaskCardMapper.java new file mode 100644 index 0000000..fab8956 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationV4TaskCardMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation V4 任务卡 Mapper。自带 CRUD 由 MyBatis-Plus 提供。 + */ +@Mapper +public interface ReservationV4TaskCardMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java new file mode 100644 index 0000000..28e6bc9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4SourceNotificationRepository.java @@ -0,0 +1,157 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4SourceNotificationMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; + +/** + * Reservation V4 来源通知 MyBatis-Plus 持久化实现。 + */ +@Repository +public class MybatisReservationV4SourceNotificationRepository implements ReservationV4SourceNotificationRepository { + + private final ReservationV4SourceNotificationMapper sourceNotificationMapper; + + /** + * 注入来源通知 Mapper,Repository 负责幂等和快照转换。 + */ + public MybatisReservationV4SourceNotificationRepository( + ReservationV4SourceNotificationMapper sourceNotificationMapper) { + this.sourceNotificationMapper = sourceNotificationMapper; + } + + /** + * 按来源通知 ID 查询当前快照。 + */ + @Override + public Optional findSourceNotificationById( + String hotelId, + Long notificationId) { + ReservationV4SourceNotificationEntity entity = sourceNotificationMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId) + .eq(ReservationV4SourceNotificationEntity::getId, notificationId) + .isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toSnapshot); + } + + /** + * 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。 + */ + @Override + public Optional findSourceNotificationBySourceMessageAndBatch( + String hotelId, + Long sourceMessageId, + Long aiBatchId) { + ReservationV4SourceNotificationEntity entity = sourceNotificationMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId) + .eq(ReservationV4SourceNotificationEntity::getSourceMessageId, sourceMessageId) + .eq(ReservationV4SourceNotificationEntity::getAiBatchId, aiBatchId) + .isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toSnapshot); + } + + /** + * 按 SourceMessage 查询来源通知列表,用于后续邮件通知详情和工作台展示。 + */ + @Override + public List findSourceNotificationsBySourceMessageId( + String hotelId, + Long sourceMessageId) { + return sourceNotificationMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId) + .eq(ReservationV4SourceNotificationEntity::getSourceMessageId, sourceMessageId) + .isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt) + .orderByAsc(ReservationV4SourceNotificationEntity::getSourceReceivedAt) + .orderByAsc(ReservationV4SourceNotificationEntity::getId)) + .stream() + .map(this::toSnapshot) + .toList(); + } + + /** + * 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。 + */ + @Override + public ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification( + ReservationV4SourceNotificationDraft draft) { + Optional existing = + findSourceNotificationBySourceMessageAndBatch(draft.hotelId(), draft.sourceMessageId(), draft.aiBatchId()); + if (existing.isPresent()) { + return existing.get(); + } + ReservationV4SourceNotificationEntity entity = new ReservationV4SourceNotificationEntity(); + entity.setHotelId(draft.hotelId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setAiBatchId(draft.aiBatchId()); + entity.setAiTransitionId(draft.aiTransitionId()); + entity.setRouteCode(draft.routeCode()); + entity.setNotificationStatus(draft.notificationStatus()); + entity.setRawPayloadJson(draft.rawPayloadJson()); + entity.setSourceReceivedAt(draft.sourceReceivedAt()); + entity.setVersion(0L); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + try { + sourceNotificationMapper.insert(entity); + return toSnapshot(entity); + } catch (DuplicateKeyException ex) { + return findSourceNotificationBySourceMessageAndBatch( + draft.hotelId(), + draft.sourceMessageId(), + draft.aiBatchId()).orElseThrow(() -> ex); + } + } + + /** + * 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。 + */ + @Override + public boolean updateNotificationStatusWithVersion( + String hotelId, + Long notificationId, + Long expectedVersion, + String notificationStatus, + String ackBy, + LocalDateTime ackAt) { + int updated = sourceNotificationMapper.update(Wrappers.lambdaUpdate() + .set(ReservationV4SourceNotificationEntity::getNotificationStatus, notificationStatus) + .set(ReservationV4SourceNotificationEntity::getAckBy, ackBy) + .set(ReservationV4SourceNotificationEntity::getAckAt, ackAt) + .set(ReservationV4SourceNotificationEntity::getUpdatedAt, ackAt) + .setSql("version = version + 1") + .eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId) + .eq(ReservationV4SourceNotificationEntity::getId, notificationId) + .eq(ReservationV4SourceNotificationEntity::getVersion, expectedVersion) + .isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt)); + return updated == 1; + } + + private ReservationV4SourceNotificationSnapshot toSnapshot(ReservationV4SourceNotificationEntity entity) { + return new ReservationV4SourceNotificationSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getSourceMessageId(), + entity.getAiBatchId(), + entity.getAiTransitionId(), + entity.getRouteCode(), + entity.getNotificationStatus(), + entity.getRawPayloadJson(), + entity.getAckBy(), + entity.getAckAt(), + entity.getSourceReceivedAt(), + entity.getVersion(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java new file mode 100644 index 0000000..19e3c7b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java @@ -0,0 +1,274 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4OrderTaskMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4TaskCardMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; + +/** + * Reservation V4 订单任务和多卡 MyBatis-Plus 持久化实现。 + */ +@Repository +public class MybatisReservationV4WorkflowRepository implements ReservationV4WorkflowRepository { + + private static final int NON_EVENT_SOURCE_EVENT_INDEX = 0; + + private final ReservationV4OrderTaskMapper orderTaskMapper; + private final ReservationV4TaskCardMapper taskCardMapper; + + /** + * 注入 V4 Mapper,Repository 负责幂等、乐观锁和快照转换。 + */ + public MybatisReservationV4WorkflowRepository( + ReservationV4OrderTaskMapper orderTaskMapper, + ReservationV4TaskCardMapper taskCardMapper) { + this.orderTaskMapper = orderTaskMapper; + this.taskCardMapper = taskCardMapper; + } + + /** + * 按 SourceMessage 和 order_ref 查询 V4 订单任务,用于幂等创建和回调重放判断。 + */ + @Override + public Optional findOrderTaskBySourceMessageAndOrderRef( + String hotelId, + Long sourceMessageId, + String orderRef) { + ReservationV4OrderTaskEntity entity = orderTaskMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationV4OrderTaskEntity::getHotelId, hotelId) + .eq(ReservationV4OrderTaskEntity::getSourceMessageId, sourceMessageId) + .eq(ReservationV4OrderTaskEntity::getOrderRef, orderRef) + .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot); + } + + /** + * 按订单任务 ID 查询 V4 订单任务。 + */ + @Override + public Optional findOrderTaskById(String hotelId, Long orderTaskId) { + ReservationV4OrderTaskEntity entity = orderTaskMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationV4OrderTaskEntity::getHotelId, hotelId) + .eq(ReservationV4OrderTaskEntity::getId, orderTaskId) + .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot); + } + + /** + * 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。 + */ + @Override + public List findOrderTasksBySourceMessageId(String hotelId, Long sourceMessageId) { + return orderTaskMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationV4OrderTaskEntity::getHotelId, hotelId) + .eq(ReservationV4OrderTaskEntity::getSourceMessageId, sourceMessageId) + .isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt) + .orderByAsc(ReservationV4OrderTaskEntity::getOrderContextIndex) + .orderByAsc(ReservationV4OrderTaskEntity::getId)) + .stream() + .map(this::toOrderTaskSnapshot) + .toList(); + } + + /** + * 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。 + */ + @Override + public ReservationV4OrderTaskSnapshot findOrCreateOrderTask(ReservationV4OrderTaskDraft draft) { + Optional existing = findOrderTaskBySourceMessageAndOrderRef( + draft.hotelId(), + draft.sourceMessageId(), + draft.orderRef()); + if (existing.isPresent()) { + return existing.get(); + } + ReservationV4OrderTaskEntity entity = new ReservationV4OrderTaskEntity(); + entity.setHotelId(draft.hotelId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setAiBatchId(draft.aiBatchId()); + entity.setOrderRef(draft.orderRef()); + entity.setOrderContextIndex(requirePositiveOrderContextIndex(draft.orderContextIndex())); + entity.setOrderId(draft.orderId()); + entity.setTargetBookingType(draft.targetBookingType()); + entity.setTargetLocatorType(draft.targetLocatorType()); + entity.setTargetLocatorValue(draft.targetLocatorValue()); + entity.setTargetResolutionStatus(draft.targetResolutionStatus()); + entity.setOrderTaskStatus(draft.orderTaskStatus()); + entity.setSourceReceivedAt(draft.sourceReceivedAt()); + entity.setVersion(0L); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + try { + orderTaskMapper.insert(entity); + return toOrderTaskSnapshot(entity); + } catch (DuplicateKeyException ex) { + return findOrderTaskBySourceMessageAndOrderRef( + draft.hotelId(), + draft.sourceMessageId(), + draft.orderRef()).orElseThrow(() -> ex); + } + } + + /** + * 创建 V4 任务卡;非 event 卡 source_event_index 固定写入 0。 + */ + @Override + public ReservationV4TaskCardSnapshot insertTaskCard(ReservationV4TaskCardDraft draft) { + ReservationV4TaskCardEntity entity = new ReservationV4TaskCardEntity(); + entity.setHotelId(draft.hotelId()); + entity.setV4OrderTaskId(draft.v4OrderTaskId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setAiTransitionId(draft.aiTransitionId()); + entity.setCardType(draft.cardType()); + entity.setEventType(draft.eventType()); + entity.setSourceEventIndex(normalizeSourceEventIndex(draft)); + entity.setCardSortOrder(draft.cardSortOrder()); + entity.setCardStatus(draft.cardStatus()); + entity.setReviewStatus(draft.reviewStatus()); + entity.setAiPayloadJson(draft.aiPayloadJson()); + entity.setDisplayPayloadJson(draft.displayPayloadJson()); + entity.setValidationErrorsJson(draft.validationErrorsJson()); + entity.setVersion(0L); + entity.setCreatedAt(draft.now()); + entity.setUpdatedAt(draft.now()); + taskCardMapper.insert(entity); + return toTaskCardSnapshot(entity); + } + + /** + * 按任务卡 ID 查询 V4 任务卡。 + */ + @Override + public Optional findTaskCardById(String hotelId, Long taskCardId) { + ReservationV4TaskCardEntity entity = taskCardMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationV4TaskCardEntity::getHotelId, hotelId) + .eq(ReservationV4TaskCardEntity::getId, taskCardId) + .isNull(ReservationV4TaskCardEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toTaskCardSnapshot); + } + + /** + * 查询订单任务下全部 V4 任务卡,并按卡片排序和 event index 返回。 + */ + @Override + public List findTaskCardsByOrderTaskId(String hotelId, Long orderTaskId) { + return taskCardMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationV4TaskCardEntity::getHotelId, hotelId) + .eq(ReservationV4TaskCardEntity::getV4OrderTaskId, orderTaskId) + .isNull(ReservationV4TaskCardEntity::getLogicDeletedAt) + .orderByAsc(ReservationV4TaskCardEntity::getCardSortOrder) + .orderByAsc(ReservationV4TaskCardEntity::getSourceEventIndex) + .orderByAsc(ReservationV4TaskCardEntity::getId)) + .stream() + .map(this::toTaskCardSnapshot) + .toList(); + } + + /** + * 按 version 乐观锁更新 V4 任务卡状态。 + */ + @Override + public boolean updateTaskCardStatusWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String cardStatus, + LocalDateTime now) { + int updated = taskCardMapper.update(Wrappers.lambdaUpdate() + .set(ReservationV4TaskCardEntity::getCardStatus, cardStatus) + .set(ReservationV4TaskCardEntity::getUpdatedAt, now) + .setSql("version = version + 1") + .eq(ReservationV4TaskCardEntity::getHotelId, hotelId) + .eq(ReservationV4TaskCardEntity::getId, taskCardId) + .eq(ReservationV4TaskCardEntity::getVersion, expectedVersion) + .isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)); + return updated == 1; + } + + private ReservationV4OrderTaskSnapshot toOrderTaskSnapshot(ReservationV4OrderTaskEntity entity) { + return new ReservationV4OrderTaskSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getSourceMessageId(), + entity.getAiBatchId(), + entity.getOrderRef(), + entity.getOrderContextIndex(), + entity.getOrderId(), + entity.getTargetBookingType(), + entity.getTargetLocatorType(), + entity.getTargetLocatorValue(), + entity.getTargetResolutionStatus(), + entity.getOrderTaskStatus(), + entity.getSourceReceivedAt(), + entity.getVersion(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } + + private Integer requirePositiveOrderContextIndex(Integer orderContextIndex) { + if (orderContextIndex == null || orderContextIndex <= 0) { + throw new IllegalArgumentException("V4 order_context_index 必须是一基正整数。"); + } + return orderContextIndex; + } + + private Integer normalizeSourceEventIndex(ReservationV4TaskCardDraft draft) { + boolean eventCard = draft.aiTransitionId() != null || hasText(draft.eventType()); + Integer sourceEventIndex = draft.sourceEventIndex(); + if (eventCard) { + if (sourceEventIndex == null || sourceEventIndex <= 0) { + throw new IllegalArgumentException("V4 event 卡 source_event_index 必须是一基正整数。"); + } + return sourceEventIndex; + } + if (sourceEventIndex != null && !sourceEventIndex.equals(NON_EVENT_SOURCE_EVENT_INDEX)) { + throw new IllegalArgumentException("V4 非 event 卡 source_event_index 必须固定为 0。"); + } + return NON_EVENT_SOURCE_EVENT_INDEX; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private ReservationV4TaskCardSnapshot toTaskCardSnapshot(ReservationV4TaskCardEntity entity) { + return new ReservationV4TaskCardSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getV4OrderTaskId(), + entity.getSourceMessageId(), + entity.getAiTransitionId(), + entity.getCardType(), + entity.getEventType(), + entity.getSourceEventIndex(), + entity.getCardSortOrder(), + entity.getCardStatus(), + entity.getReviewStatus(), + entity.getAiPayloadJson(), + entity.getDisplayPayloadJson(), + entity.getConfirmedPayloadJson(), + entity.getReviewResolutionJson(), + entity.getValidationErrorsJson(), + entity.getConfirmedBy(), + entity.getConfirmedAt(), + entity.getVersion(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java new file mode 100644 index 0000000..2f34d98 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4SourceNotificationRepository.java @@ -0,0 +1,51 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Reservation V4 来源通知持久化边界。第一版用于 S10 纯通知的幂等创建、查询和确认。 + */ +public interface ReservationV4SourceNotificationRepository { + + /** + * 按来源通知 ID 查询当前快照。 + */ + Optional findSourceNotificationById( + String hotelId, + Long notificationId); + + /** + * 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。 + */ + Optional findSourceNotificationBySourceMessageAndBatch( + String hotelId, + Long sourceMessageId, + Long aiBatchId); + + /** + * 按 SourceMessage 查询来源通知列表,用于后续邮件通知详情和工作台展示。 + */ + List findSourceNotificationsBySourceMessageId( + String hotelId, + Long sourceMessageId); + + /** + * 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。 + */ + ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(ReservationV4SourceNotificationDraft draft); + + /** + * 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。 + */ + boolean updateNotificationStatusWithVersion( + String hotelId, + Long notificationId, + Long expectedVersion, + String notificationStatus, + String ackBy, + LocalDateTime ackAt); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java new file mode 100644 index 0000000..4929ea7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java @@ -0,0 +1,63 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Reservation V4 订单任务和多卡持久化边界。Service 不直接依赖 Mapper 或 Entity。 + */ +public interface ReservationV4WorkflowRepository { + + /** + * 按 SourceMessage 和 order_ref 查询 V4 订单任务,用于幂等创建和回调重放判断。 + */ + Optional findOrderTaskBySourceMessageAndOrderRef( + String hotelId, + Long sourceMessageId, + String orderRef); + + /** + * 按订单任务 ID 查询 V4 订单任务。 + */ + Optional findOrderTaskById(String hotelId, Long orderTaskId); + + /** + * 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。 + */ + List findOrderTasksBySourceMessageId(String hotelId, Long sourceMessageId); + + /** + * 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。 + */ + ReservationV4OrderTaskSnapshot findOrCreateOrderTask(ReservationV4OrderTaskDraft draft); + + /** + * 创建 V4 任务卡;非 event 卡 source_event_index 固定写入 0。 + */ + ReservationV4TaskCardSnapshot insertTaskCard(ReservationV4TaskCardDraft draft); + + /** + * 按任务卡 ID 查询 V4 任务卡。 + */ + Optional findTaskCardById(String hotelId, Long taskCardId); + + /** + * 查询订单任务下全部 V4 任务卡,并按卡片排序和 event index 返回。 + */ + List findTaskCardsByOrderTaskId(String hotelId, Long orderTaskId); + + /** + * 按 version 乐观锁更新 V4 任务卡状态。 + */ + boolean updateTaskCardStatusWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String cardStatus, + LocalDateTime now); +} diff --git a/server/src/main/resources/db/migration/V23__create_reservation_v4_order_task_card.sql b/server/src/main/resources/db/migration/V23__create_reservation_v4_order_task_card.sql new file mode 100644 index 0000000..e6a3a52 --- /dev/null +++ b/server/src/main/resources/db/migration/V23__create_reservation_v4_order_task_card.sql @@ -0,0 +1,83 @@ +CREATE TABLE workflow_reservation_v4_order_task ( + id BIGINT NOT NULL COMMENT 'V4 订单任务 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID', + source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID', + ai_batch_id BIGINT NOT NULL COMMENT 'AI 回调批次 ID', + order_ref VARCHAR(128) NOT NULL COMMENT 'V4 包内订单引用,同一 source_message_id 下用于聚合同一订单任务', + order_context_index INT NOT NULL COMMENT 'order_contexts[] 中的一基序号,用于同一 SourceMessage 下稳定排序', + order_id BIGINT NULL COMMENT '绑定的本地订单投影 ID,归属未解决时可为空', + target_booking_type VARCHAR(32) NULL COMMENT '目标订单业务类型,例如 GROUP、FIT', + target_locator_type VARCHAR(64) NULL COMMENT '目标订单定位类型,例如 GROUP_CODE、BOOKING_CODE、CONFIRMATION_NUMBER', + target_locator_value VARCHAR(128) NULL COMMENT '目标订单定位值,未解决时为空', + target_resolution_status VARCHAR(32) NOT NULL COMMENT '订单归属解析状态:RESOLVED、UNRESOLVED、CONFLICT', + order_task_status VARCHAR(32) NOT NULL COMMENT '订单任务状态快照:OPEN、COMPLETED;阻塞状态由查询实时派生', + source_received_at DATETIME(6) NOT NULL COMMENT '来源邮件接收 UTC 时间,用于排序', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用', + logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_v4_order_task_source_ref (hotel_id, source_message_id, order_ref), + UNIQUE KEY uk_reservation_v4_order_task_source_index (hotel_id, source_message_id, order_context_index), + KEY idx_reservation_v4_order_task_order (hotel_id, order_id, source_received_at), + KEY idx_reservation_v4_order_task_status (hotel_id, order_task_status, source_received_at), + KEY idx_reservation_v4_order_task_source (hotel_id, source_message_id), + KEY idx_reservation_v4_order_task_locator (hotel_id, target_locator_type, target_locator_value) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 订单任务聚合表'; + +CREATE TABLE workflow_reservation_v4_task_card ( + id BIGINT NOT NULL COMMENT 'V4 任务卡 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID', + v4_order_task_id BIGINT NOT NULL COMMENT 'V4 订单任务 ID', + source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID', + ai_transition_id BIGINT NULL COMMENT 'AI transition ID,非 event 卡可为空', + card_type VARCHAR(64) NOT NULL COMMENT '卡片类型,例如 SOURCE_MESSAGE_DISPLAY、BASIC_INFORMATION、ROOM_INFORMATION', + event_type VARCHAR(64) NULL COMMENT '业务事件类型,非 event 卡为空', + source_event_index INT NOT NULL DEFAULT 0 COMMENT 'message_events[] 中的一基序号;非 event 卡固定为 0', + card_sort_order INT NOT NULL COMMENT '同一订单任务内卡片展示和处理排序', + card_status VARCHAR(32) NOT NULL COMMENT '卡片状态:READONLY、PENDING_CONFIRM、REVIEW_REQUIRED、CONFIRMED', + review_status VARCHAR(32) NULL COMMENT '人工复核状态,非复核卡为空', + ai_payload_json LONGTEXT NOT NULL COMMENT 'AI 原始卡片 payload JSON 快照', + display_payload_json LONGTEXT NULL COMMENT '前端展示用 payload JSON 快照', + confirmed_payload_json LONGTEXT NULL COMMENT '用户最终确认 payload JSON', + review_resolution_json LONGTEXT NULL COMMENT '人工复核解阻结果 JSON', + validation_errors_json LONGTEXT NULL COMMENT '第一版后端校验错误 JSON', + confirmed_by VARCHAR(128) NULL COMMENT '最终确认人标识', + confirmed_at DATETIME(6) NULL COMMENT '最终确认 UTC 时间', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用', + logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_v4_task_card_slot (hotel_id, v4_order_task_id, card_sort_order, source_event_index), + KEY idx_reservation_v4_task_card_task (hotel_id, v4_order_task_id, card_sort_order), + KEY idx_reservation_v4_task_card_source (hotel_id, source_message_id), + KEY idx_reservation_v4_task_card_transition (hotel_id, ai_transition_id), + KEY idx_reservation_v4_task_card_status (hotel_id, card_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 多卡任务表'; + +CREATE TABLE workflow_reservation_v4_source_notification ( + id BIGINT NOT NULL COMMENT 'V4 来源通知 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID', + source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID', + ai_batch_id BIGINT NOT NULL COMMENT 'AI 回调批次 ID', + ai_transition_id BIGINT NULL COMMENT 'AI transition ID,S10 第一版可为空', + route_code VARCHAR(32) NOT NULL COMMENT '包级路由码,第一版用于 S10 来源通知', + notification_status VARCHAR(32) NOT NULL COMMENT '通知状态:ACK_REQUIRED、ACKED', + raw_payload_json LONGTEXT NOT NULL COMMENT '来源通知原始 AI 片段或包级摘要 JSON', + ack_by VARCHAR(128) NULL COMMENT '确认处理人标识', + ack_at DATETIME(6) NULL COMMENT '确认处理 UTC 时间', + source_received_at DATETIME(6) NOT NULL COMMENT '来源邮件接收 UTC 时间,用于排序', + version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用', + logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用', + PRIMARY KEY (id), + UNIQUE KEY uk_reservation_v4_notification_message_batch (hotel_id, source_message_id, ai_batch_id), + KEY idx_reservation_v4_notification_status (hotel_id, notification_status, source_received_at), + KEY idx_reservation_v4_notification_source (hotel_id, source_message_id), + KEY idx_reservation_v4_notification_transition (hotel_id, ai_transition_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 S10 来源通知表'; diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepositoryTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepositoryTest.java new file mode 100644 index 0000000..2c6ac8b --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepositoryTest.java @@ -0,0 +1,274 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:m002_v4_repository;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=", + "auth.bootstrap.admin.password=", + "auth.bootstrap.default-hotel-id=" + }) +@ActiveProfiles("test") +class MybatisReservationV4WorkflowRepositoryTest { + + private static final String HOTEL_ID = "HOTEL-TEST"; + + @Autowired + private ReservationV4WorkflowRepository workflowRepository; + + @Autowired + private ReservationV4SourceNotificationRepository sourceNotificationRepository; + + @Test + void shouldCreateOrderTaskIdempotentlyBySourceMessageAndOrderRef() { + ReservationV4OrderTaskDraft draft = orderTaskDraft(1001L, "order-1", 501L, now()); + + ReservationV4OrderTaskSnapshot first = workflowRepository.findOrCreateOrderTask(draft); + ReservationV4OrderTaskSnapshot second = workflowRepository.findOrCreateOrderTask(draft); + + assertThat(second.id()).isEqualTo(first.id()); + assertThat(workflowRepository.findOrderTaskBySourceMessageAndOrderRef(HOTEL_ID, 1001L, "order-1")) + .get() + .satisfies(snapshot -> { + assertThat(snapshot.hotelId()).isEqualTo(HOTEL_ID); + assertThat(snapshot.orderRef()).isEqualTo("order-1"); + assertThat(snapshot.orderContextIndex()).isEqualTo(1); + assertThat(snapshot.orderTaskStatus()).isEqualTo(ReservationV4OrderTaskStatus.OPEN.name()); + assertThat(snapshot.targetResolutionStatus()) + .isEqualTo(ReservationV4TargetResolutionStatus.RESOLVED.name()); + }); + } + + @Test + void shouldFindOrderTasksBySourceMessageInOrderContextIndexOrder() { + workflowRepository.findOrCreateOrderTask(orderTaskDraft(1010L, "order-later", 510L, 2, now())); + workflowRepository.findOrCreateOrderTask(orderTaskDraft(1010L, "order-earlier", 511L, 1, now())); + + assertThat(workflowRepository.findOrderTasksBySourceMessageId(HOTEL_ID, 1010L)) + .extracting(ReservationV4OrderTaskSnapshot::orderRef) + .containsExactly("order-earlier", "order-later"); + } + + @Test + void shouldStoreNonEventCardWithZeroSourceEventIndexAndRejectDuplicateSlot() { + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask( + orderTaskDraft(1002L, "order-2", 502L, now())); + ReservationV4TaskCardDraft draft = sourceMessageDisplayCardDraft(orderTask.id(), 1002L, now()); + + ReservationV4TaskCardSnapshot card = workflowRepository.insertTaskCard(draft); + + assertThat(card.sourceEventIndex()).isZero(); + assertThat(card.version()).isZero(); + assertThatThrownBy(() -> workflowRepository.insertTaskCard(draft)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void shouldRejectEventCardWithoutPositiveSourceEventIndex() { + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask( + orderTaskDraft(1011L, "order-11", 511L, now())); + + assertThatThrownBy(() -> workflowRepository.insertTaskCard( + eventCardDraftWithoutSourceEventIndex(orderTask.id(), 1011L, 2011L, now()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("source_event_index"); + } + + @Test + void shouldUpdateTaskCardStatusWithExpectedVersion() { + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask( + orderTaskDraft(1003L, "order-3", 503L, now())); + ReservationV4TaskCardSnapshot card = workflowRepository.insertTaskCard( + businessCardDraft(orderTask.id(), 1003L, 2003L, now())); + LocalDateTime updatedAt = now().plusMinutes(1); + + boolean updated = workflowRepository.updateTaskCardStatusWithVersion( + HOTEL_ID, + card.id(), + 0L, + ReservationV4CardStatus.CONFIRMED.name(), + updatedAt); + + assertThat(updated).isTrue(); + ReservationV4TaskCardSnapshot afterUpdate = workflowRepository.findTaskCardById(HOTEL_ID, card.id()).orElseThrow(); + assertThat(afterUpdate.cardStatus()).isEqualTo(ReservationV4CardStatus.CONFIRMED.name()); + assertThat(afterUpdate.version()).isEqualTo(1L); + assertThat(workflowRepository.updateTaskCardStatusWithVersion( + HOTEL_ID, + card.id(), + 0L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + updatedAt.plusMinutes(1))).isFalse(); + } + + @Test + void shouldCreateSourceNotificationIdempotentlyAndAckWithVersion() { + ReservationV4SourceNotificationDraft draft = sourceNotificationDraft(1004L, 704L, now()); + + ReservationV4SourceNotificationSnapshot first = sourceNotificationRepository.findOrCreateSourceNotification(draft); + ReservationV4SourceNotificationSnapshot second = sourceNotificationRepository.findOrCreateSourceNotification(draft); + LocalDateTime ackAt = now().plusMinutes(2); + + assertThat(second.id()).isEqualTo(first.id()); + assertThat(first.notificationStatus()).isEqualTo(ReservationV4NotificationStatus.ACK_REQUIRED.name()); + assertThat(first.version()).isZero(); + assertThat(sourceNotificationRepository.updateNotificationStatusWithVersion( + HOTEL_ID, + first.id(), + 0L, + ReservationV4NotificationStatus.ACKED.name(), + "user-1", + ackAt)).isTrue(); + assertThat(sourceNotificationRepository.updateNotificationStatusWithVersion( + HOTEL_ID, + first.id(), + 0L, + ReservationV4NotificationStatus.ACK_REQUIRED.name(), + "user-2", + ackAt.plusMinutes(1))).isFalse(); + + ReservationV4SourceNotificationSnapshot afterAck = + sourceNotificationRepository.findSourceNotificationById(HOTEL_ID, first.id()).orElseThrow(); + assertThat(afterAck.notificationStatus()).isEqualTo(ReservationV4NotificationStatus.ACKED.name()); + assertThat(afterAck.ackBy()).isEqualTo("user-1"); + assertThat(afterAck.ackAt()).isEqualTo(ackAt); + assertThat(afterAck.version()).isEqualTo(1L); + } + + private ReservationV4OrderTaskDraft orderTaskDraft( + Long sourceMessageId, + String orderRef, + Long orderId, + LocalDateTime now) { + return orderTaskDraft(sourceMessageId, orderRef, orderId, 1, now); + } + + private ReservationV4OrderTaskDraft orderTaskDraft( + Long sourceMessageId, + String orderRef, + Long orderId, + Integer orderContextIndex, + LocalDateTime now) { + return new ReservationV4OrderTaskDraft( + HOTEL_ID, + sourceMessageId, + 9001L, + orderRef, + orderContextIndex, + orderId, + "GROUP", + "GROUP_CODE", + "GRP-" + orderRef, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4OrderTaskStatus.OPEN.name(), + now.minusMinutes(5), + now); + } + + private ReservationV4TaskCardDraft sourceMessageDisplayCardDraft( + Long orderTaskId, + Long sourceMessageId, + LocalDateTime now) { + return new ReservationV4TaskCardDraft( + HOTEL_ID, + orderTaskId, + sourceMessageId, + null, + ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), + null, + null, + 10, + ReservationV4CardStatus.READONLY.name(), + null, + "{\"source\":\"ai\"}", + "{\"display\":\"mail\"}", + null, + now); + } + + private ReservationV4TaskCardDraft eventCardDraftWithoutSourceEventIndex( + Long orderTaskId, + Long sourceMessageId, + Long transitionId, + LocalDateTime now) { + return new ReservationV4TaskCardDraft( + HOTEL_ID, + orderTaskId, + sourceMessageId, + transitionId, + ReservationV4CardType.ROOM_INFORMATION.name(), + "NEW_BOOKING", + null, + 30, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + null, + "{\"event\":\"new_booking\"}", + "{\"display\":\"room\"}", + null, + now); + } + + private ReservationV4TaskCardDraft businessCardDraft( + Long orderTaskId, + Long sourceMessageId, + Long transitionId, + LocalDateTime now) { + return new ReservationV4TaskCardDraft( + HOTEL_ID, + orderTaskId, + sourceMessageId, + transitionId, + ReservationV4CardType.ROOM_INFORMATION.name(), + "NEW_BOOKING", + 1, + 30, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + null, + "{\"event\":\"new_booking\"}", + "{\"display\":\"room\"}", + null, + now); + } + + private ReservationV4SourceNotificationDraft sourceNotificationDraft( + Long sourceMessageId, + Long transitionId, + LocalDateTime now) { + return new ReservationV4SourceNotificationDraft( + HOTEL_ID, + sourceMessageId, + 9004L, + transitionId, + "S10", + ReservationV4NotificationStatus.ACK_REQUIRED.name(), + "{\"route_code\":\"S10\"}", + now.minusMinutes(3), + now); + } + + private LocalDateTime now() { + return LocalDateTime.of(2026, 7, 18, 8, 30, 0); + } +}