From 88f98a0eb658add30227a007d1c609609c4e6e74 Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 14:36:11 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0V4=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=8ELookup=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 18 +- docs/project/README.md | 7 +- .../backend-to-frontend-notes.md | 16 +- .../frontend-to-backend-api-requests.md | 3 + docs/project/go-live-notes.md | 4 + .../M002-order-task-workflow-v3.md | 8 +- .../M002-v4-agent-callback-field-contract.md | 18 +- ...002-v4-order-task-card-domain-model-cp2.md | 28 +- .../M002-v4-real-catalog-lookup-api-design.md | 494 ++++++++++++++++++ .../security-access-control-boundary.md | 8 +- .../dto/ReservationV4AccountCatalogItem.java | 2 +- .../ReservationV4CatalogAccountSnapshot.java | 33 ++ .../dto/ReservationV4CatalogCodeSnapshot.java | 33 ++ .../ReservationV4CatalogSourceSystem.java | 10 + .../enums/ReservationV4CatalogStatus.java | 9 + .../enums/ReservationV4CatalogType.java | 12 + .../ReservationV4CatalogLookupRequest.java | 19 + .../ReservationV4CatalogLookupItemResult.java | 41 ++ .../ReservationV4CatalogLookupResult.java | 32 ++ .../ReservationTaskControllerAdvice.java | 1 + .../ReservationV4CatalogLookupController.java | 87 +++ .../ReservationCatalogAccountEntity.java | 84 +++ .../domain/ReservationCatalogCodeEntity.java | 84 +++ .../ReservationCatalogAccountMapper.java | 12 + .../mapper/ReservationCatalogCodeMapper.java | 12 + ...MybatisReservationV4CatalogRepository.java | 171 ++++++ .../ReservationV4CatalogRepository.java | 41 ++ .../ReservationV4CatalogLookupService.java | 25 + .../ReservationV4DirectoryService.java | 14 +- ...ixedReservationV4DirectoryServiceImpl.java | 70 --- ...ReservationV4CatalogLookupServiceImpl.java | 296 +++++++++++ .../impl/ReservationV4CommandServiceImpl.java | 55 +- ...rvationV4DatabaseDirectoryServiceImpl.java | 59 +++ .../impl/ReservationV4QueryServiceImpl.java | 7 +- .../ReservationV4TaskIntakeServiceImpl.java | 42 +- ...V24__create_reservation_catalog_tables.sql | 185 +++++++ ...ervationV4CatalogLookupControllerTest.java | 147 ++++++ .../ReservationV4CommandControllerTest.java | 26 + .../SuperAgentTaskResultControllerTest.java | 29 + 39 files changed, 2075 insertions(+), 167 deletions(-) create mode 100644 docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogAccountSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogCodeSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogSourceSystem.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogStatus.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogType.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogLookupRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupItemResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupController.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationCatalogAccountEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationCatalogCodeEntity.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationCatalogAccountMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationCatalogCodeMapper.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CatalogLookupService.java delete mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4DatabaseDirectoryServiceImpl.java create mode 100644 server/src/main/resources/db/migration/V24__create_reservation_catalog_tables.sql create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index a015f85..be99dea 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验和订单详情时间线补齐并行 | -| 当前重点 | M002 V4 已在旧订单详情接口补齐 `v4_order_tasks[]` 订单任务时间线,保留旧 `tasks[]` 兼容;后续准备做 V4 前端页面或真实目录 / PMS lookup | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录和 Lookup API 并行 | +| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端;后续可做 V4 前端 lookup 接入、目录管理后台或真实 PMS / OPERA / OHIP 同步 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-order-detail-timeline-completion-v1` -- 状态:Done,已在 `GET /api/reservation/orders/{orderId}` 响应中新增 V4 `v4_order_tasks[]` 订单任务时间线,保留旧 `tasks[]` 兼容,并按订单酒店过滤 V4 数据。 -- 目标:让订单详情页在旧 V2/V3 任务时间线之外,可以看到同订单下 V4 order task 摘要、卡片数量、来源邮件安全摘要和最新活动时间。 -- 边界:本轮不做前端页面、不做真实 OPERA / OHIP、不做普通任务切换订单、不废弃旧 V2/V3 接口、不接真实目录 lookup。 +- 名称:`M002-V4-CP11-db-catalog-lookup-api-v1` +- 状态:Done,已新增 `workflow_reservation_catalog_account`、`workflow_reservation_catalog_code`,将 `QBD_TRAVEL`、`LIAN_TAI`、`HANATOUR_TD`、Room Type、Rate Code、Market、Source 固定种子导入数据库,并开放 Account / Room Type / Rate Code lookup API。 +- 目标:让 V4 任务详情字段控件不再依赖前端硬编码选项,后端入站、确认、复核统一按当前酒店数据库目录做校验。 +- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理后台,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不做前端页面。 ## 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 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成固定种子目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验。V4 前端页面仍未完成代码实现。 -- M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 第一版使用固定种子数据;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录。V4 前端页面、目录管理后台、真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 +- M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Account / Market / Source 当前通过数据库目录读取和派生;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps -- 后续如继续做 M002 V4,应优先进入 V4 前端页面接入 checkpoint,或进入真实目录 / PMS lookup / OPERA 映射前置设计 checkpoint。 +- 后续如继续做 M002 V4,可优先进入 V4 前端 lookup / 多卡页面接入 checkpoint,或进入目录管理后台、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/docs/project/README.md b/docs/project/README.md index d95496a..68e418c 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -45,8 +45,9 @@ | `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/S99 和校验口径;后端已完成 V4 入站解析 CP1、CP3 持久化基线、CP4 入站写入新模型和 CP5 查询接口,V4 写接口仍待后续实现。 | -| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3 表结构、Entity、Mapper、Repository、CP4 入站写入新模型和 CP5 查询接口已落地状态;后续仍需实现卡片确认 / 复核、S10/S99 ack 和前端页面。 | +| `requirements/M002-v4-agent-callback-field-contract.md` | 当前有效 | M002 V4 Agent 回调字段契约,基于 2026-07-18 业务基线和最新答复,冻结 `source_message`、`order_contexts`、`message_events`、订单级 Basic Information、六类 Event、S10/S99 和校验口径;后端已完成 V4 入站解析、持久化、查询、确认、复核和当前酒店数据库目录校验。 | +| `requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前有效 | M002 V4 CP2 订单任务与多卡领域模型设计,并记录 CP3-CP8 表结构、入站写入、查询、确认、复核和目录校验已落地状态;CP11 已完成 DB 目录与 lookup API,后续仍需前端页面、目录管理后台和真实 PMS 同步。 | +| `requirements/M002-v4-real-catalog-lookup-api-design.md` | 当前有效 | M002 V4 真实目录与 Lookup API 设计及 CP11 实现记录,记录 Account、Market、Source、Room Type、Rate Code 从固定种子导入数据库、前端 lookup API、权限、缓存后置、PMS / OPERA / OHIP 同步后置和失败兜底。 | | `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 +92,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`;M002 V4 CP3 已落地 V4 订单任务、任务卡、来源通知表结构和 Repository 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,CP5 已落地工作台、订单任务和来源通知查询接口,后续仍需实现卡片确认 / 复核、S10/S99 ack 和前端页面模型。 +- 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 基线,CP4 已落地普通 V4 业务包和 S10/S99 来源通知入站写入新模型,CP5 已落地工作台、订单任务和来源通知查询接口,CP6/CP7 已落地卡片确认、S10/S99 ack 和复核解阻,CP11 已落地 DB 目录与 lookup API;后续仍需前端页面模型、目录管理后台和真实 PMS 同步。 - 前端展示 / 编辑字段以 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 2ce4b6f..cd7e5a4 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -58,7 +58,10 @@ | `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id`、`order_id`、`order_task_status`、`card_status`、`keyword`、`page_num`、`page_size`;`order_task_status` 非 `OPEN` / `COMPLETED` 返回 400,`card_status` 非 V4 卡状态返回 400;`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 | | `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会做 Account / Room Type / Rate Code 第一版目录校验,嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | +| `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,支持 `hotel_id`、`keyword`、`page_num`、`page_size`;返回统一 wrapper:`hotel_id`、`catalog_type=ACCOUNT`、`catalog_source`、`catalog_version`、`stale`、`items[]`、`page`、`warnings[]`。前端在 `options_source=reservation_v4_account_catalog` 时调用,只提交 `items[].code`,Market / Source 以后端确认派生结果为准。 | +| `GET /api/reservation/lookups/room-types` | 查询 V4 Room Type 目录 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,支持 `hotel_id`、`keyword`、`page_num`、`page_size`;第一版只返回当前酒店 `ACTIVE` 房型目录,不接日期过滤,不代表 PMS 全量房型。前端在 `options_source=reservation_v4_room_type_catalog` 时调用。 | +| `GET /api/reservation/lookups/rate-codes` | 查询 V4 Rate Code 目录 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,支持 `hotel_id`、`keyword`、`page_num`、`page_size`;第一版只返回当前酒店 `ACTIVE` Rate Code,`pricing_available=false` 表示后端未接真实价格,不要据此展示价格。前端在 `options_source=reservation_v4_rate_code_catalog` 时调用。 | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code,嵌套字段错误会返回如 `business_fields.after.room_items.0.room_type_code` 的路径,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;目录错误字段可按 `validation_errors_json` / `fields[].validation_errors` 指向的 pointer 修正;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 | | `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 | | `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要,此时旧 `tasks[]` 和 V4 `v4_order_tasks[]` 都为空数组;旧 `tasks[]` 按后端队列顺序返回,前端不要自行按创建时间重排;V4 `v4_order_tasks[]` 按同订单 V4 订单任务来源时间正序返回;隐藏技术订单详情不可作为普通订单页打开。 | @@ -96,6 +99,7 @@ | `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | | `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留、安全 HTML 字段和入口通知识别。 | 只用于调试页面;请求为 multipart/form-data;必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报;SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。 | | `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口,并在 CP6 打开卡片确认 / 来源通知 ack availability。 | 这是 V4 新模型前端主入口;前端应按每张卡或通知返回的 `availability.confirmable`、`availability.ackable`、`readonly_reason_code` 控制按钮。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 | +| `GET /api/reservation/lookups/accounts` / `/room-types` / `/rate-codes` | 新增 M002 V4 CP11 数据库目录 lookup。 | 前端从任务详情 `fields[].options_source` 选择调用哪个 lookup;只提交返回项的 `code`,不要提交显示名、派生 Market / Source、目录完整对象或前端自造 code。`warnings[]` 非空时可做非阻塞提示。 | V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100;`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。`order_task_status` 和 `card_status` 是稳定枚举查询参数,前端不要传中文文案或自造状态码。 @@ -472,7 +476,7 @@ RESERVATION_ROOMING_LIST_GENERATE - V4 复核解阻应使用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`,请求带 `version`;`field_overrides[]` 只使用当前卡 `fields[].field_pointer`,订单任务归属未解决时允许用户填写 `confirmed_order_id`。 - 所有按钮应按后端 `availability.confirmable`、`availability.reviewable`、`availability.ackable` 和前端权限共同控制;不可操作原因优先展示 `readonly_reason_message`,否则按 `readonly_reason_code` 做友好映射。 - `fields[].validation_errors` 应展示在对应字段旁边;接口返回 `V4_FIELD_VALIDATION_FAILED` 且 `details[]` 带嵌套路径时,前端应尝试定位到对应 field,定位不到则在当前卡片动作错误区展示。 -- `options_source` 第一版仍按前端固定种子渲染 Account / Room Type / Rate Code,不调用真实目录 lookup API。 +- `options_source=reservation_v4_account_catalog`、`reservation_v4_room_type_catalog`、`reservation_v4_rate_code_catalog` 时,前端应调用对应 lookup API,不再硬编码固定种子;如果 lookup 返回空列表或 `warnings[]`,前端展示非阻塞提示,但提交时仍以后端目录校验为准。 - 前端不展示 `ai_payload_json`、邮件完整正文、附件 URL、raw evidence 或 SuperAgent 原始 payload;来源邮件详情仍从邮件会话页面查看,卡片内仅展示后端普通接口返回的安全摘要、邮件片段和附件名称。 - V4 来源消息卡读取 `attachments`、`uploaded_media`、`file_references` 时,只允许展示附件名称、类型和大小等安全摘要;如果后端 payload 中异常出现 `https://`、`oss://`、`s3://` 等直接 URL 字符串,前端必须替换为“未命名附件”或隐藏,不得把 URL 渲染到普通业务页面。 - V4 主流程不调用旧 V2/V3 草稿、旧任务确认、旧同卡复核接口,也不展示 OPERA 模拟操作入口。 @@ -491,20 +495,20 @@ RESERVATION_ROOMING_LIST_GENERATE - 普通任务切换订单接口继续后置。 - M002 V4 入站解析与数据模型基线已完成第一版:后端可接收 `source_message + order_contexts[] + message_events[]`,识别 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`,并保存 V4 原始 payload、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。前端暂不需要直接调用 V4 回调接口。 -- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口;M002 V4 CP7 已开放 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻接口;M002 V4 CP8 已开放 V4 卡片 `fields[]` 白名单和第一版固定目录校验。 +- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口;M002 V4 CP7 已开放 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻接口;M002 V4 CP8 已开放 V4 卡片 `fields[]` 白名单和目录校验;M002 V4 CP11 已把固定种子迁移到数据库目录,并开放 Account / Room Type / Rate Code lookup API。 - V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染;前端对来源消息卡附件字段仍做 URL-like 文本兜底脱敏。 - V4 可映射 event 现阶段仍保留现有任务详情结构作为过渡兼容;任务详情中若出现 `field_contract_version=20260718-v4` 或 AI payload 内的 `v4_source_message`、`v4_order_context`、`v4_message_event`,前端第一版只读展示即可,不要据此假定完整 V4 多卡页面已经完成。 - V4 `PAYMENT.attachment_ids[]` 不匹配、`UPDATE_BOOKING` 携带 `rate_code` 等问题会出现在任务详情同批次的 `adapter_contract_errors[]` 只读诊断块中,不展示保存、确认、执行或重试按钮。该字段只返回白名单诊断字段,不返回完整 AI payload、邮件正文、附件 URL 或 raw evidence。 - 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 工作台统一列表、业务订单任务列表 / 详情和 S10/S99 来源通知详情已实现。 -- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认;M002 V4 CP8 已开放固定种子目录校验和 V4 任务卡 `fields[]` 字段白名单。 +- M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认;M002 V4 CP8 已开放目录校验和 V4 任务卡 `fields[]` 字段白名单;M002 V4 CP11 已新增目录表、数据库种子和 lookup API。 - V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false`、`editable=true`、`confirmable=false`、`reviewable=true`、`readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`。 -- CP8 起 V4 入站阶段也会做目录校验:Account 缺失或不存在时 Basic Information 卡直接 `REVIEW_REQUIRED`;业务卡已有 `room_items[].room_type_code` 或 `rate_code` 但不在固定目录时,业务卡也会直接 `REVIEW_REQUIRED`,错误会回显在 `fields[].validation_errors`。 +- CP11 起 V4 入站阶段按当前酒店数据库目录做校验:Account 缺失或不存在时 Basic Information 卡直接 `REVIEW_REQUIRED`;业务卡已有 `room_items[].room_type_code` 或 `rate_code` 但不在当前酒店目录时,业务卡也会直接 `REVIEW_REQUIRED`,错误会回显在 `fields[].validation_errors`。 - CP8 确认接口也按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order`、`order_ref`、`manual_review`、校验诊断字段以及前端额外注入字段不会写入 `confirmed_payload_json`。 - 业务卡目录校验会递归检查 `business_fields` 下的嵌套结构。例如 `UPDATE_BOOKING` 的房型可能位于 `/business_fields/after/room_items/0/room_type_code`,错误详情会使用 `business_fields.after.room_items.0.room_type_code`;前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]`。 - `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/business_fields/room_items/0/pms_room_type_code","value":"RM2"}]}`。`confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID,不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡允许编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段;展示 payload 有 `missing_fields[]` 时只提交清单里的 pointer,没有显式清单时只提交当前值为 `null` / 空字符串的未解决叶子字段;如果是目录校验错误,也可以提交后端 `fields[].validation_errors` 对应的字段 pointer。前端不要提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段,也不能替换整个对象 / 数组。 - V4 `fields[]` 第一版字段说明:Basic Information 固定返回 `/basic_information/account_code`、`/basic_information/market_code`、`/basic_information/source_code`;其中 Account `control_type=select`、`options_source=reservation_v4_account_catalog`,Market / Source 为只读派生字段。业务卡会按展示 payload 里的业务叶子字段返回字段白名单,例如 `/room_items/0/room_type_code` 或 `/business_fields/room_items/0/pms_room_type_code`;前端不要自行补未返回字段。 -- V4 CP8 仍没有独立目录 lookup API。前端第一版可先按后端文档固定种子渲染 Account / Room Type / Rate Code 选项;后续真实目录或管理后台目录接入时再切换为 lookup。用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象;后端确认前仍会重新校验目录。 +- V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts`、`GET /api/reservation/lookups/room-types`、`GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象;后端确认前仍会重新校验目录。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 - V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。 - 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/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index 9a5bd74..0393d73 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -20,6 +20,7 @@ | P1 | type-known manual review 同卡复核解阻 | 任务详情复核 | 已完成第一版:原业务任务卡复核、字段修正、订单归属确认、READY 流转 | | P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 | | P1 | 系统管理菜单树增强接口 | 系统设置 / 菜单管理树形交互 | 已完成:完整菜单树查询、批量保存父级和排序 | +| Done | V4 目录 Lookup API | V4 Basic Information、Room Type、Rate Code 选择 | M002 V4 CP11 已实现 Account / Room Type / Rate Code 数据库目录 lookup;设计与实现说明见 `../requirements/M002-v4-real-catalog-lookup-api-design.md` | | 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 未完成;已确认后置 | ## 2.1 后端当前接口完成度核对 @@ -48,6 +49,7 @@ | `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | | `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 第一版不做独立接口;旧 S000/S999 已通过 `SOURCE_MESSAGE_ONLY` 任务展示,0711 P0 新 S10/S99 也继续复用任务列表 / 任务详情只读展示。 | | `GET /api/reservation/task-card-field-whitelist` | 未发现后端实现 | 不可以 | 若任务详情 `fields[]` 已补齐 3.0 元数据,可后置。 | +| `GET /api/reservation/lookups/accounts` / `room-types` / `rate-codes` | M002 V4 CP11 已实现 | 可以 | 用于 V4 任务卡下拉 / 搜索选择;Bearer token + `RESERVATION_TASK_READ` + 酒店访问权;支持 `hotel_id`、`keyword`、`page_num`、`page_size`,第一版只返回 ACTIVE 目录。 | ## 3. 任务列表 / 工作台接口字段补齐 @@ -1097,3 +1099,4 @@ POST /api/reservation/tasks/{taskId}/order-binding - `manual-review-resolutions` 成功响应中的 `opera_operations[]` 数量请后端最终确认;前端不写死两条,只按返回内容刷新展示。 - 系统管理菜单树增强接口已完成:`GET /api/admin/menus/tree`、`PUT /api/admin/menus/tree-order`。 - Manual Invoice 第一阶段的客户 / 联系人目录来源、模板初始文件、VAT 配置和生成记录是否必须落库,已在 M009 中列为开发前确认项。 +- V4 真实目录与 Lookup API 第一版已在后端 CP11 落地。前端后续可以按 `GET /api/reservation/lookups/accounts`、`GET /api/reservation/lookups/room-types`、`GET /api/reservation/lookups/rate-codes` 接入 V4 字段选择控件;真实 PMS 同步、目录管理后台和 SuperAgent 目录机器接口仍后置。 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 4f7893f..5afe102 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -52,6 +52,7 @@ - 源邮件只读通知卡上线前,必须确认 `platform_hotel` 中存在且只存在一家 `ACTIVE` 酒店,并且已有 SourceMessage Inbox 数据的 `hotel_id` 与该酒店一致。旧 `S000/S999` 和新结构化 `S10/S99` 都沿用该酒店解析约束。 - 系统管理后台上线前,必须确认至少存在一个 `ACTIVE` 超级管理员账号,且该账号拥有 `SYSTEM_ADMIN_CONSOLE_ACCESS` 和各系统管理权限。 - 单酒店阶段上线前,必须确认 `platform_hotel` 中只有一家 `ACTIVE` 酒店;新增酒店可以存在但应保持 `DISABLED`。 +- M002 V4 lookup 上线前,必须确认当前 `platform_hotel` 唯一 `ACTIVE` 酒店有可用目录数据。V24 会初始化 `HOTEL-TEST`、`HOTEL-DEV`,并给迁移执行时已经存在的 `ACTIVE` 酒店导入固定种子;如果生产真实酒店是在迁移后由 bootstrap 创建,或不能使用固定种子作为业务事实源,必须补正式目录数据或新增导入脚本。 - 管理后台启用后,不要继续把手工改库作为常规运营方式;用户、角色、菜单和酒店变更应通过 `/api/admin/**` 并写入管理审计。 - 原文读取接口开启前,已经确认谁可以使用、在哪些场景使用、如何轮换访问 key。 - 日志采集、错误响应和监控面板都不会展示邮件正文、HTML、附件 URL、Token、Cookie、客户姓名、邮箱、电话或支付信息。 @@ -245,6 +246,8 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系: - `server/src/main/resources/db/migration/V11__add_reservation_order_visibility.sql` - `server/src/main/resources/db/migration/V16__add_m002_v3_ai_route_fields.sql` - `server/src/main/resources/db/migration/V21__add_reservation_order_latest_activity.sql` +- `server/src/main/resources/db/migration/V23__create_reservation_v4_order_task_card.sql` +- `server/src/main/resources/db/migration/V24__create_reservation_catalog_tables.sql` 当前 M004 Debug EML 相关 migration: @@ -277,6 +280,7 @@ SourceMessage 原文和邮件会话完整正文已迁移到登录权限体系: - 目标数据库为空库或 Flyway history 与当前代码一致。 - 如果某个环境已经在缺少 V10 的临时提交上执行过 V11 / V12,不能直接用默认 Flyway 策略补跑 V10;应先重建测试库,或按运维窗口明确 out-of-order / repair 策略。 - V21 会为 `workflow_reservation_order` 增加 `latest_activity_at`,并按订单更新时间和历史任务最新来源 / 创建时间回填一次;上线后订单列表依赖该字段排序,不再在列表查询时聚合全量任务。发布后需要确认 Flyway 已执行到 V21,且订单列表能按最新业务活动倒序返回。 +- V24 会新增 `workflow_reservation_catalog_account` 和 `workflow_reservation_catalog_code`,并初始化 `HOTEL-TEST`、`HOTEL-DEV` 以及迁移执行时已有 `ACTIVE` 酒店的 Account、Market、Source、Room Type、Rate Code 种子目录。发布后需要确认 Flyway 已执行到 V24,且当前酒店 lookup 能返回目录项;真实 PMS 同步、目录管理后台和 `workflow_reservation_catalog_sync_run` 仍未实现。 - V22 会新增 `workflow_reservation_invoice_generation`,用于记录 Manual Invoice 生成状态、Excel / PDF OSS 对象、金额摘要和安全错误摘要;发布后需要确认 `RESERVATION_INVOICE_GENERATE` 权限已由启动同步写入平台权限表,预订操作员或目标角色已拥有该权限。 - MySQL 版本满足项目要求,默认使用 MySQL 8.0+。 - migration 在 UAT 或测试库已经跑过。 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index 9b13d70..27f7aca 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -444,7 +444,7 @@ V3 P0.1 不做以下事项: - M002 V4 CP4 已补充新模型写入:普通 V4 业务包会额外创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;只有契约错误、没有合法业务 event 的包不会创建 V4 订单任务。 - M002 V4 CP4 后,V4 `route_code=S10/S99` 写入 `workflow_reservation_v4_source_notification`,不再创建隐藏技术订单或旧任务;V3 S10/S99 和旧 S000/S999 仍保留历史兼容链路。 - M002 V4 CP5 已开放 V4 查询接口:`GET /api/reservation/workbench-items`、`GET /api/reservation/order-tasks`、`GET /api/reservation/order-tasks/{orderTaskId}`、`GET /api/reservation/source-notifications/{notificationId}`。 -- M002 V4 CP6/CP7/CP8 已开放 V4 普通卡片确认、S10/S99 来源通知 ack、`REVIEW_REQUIRED` 卡复核解阻、复核场景订单归属确认、固定种子目录校验和 V4 卡片 `fields[]` 字段白名单。 +- M002 V4 CP6/CP7/CP8/CP11 已开放 V4 普通卡片确认、S10/S99 来源通知 ack、`REVIEW_REQUIRED` 卡复核解阻、复核场景订单归属确认、当前酒店数据库目录校验、V4 卡片 `fields[]` 字段白名单和 Account / Room Type / Rate Code lookup API。 - V4 包级契约错误在 `source_message.source_message_id` 可定位时只写 `adapter_contract_error` transition,不创建订单、任务或用户可处理卡;`source_message_id` 缺失或 SourceMessage 不存在时仍返回明确错误。 - V4 `PAYMENT.attachment_ids[]` 必须匹配 `source_message.attachments[].id`;V4 `UPDATE_BOOKING` 不接受 `rate_code` 或 `after.rate_code`;这类契约错误只落 `adapter_contract_error` transition,不创建用户可处理业务任务。 - 40 条 P0.1 路由枚举 / 稳定配置。 @@ -477,13 +477,13 @@ M002 V4 CP2 设计文档已落地: - 文档路径:`docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`。 - 设计内容:SourceMessage 邮件展示卡、S10/S99 来源通知、`source_message_id + order_ref` 订单任务、Basic Information 独立卡、每个 V4 event 的业务卡、卡片确认 / 复核 / 锁定、同订单阻塞、表结构草案和后续接口草案。 -- 已确认:V4 工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知使用 `/api/reservation/source-notifications/**`;S10/S99 采用来源通知模型;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认,其它业务卡第一版不强制逐张确认;Account / Market / Source 目录第一版使用后端固定种子数据。 -- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型、CP5 查询接口、CP6 普通卡片确认 / S10/S99 ack、CP7 复核解阻和 CP8 固定目录 / 字段白名单已完成;V4 前端页面仍未实现。 +- 已确认:V4 工作台统一列表新开 `/api/reservation/workbench-items`,业务订单任务新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知使用 `/api/reservation/source-notifications/**`;S10/S99 采用来源通知模型;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认,其它业务卡第一版不强制逐张确认;Account / Market / Source 目录当前按数据库目录读取和派生,真实目录与 Lookup API 设计见 `M002-v4-real-catalog-lookup-api-design.md`。 +- 当前状态:CP3 表结构 / Repository、CP4 入站写入新模型、CP5 查询接口、CP6 普通卡片确认 / S10/S99 ack、CP7 复核解阻、CP8 目录校验 / 字段白名单和 CP11 DB 目录 / lookup API 已完成;V4 前端页面仍未实现。 仍需后续 checkpoint 实现: - V4 表结构和 Repository 落地已完成第一版:新增 V4 订单任务表、V4 任务卡表和 V4 来源通知表,并提供 Entity、Mapper、Repository、幂等创建、`order_context_index` 稳定排序、非 event 卡 `source_event_index=0` 和 version 乐观锁更新基础方法。 - V4 入站写入新模型已完成第一版:真正创建 SourceMessage 展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知。 - V4 前端页面模型切换。 -- 真实 PMS 目录、Rate Code 配置中心或通用 lookup API 接入;当前 CP8 只是后端固定种子目录第一版。 +- 真实 PMS 目录同步、Rate Code 价格 / 适用范围配置中心、目录管理后台和 SuperAgent 目录机器接口仍后置;当前 CP11 只是把固定种子导入数据库并开放前端 lookup API,不代表已接 PMS 全量目录。 - 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。 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 0de1ae4..ae45989 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.5 | | 日期 | 2026-07-19 | -| 状态 | 当前 V4 字段基线;后端已完成 CP1 入站解析基线、CP2 多卡模型设计、CP3 持久化基线、CP4 入站写入新模型、CP5 查询接口和 CP6 普通卡片确认 / S10/S99 ack | +| 状态 | 当前 V4 字段基线;后端已完成 CP1-CP8,真实目录与 Lookup API 设计已落文档 | | 适用范围 | 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`。截至 CP6,表结构、Entity、Mapper、Repository 基线已经实现,SuperAgent V4 入站已经能写入 V4 订单任务、来源邮件展示卡、Basic Information 卡、业务卡和 S10/S99 来源通知;V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、普通卡片确认和 S10/S99 ack 写接口已实现,V4 复核写接口仍未实现。 +V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP11,表结构、Entity、Mapper、Repository、SuperAgent V4 入站写入、V4 查询、普通卡片确认、S10/S99 ack、V4 复核解阻、数据库目录和 Account / Room Type / Rate Code Lookup API 已实现。真实 PMS 同步和目录管理后台仍后置,方案见 `M002-v4-real-catalog-lookup-api-design.md`。 当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。 @@ -193,13 +193,13 @@ Agent 给出非空 `account_code`,但信息系统运行时目录不存在该 不同 `order_ref` 可以对应不同 Account。 -后端 CP8 第一版已落地固定种子目录: +后端 CP11 第一版已落地当前酒店数据库目录: - Account:`QBD_TRAVEL`、`LIAN_TAI`、`HANATOUR_TD`。 - Market / Source:由 Account 派生,当前分别为 `LEISURE` / `TRAVEL_AGENT`。 - SuperAgent 不需要输出 `market_code`、`source_code`、`account_name`,也不要输出显示名称替代 `account_code`。 - 如果 SuperAgent 输出的 `account_code` 不在上述目录,后端会创建 `REVIEW_REQUIRED` Basic Information 卡,并在 `validation_errors_json` / 查询 `fields[].validation_errors` 中返回目录错误。 -- 如果 SuperAgent 输出的业务卡 `room_items[].room_type_code` 或 `rate_code` 不在上述固定目录,后端会创建 `REVIEW_REQUIRED` 业务卡,并在 `validation_errors_json` / 查询 `fields[].validation_errors` 中返回目录错误;用户可通过 V4 复核解阻接口提交对应字段 pointer 修正。 +- 如果 SuperAgent 输出的业务卡 `room_items[].room_type_code` 或 `rate_code` 不在当前酒店数据库目录,后端会创建 `REVIEW_REQUIRED` 业务卡,并在 `validation_errors_json` / 查询 `fields[].validation_errors` 中返回目录错误;用户可通过 V4 复核解阻接口提交对应字段 pointer 修正。 ## 8. message_events 公共字段 @@ -639,7 +639,7 @@ Account、RoomType、RateCode、Department 的目录由信息系统或其主数 - Agent 输出非空 code、但信息系统目录不存在该值时,属于目录校验或契约问题。 - Market 和 Source 由信息系统根据订单级 `account_code` 派生,不由 Agent 输出。 -当前项目接受“SuperAgent 确定后把目录给本系统”的落地方式。第一阶段可以先以固定种子目录或版本化目录快照对齐;后续如需在线查询或目录同步接口,再另开需求。 +当前项目接受“SuperAgent 确定后把目录给本系统”的落地方式。第一阶段已用固定种子目录完成开发闭环;真实目录、系统管理维护、PMS / OPERA / OHIP 同步、前端 lookup API、缓存、权限和兜底策略已在 `M002-v4-real-catalog-lookup-api-design.md` 中设计。该设计不改变 SuperAgent V4 输入契约:SuperAgent 仍只输出稳定 code,不输出显示名或自由文本。 ## 21. 后端 V4 建模建议 @@ -674,7 +674,7 @@ AI 回调包 | `source_message_id` 映射 | 本项目按 SourceMessage Inbox 的 `external_message_id` 处理 | | 时间格式 | UTC ISO-8601,例如 `2026-07-18T02:10:00Z` | | 正文格式 | 单一 `body` + `body_content_type=text/plain/text/html` | -| code 目录 | 信息系统主数据是唯一事实源,目录同步方式后置 | +| code 目录 | 信息系统数据库目录是唯一运行时事实源;当前目录由固定种子初始化导入,真实 PMS 同步与管理维护见 `M002-v4-real-catalog-lookup-api-design.md` | | 技术异常 channel | 需要独立建设,不进入酒店用户任务体系;当前先保留设计空间 | | Payment 附件 | 正常 Payment 必须 `attachment_ids.length > 0` | | `manual_review` validator | 按各对象条件 Schema 校验,必须能由可识别未解决字段解释 | @@ -682,7 +682,7 @@ AI 回调包 | Fit Booking Code 临时定位 | 当前无 Confirmation Number 时可用 Booking Code 查本地订单投影;第一版不建立 ACTIVE 唯一约束,匹配多条进入人工复核 | | V4 前端资源路径 | 新开 `/api/reservation/order-tasks/**`,不扩展旧 `/api/reservation/tasks/**` 作为 V4 主入口 | | Basic Information 前置 | Basic Information 必须先确认;业务卡之间第一版不强制逐张顺序确认 | -| Account 目录 | 第一版使用信息系统后端固定种子数据 | +| Account 目录 | 第一版使用当前酒店数据库 Account 目录;`FIXED_SEED_IMPORT` 仅表示初始化来源,不代表运行时代码固定兜底 | ## 23. 后续仍需技术对齐 @@ -691,7 +691,7 @@ AI 回调包 1. SuperAgent、Adapter、MCP Schema 和信息系统 DTO 使用同一份 V4 Schema。 2. `body_content_type` 的来源是 AgentBus / 邮件监听层还是 Adapter 派生。 3. `dispatch_run_id`、超时、错误 channel 和技术失败查询入口如何落地。 -4. Account、RoomType、RateCode、Department 目录如何提供给 SuperAgent,以及目录版本如何管理。 +4. Account、RoomType、RateCode、Department 目录如何提供给 SuperAgent,仍需在真实目录实现后确认是离线目录包还是独立机器接口。 5. 如需 `event_id` 或幂等键,应作为 transport 字段设计,不作为业务页面字段。 ## 24. 当前开发结论 @@ -741,7 +741,7 @@ AI 回调包 - `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` 确认 V4 卡片,强制 Bearer 登录、`RESERVATION_TASK_CONFIRM`、酒店访问权和 version 并发校验。 - `POST /api/reservation/source-notifications/{notificationId}/ack` 确认 V4 S10/S99 来源通知已读 / 已处理,强制 Bearer 登录、`RESERVATION_TASK_CONFIRM`、酒店访问权和 version 并发校验。 - CP7 已完成 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻和复核场景订单归属确认。 -- CP8 已完成 Account 固定目录校验、Market / Source 派生、Room Type / Rate Code 第一版固定种子校验和 V4 任务卡 `fields[]` 白名单。 +- CP8 已完成 Account / Room Type / Rate Code 目录校验和 V4 任务卡 `fields[]` 白名单;CP11 已把固定种子导入数据库目录并开放 Account / Room Type / Rate Code lookup API。真实 PMS 同步和目录管理后台仍未实现。 当前仍未完成: 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 fd94248..b4eaa5f 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,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.4 | +| 文档版本 | 0.6 | | 日期 | 2026-07-19 | -| 状态 | CP2 设计已确认;CP3 表结构、Entity、Mapper、Repository 基线已实现;CP4 入站写入新模型已实现;CP5 查询接口和订单详情 V4 时间线已实现;CP6 卡片确认和 S10/S99 ack 已实现 | +| 状态 | CP2 设计已确认;CP3-CP8 已实现;CP11 DB 目录与 Lookup API V1 已实现 | | 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 | | 不适用范围 | V4 复核接口、真实 PMS / OPERA / OHIP、前端页面视觉稿、历史数据迁移 | @@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。 -截至 CP8,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口,以及 Account / Room Type / Rate Code 固定种子目录第一版校验和卡片 `fields[]` 白名单。 +截至 CP11,后端已实现本文第 10、11 节中的持久化基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单,以及 Account / Room Type / Rate Code lookup API。真实 PMS 同步和目录管理后台继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 @@ -26,7 +26,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 | --- | --- | --- | | 入站识别 | 已识别 `route_code`、`source_message`、`order_contexts[]`、`message_events[]` | CP4 已把有合法 event 的 `order_ref` 建成订单任务聚合;CP5 已开放 V4 安全查询接口 | | SourceMessage | 已按 `source_message.source_message_id` 反查 SourceMessage Inbox | CP4 已固定生成普通业务包内邮件展示卡;邮件正文完整读取仍走 SourceMessage 会话接口 | -| Basic Information | 已写入 V4 Basic Information 独立卡 | CP6 已支持确认并锁定;CP7 已支持复核解阻;CP8 已支持 Account 固定目录校验、Market / Source 派生和 `fields[]` 白名单 | +| Basic Information | 已写入 V4 Basic Information 独立卡 | CP6 已支持确认并锁定;CP7 已支持复核解阻;CP8 已支持目录校验和 `fields[]` 白名单;CP11 已改为按当前酒店数据库 Account 目录校验并派生 Market / Source | | 业务 Event | 可映射 event 临时创建旧 `workflow_reservation_task`,并已额外创建 V4 业务卡 | 旧任务链路仍作前端过渡兼容,后续 V4 查询和写接口完成后再逐步废弃 | | 技术错误 | 已落 `adapter_contract_error` transition | 已符合目标方向:不创建用户可处理卡 | | 草稿 / READY / OPERA | 仍复用 V3 草稿、READY 和 OPERA 模拟骨架 | V4 新数据确认口径是不保存草稿、确认后锁定、当前不生成 OPERA | @@ -171,7 +171,7 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 - 不改写 AI 原始 payload。 - 用户修正写入 `review_resolution_json` 和 `confirmed_payload_json`。 -- CP8 起确认和复核都会校验目录字段;Basic Information 的 `account_code` 必须来自第一版 Account 固定目录,通过后后端派生 `market_code` / `source_code`。 +- CP11 起确认和复核都会校验当前酒店数据库目录字段;Basic Information 的 `account_code` 必须来自当前酒店 ACTIVE Account 目录,通过后后端派生 `market_code` / `source_code`。 - 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。 - `field_overrides[].field_pointer` 必须是当前卡 `display_payload_json` 中允许编辑的 RFC 6901 JSON Pointer;如果当前卡展示 payload 中存在显式 `missing_fields[]`,只允许提交该清单内的 pointer;如果没有显式清单,第一版只允许 `basic_information.*` 或 `business_fields.*` 下已经存在且值为 `null` / 空字符串的未解决叶子字段,或后端 `validation_errors_json` 指向的目录错误字段,不允许替换对象或数组。 - 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。 @@ -193,9 +193,9 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 - 用户只能从信息系统已有 Account 目录中选择。 - 后端不得反向篡改 Agent 原始 `basic_information.manual_review`。 -第一版 Account / Market / Source 目录使用后端固定种子数据,不依赖 SuperAgent 动态提供目录文件。后续如目录由管理后台维护或从外部系统同步,应以专项 checkpoint 设计目录版本、变更审计和回放影响。 +CP11 起 Account / Market / Source 目录使用本系统数据库目录,不依赖 SuperAgent 动态提供目录文件。当前初始目录来自固定种子导入,`source_system=FIXED_SEED_IMPORT`;V24 会覆盖 `HOTEL-TEST`、`HOTEL-DEV` 和迁移执行时已有的 `ACTIVE` 酒店。后续 Account / Market / Source 优先由系统管理维护,Room Type / Rate Code 未来优先来自 PMS / OPERA / OHIP 同步,本地目录表和 lookup API 设计见 `M002-v4-real-catalog-lookup-api-design.md`。 -CP8 第一版固定种子: +CP11 数据库初始化种子: | 目录 | 第一版代码 | | --- | --- | @@ -205,7 +205,7 @@ CP8 第一版固定种子: | Room Type | `TWN`、`KING`、`DBL`、`SGL`、`TRP`、`RM1`、`RM2`、`RM3` | | Rate Code | `BAR`、`RACK`、`PACKAGE`、`GROUP`、`FIT` | -说明:Room Type / Rate Code 当前只作为确认和字段控件的第一版校验 / 选项来源代码;尚未接真实 PMS 房型目录、Rate Code 配置中心或通用 lookup API。 +说明:Room Type / Rate Code 当前只作为确认和字段控件的第一版校验 / 选项来源代码;已开放 `GET /api/reservation/lookups/accounts|room-types|rate-codes`,但尚未接真实 PMS 房型目录、Rate Code 配置中心、目录管理后台或同步 run。 ## 8. 订单归属和 target_order @@ -389,7 +389,7 @@ uk_reservation_v4_notification_message_batch(hotel_id, source_message_id, ai_bat | V4 草稿表 | V4 已确认不保存草稿 | | V4 OPERA operation 表 | 当前不做真实 OPERA / OHIP,也不生成 OPERA 模拟 | | 技术错误用户任务表 | 技术错误不进入用户任务体系,先用 AI transition 和 dispatch run | -| 目录表 | Account、Market、Source 第一版已确认用后端固定种子数据;CP2 只设计边界,后续目录 checkpoint 再决定是否建表、导入或管理后台维护 | +| 目录表 | CP11 已新增 `workflow_reservation_catalog_account`、`workflow_reservation_catalog_code`;真实同步 run 和目录管理后台后置 | ## 11. Entity / Mapper / Repository 边界草案 @@ -633,8 +633,8 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm - 请求 JSON 必须携带 `version`;`confirmed_payload` 可选,未传时后端使用当前展示 payload 作为确认快照。 - 前端只应提交当前卡 `fields[]` 中可编辑字段。后端确认时以当前卡展示快照为基准合并 `confirmed_payload`,未出现在展示快照 / 字段白名单中的字段会被忽略,不会写入 `confirmed_payload_json`。 -- Basic Information 确认时 `basic_information.account_code` 必须是第一版 Account 目录值;后端确认前会派生 `account_name`、`market_code` 和 `source_code` 写入 `confirmed_payload_json`。 -- 业务卡确认时,第一版会递归校验已有 `rate_code`、`room_items[].room_type_code` 是否在固定目录中;`UPDATE_BOOKING` 等嵌套结构会返回类似 `business_fields.after.room_items.0.room_type_code` 的错误路径,失败返回 `V4_FIELD_VALIDATION_FAILED`。 +- Basic Information 确认时 `basic_information.account_code` 必须是当前酒店数据库 Account 目录值;后端确认前会派生 `account_name`、`market_code` 和 `source_code` 写入 `confirmed_payload_json`。 +- 业务卡确认时,第一版会递归校验已有 `rate_code`、`room_items[].room_type_code` 是否在当前酒店数据库目录中;`UPDATE_BOOKING` 等嵌套结构会返回类似 `business_fields.after.room_items.0.room_type_code` 的错误路径,失败返回 `V4_FIELD_VALIDATION_FAILED`。 - 不提交草稿。 - 必须带 `version` 做并发校验。 - 后端确认后卡片 `CONFIRMED` 并锁定。 @@ -744,6 +744,10 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 | M002-V4-CP8 | 受控目录第一版 | 已完成第一版:Account 固定目录校验、Market / Source 派生、RoomType / RateCode 固定种子校验、V4 任务卡 `fields[]` 字段白名单 | | M002-V4-CP9 | V4 前端契约收口 | 字段、控件、availability、错误展示和旧任务入口切换 | | M002-V4-CP10 | 旧 V3 / V2 能力收口评估 | 明确哪些兼容入口可以关闭,哪些仍保留只读历史 | +| M002-V4-CP11 | DB 管理目录与 Lookup API V1 | 已完成:新增 Account / Code 目录表、DirectoryService DB 实现、Account / Room Type / Rate Code lookup 查询接口、权限、酒店隔离和测试;同步 run 后置 | +| M002-V4-CP12 | 前端 Lookup 接入 | V4 卡片字段按 `options_source` 调用 lookup,替换固定种子硬编码选项,处理 stale / warning / 空目录 | +| M002-V4-CP13 | 目录管理后台 V1 | Account / Market / Source 管理,临时 Room Type / Rate Code 管理,目录维护权限和管理审计 | +| M002-V4-CP14 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run、最后成功快照、失败重试和同步状态管理入口 | ## 17. 已确认设计决策 @@ -753,7 +757,7 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入 4. S10/S99 采用来源通知模型:任务列表 / 工作台展示,不挂隐藏技术订单;通知详情只显示邮件展示卡和确认按钮。 5. S10/S99 不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。 6. 第一版不强制所有业务卡逐张顺序确认,但 Basic Information 必须先确认。 -7. Basic Information 的 Account / Market / Source 目录第一版使用后端固定种子数据。 +7. Basic Information 的 Account / Market / Source 目录当前使用本系统数据库目录;第一版初始化数据来自固定种子导入,但运行时不再读取后端固定 Map。 8. 普通业务邮件的来源邮件展示卡只读展示,不需要用户确认;用户只确认 Basic Information 和具体业务卡。 9. S10/S99 因为没有业务卡,邮件展示卡需要确认按钮,用来记录已读 / 已处理。 10. V4 新模型落地并完成前端切换后,旧 V2/V3 任务详情、草稿保存和最终确认接口可以逐步废弃。 diff --git a/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md b/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md new file mode 100644 index 0000000..68bae25 --- /dev/null +++ b/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md @@ -0,0 +1,494 @@ +# M002 V4 真实目录与 Lookup API 设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.2 | +| 日期 | 2026-07-19 | +| 状态 | CP11 已落地第一版数据库目录与 lookup API;后续真实 PMS 同步和目录管理后台继续后置 | +| 适用范围 | M002 V4 Account、Market、Source、Room Type、Rate Code 目录来源、数据模型、前端 lookup、缓存、酒店隔离、权限和失败兜底 | +| 不适用范围 | 真实 OPERA / OHIP 写操作、真实价格计算、前端页面实现、SuperAgent Prompt 修改、目录管理后台、SuperAgent 机器目录接口 | + +## 1. 文档定位 + +M002 V4 CP8 已实现第一版固定种子目录校验;M002 V4 CP11 已把该目录迁移为数据库目录和前端 lookup API: + +- Basic Information 的 `account_code` 必须存在于当前酒店数据库 Account 目录。 +- Market / Source 由 Account 派生,不由 SuperAgent 输出。 +- Room Type / Rate Code 第一版使用当前酒店数据库目录校验和字段选项提示。 +- 目录错误会让对应 V4 卡片进入 `REVIEW_REQUIRED`,用户通过复核解阻选择合法 code。 + +本文记录真实目录和 lookup API 的设计与 CP11 第一版实现。CP11 新增 `workflow_reservation_catalog_account`、`workflow_reservation_catalog_code` 两张表,并通过 Flyway 初始化 `HOTEL-TEST`、`HOTEL-DEV` 以及迁移执行时已存在的 `ACTIVE` 平台酒店的固定种子目录;不再在运行时代码中把固定种子作为全局目录事实。`workflow_reservation_catalog_sync_run`、真实 PMS / OPERA / OHIP 同步、目录管理后台和 SuperAgent 机器目录供给接口继续后置。 + +后续若本文与 `M002-v4-agent-callback-field-contract.md` 的 Agent 输入字段冲突,以 Agent 字段契约为准;若与 `security-access-control-boundary.md` 的接口权限冲突,以安全边界为准。 + +## 2. 当前固定种子目录 + +CP11 之前固定种子实现位于后端 `ReservationV4DirectoryService` 和 `FixedReservationV4DirectoryServiceImpl`。CP11 起实现改为 `ReservationV4DatabaseDirectoryServiceImpl`,目录读取只走数据库目录表;固定种子通过 `V24__create_reservation_catalog_tables.sql` 初始化导入 dev/test 基准酒店和迁移时已有 `ACTIVE` 酒店,来源标记为 `FIXED_SEED_IMPORT`。 + +| 目录 | 当前用途 | 当前固定值 | 当前限制 | +| --- | --- | --- | --- | +| Account | Basic Information 可选目录;SuperAgent 和用户提交都使用稳定 code | `QBD_TRAVEL`、`LIAN_TAI`、`HANATOUR_TD` | 已进入数据库初始化目录,仍不是 PMS 全量 Account,管理后台后置 | +| Market | 由 Account 派生的订单级 Market | 当前 Account 均派生 `LEISURE` | 已进入通用代码目录,前端仍不直接编辑 | +| Source | 由 Account 派生的订单级 Source | 当前 Account 均派生 `TRAVEL_AGENT` | 已进入通用代码目录,前端仍不直接编辑 | +| Room Type | 房型 code 校验和字段选项提示 | `TWN`、`KING`、`DBL`、`SGL`、`TRP`、`RM1`、`RM2`、`RM3` | 已进入数据库初始化目录,仍不是 PMS 全量房型 | +| Rate Code | Rate Code 校验和字段选项提示 | `BAR`、`RACK`、`PACKAGE`、`GROUP`、`FIT` | 已进入数据库初始化目录,不按日期、账号、房型过滤,不含价格 | + +当前固定种子只能支撑开发和演示闭环,不能作为生产长期事实源。 + +## 3. 设计目标 + +真实目录能力要解决以下问题: + +1. 前端不再硬编码 Account、Room Type、Rate Code 选项。 +2. 后端确认和复核仍然是最终校验者,前端 lookup 只用于选择体验。 +3. 每个目录都按 `hotel_id` 隔离,不能跨酒店泄露目录。 +4. 目录有来源、版本、更新时间和启停状态,便于排查 SuperAgent 输出 code 与系统目录不一致的问题。 +5. PMS / OPERA / OHIP 不稳定或暂未接入时,系统仍可使用最后一次成功目录快照或系统管理目录兜底。 +6. 固定种子目录可以作为 dev/test 或导入初始化兜底,但生产不应默认靠代码固定值。 + +## 4. 目录来源分层 + +目录来源按阶段分三层,后续可以逐步切换,不要求一步接真实 PMS。 + +| 阶段 | 来源 | 中文说明 | 适用目录 | +| --- | --- | --- | --- | +| Phase 0 | `FIXED_SEED_IMPORT` | CP11 已将固定种子通过 Flyway 导入数据库,不再作为运行时代码全局 Map | 当前 Account、Market、Source、Room Type、Rate Code | +| Phase 1 | `SYSTEM_MANAGED` | 本系统数据库目录,由初始化脚本、管理后台或导入文件维护 | Account、Market、Source;也可临时维护 Room Type、Rate Code | +| Phase 2 | `PMS_SYNC` | 后端从 PMS / OPERA / OHIP 同步目录到本系统本地表,业务查询只读本地快照 | Room Type、Rate Code 优先;Account、Market、Source 视 PMS 能力再接 | + +原则: + +- 业务确认接口只依赖本系统目录服务,不直接调用外部 PMS。 +- 真实 PMS / OHIP Adapter 只负责同步目录快照,不把厂商 DTO 直接暴露给业务服务或前端。 +- 同一目录可有 `source_system` 标记,但业务接口只使用稳定 `code`。 + +## 5. 目录归属建议 + +| 目录 | 第一版真实来源建议 | 未来 PMS / OPERA / OHIP 方向 | 前端是否可编辑 | 说明 | +| --- | --- | --- | --- | --- | +| Account | 本系统管理目录优先 | 可选同步 PMS profile / company / travel agent 主数据,但本系统仍维护映射 code | 是,Basic Information 选择 Account | Account 影响 Market / Source 派生,第一版建议先由系统管理维护,避免 PMS profile 字段未确认导致业务不可用 | +| Market | 本系统管理目录 | 可选同步 PMS market code 配置 | 否,随 Account 派生展示 | 前端不直接改 Market;修改 Account 后后端派生 Market | +| Source | 本系统管理目录 | 可选同步 PMS source code 配置 | 否,随 Account 派生展示 | 前端不直接改 Source;修改 Account 后后端派生 Source | +| Room Type | PMS / OPERA / OHIP 同步目录优先 | 同步酒店有效房型、展示名、人数、启停状态 | 是,业务卡选择房型 | 若 PMS 未接入,可临时由系统管理维护或固定种子初始化 | +| Rate Code | PMS / OPERA / OHIP 同步目录优先 | 同步有效 Rate Plan / Rate Code;价格和日期适用规则后置 | 是,New Booking 选择 Rate Code | 第一版 lookup 只选 code,不做价格计算 | + +Department 目录在 V4 字段契约中也会被 Trace 使用,但当前固定种子 CP8 尚未实现。后续可沿用本文模型扩展 `DEPARTMENT`,不放入本 checkpoint。 + +## 6. 数据模型草案 + +### 6.1 推荐表:`workflow_reservation_catalog_account` + +Account 单独建表,原因是它不仅有显示名称,还要派生 Market / Source。 + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 内部主键 ID | +| `hotel_id` | 酒店 ID,目录按酒店隔离 | +| `account_code` | Account 稳定 code,大小写敏感 | +| `account_name` | Account 显示名称 | +| `market_code` | 该 Account 派生的 Market code | +| `source_code` | 该 Account 派生的 Source code | +| `status` | `ACTIVE` / `DISABLED` | +| `source_system` | `SYSTEM_MANAGED` / `PMS_SYNC` / `FIXED_SEED_IMPORT` | +| `external_account_id` | 外部 PMS profile 或 account ID,可为空 | +| `catalog_version` | 目录版本,用于前端缓存和排查 | +| `last_synced_at` | 外部同步成功 UTC 时间;系统管理数据可为空 | +| `metadata_json` | 扩展元数据,不替代可查询字段 | +| `version` | 乐观锁版本 | +| `created_at` / `updated_at` | UTC 创建和更新时间 | +| `logic_deleted_at` / `logic_deleted_reason` | 逻辑删除时间和原因 | + +建议唯一约束: + +```text +uk_reservation_catalog_account_code(hotel_id, account_code) +``` + +### 6.2 推荐表:`workflow_reservation_catalog_code` + +Market、Source、Room Type、Rate Code 可先使用统一 code 表。 + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 内部主键 ID | +| `hotel_id` | 酒店 ID,目录按酒店隔离 | +| `catalog_type` | `MARKET` / `SOURCE` / `ROOM_TYPE` / `RATE_CODE` | +| `code` | 稳定目录 code,大小写敏感 | +| `display_name` | 前端显示名称 | +| `status` | `ACTIVE` / `DISABLED` | +| `source_system` | `SYSTEM_MANAGED` / `PMS_SYNC` / `FIXED_SEED_IMPORT` | +| `external_id` | 外部 PMS / OPERA / OHIP ID,可为空 | +| `sort_order` | 前端默认排序 | +| `effective_from` / `effective_to` | 生效日期范围,可为空;酒店本地业务日期语义 | +| `catalog_version` | 目录版本,用于前端缓存和排查 | +| `last_synced_at` | 外部同步成功 UTC 时间 | +| `metadata_json` | 扩展元数据,例如房型人数、Rate Code 适用说明 | +| `version` | 乐观锁版本 | +| `created_at` / `updated_at` | UTC 创建和更新时间 | +| `logic_deleted_at` / `logic_deleted_reason` | 逻辑删除时间和原因 | + +建议唯一约束: + +```text +uk_reservation_catalog_code(hotel_id, catalog_type, code) +``` + +### 6.3 推荐表:`workflow_reservation_catalog_sync_run` + +如果接 PMS / OPERA / OHIP 同步,建议记录每次同步运行。 + +CP11 本轮没有创建该表。原因是当前不接真实 PMS / OPERA / OHIP 同步,暂时没有同步运行事实可记录;后续做同步 worker 或目录管理后台时,再新增该表和对应 Repository。 + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 同步运行 ID | +| `hotel_id` | 酒店 ID | +| `catalog_type` | 同步目录类型 | +| `source_system` | 外部来源系统 | +| `sync_status` | `SUCCESS` / `FAILED` / `PARTIAL_SUCCESS` | +| `started_at` / `finished_at` | UTC 开始和结束时间 | +| `catalog_version` | 本次生成的目录版本 | +| `items_seen_count` | 外部返回数量 | +| `items_upserted_count` | 本系统写入或更新数量 | +| `items_disabled_count` | 本系统停用数量 | +| `safe_error_summary` | 安全错误摘要,不保存 Secret 或完整外部响应 | +| `created_at` / `updated_at` | UTC 创建和更新时间 | + +同步 run 是技术追踪,不直接给普通业务前端展示完整细节。管理后台后续如展示同步记录,应单独登记权限和脱敏规则。 + +## 7. 后端服务边界 + +CP11 已替换当前 `FixedReservationV4DirectoryServiceImpl`,保留 `ReservationV4DirectoryService` 作为业务稳定端口。 + +```text +workflows.reservation.service + ReservationV4DirectoryService + - findAccount(hotelId, accountCode) + - isKnownRoomTypeCode(hotelId, roomTypeCode) + - isKnownRateCode(hotelId, rateCode) + +workflows.reservation.repository + ReservationV4CatalogRepository + - 只封装本地目录表查询,不直接调用 PMS + +workflows.reservation.service + ReservationV4CatalogLookupService + - listAccounts(...) + - listRoomTypes(...) + - listRateCodes(...) + +integrations.ohip / integrations.pms + CatalogSyncAdapter + - 后续从 PMS / OPERA / OHIP 拉取目录并转换为本系统目录草稿 +``` + +规则: + +- V4 入站、确认、复核只调用 `ReservationV4DirectoryService`。 +- 前端 lookup Controller 调用 `ReservationV4CatalogLookupService`,该服务同样只读本地目录表。 +- PMS / OHIP 同步 Adapter 只能写本地目录表或同步 run,不直接参与用户确认事务。 +- 如果目录服务不可用,确认接口 fail closed,不接受自由文本。 + +## 8. Lookup API 草案 + +Lookup API 属于前端业务查询接口,不给 SuperAgent 或 AgentBus 调用。 + +统一要求: + +- 分类:`FRONTEND_USER`。 +- 鉴权:Bearer session token。 +- 权限:第一版建议复用 `RESERVATION_TASK_READ`;目录维护后台另行使用管理权限。 +- 酒店隔离:`hotel_id` 可选;不传时按当前用户默认酒店解析,传入时必须校验用户可访问。 +- 返回:只返回目录 code、显示名、状态、来源和安全元数据,不返回外部 PMS 原始响应。 +- 分页:`page_num` 从 1 开始,`page_size` 后端限制最大值。 + +### 8.1 Account Lookup + +```text +GET /api/reservation/lookups/accounts +``` + +查询参数: + +| 参数 | 必需 | 中文说明 | +| --- | --- | --- | +| `hotel_id` | 否 | 当前酒店 ID;未传时按当前用户默认酒店 | +| `keyword` | 否 | 匹配 `account_code` 或 `account_name` | +| `page_num` / `page_size` | 否 | 分页 | + +第一版固定只返回 `ACTIVE` Account,不开放 `active_only=false`。 + +响应草案: + +```json +{ + "hotel_id": "HOTEL-TEST", + "catalog_type": "ACCOUNT", + "catalog_source": "FIXED_SEED_IMPORT", + "catalog_version": "seed-20260719-v1", + "stale": false, + "items": [ + { + "code": "QBD_TRAVEL", + "display_name": "Q.B.D. TRAVEL GROUP CO., LTD", + "status": "ACTIVE", + "catalog_source": "FIXED_SEED_IMPORT", + "market_code": "LEISURE", + "market_name": "LEISURE", + "source_code": "TRAVEL_AGENT", + "source_name": "TRAVEL_AGENT" + } + ], + "page": { + "page_num": 1, + "page_size": 20, + "total": 1 + }, + "warnings": [] +} +``` + +前端用途: + +- `fields[].options_source=reservation_v4_account_catalog` 时调用。 +- 用户选择 Account 后,前端可以立即展示响应中的 Market / Source;最终以后端确认接口派生结果为准。 +- 不允许用户自由输入 Account code。 + +### 8.2 Room Type Lookup + +```text +GET /api/reservation/lookups/room-types +``` + +查询参数: + +| 参数 | 必需 | 中文说明 | +| --- | --- | --- | +| `hotel_id` | 否 | 当前酒店 ID | +| `keyword` | 否 | 匹配房型 code 或显示名 | +| `page_num` / `page_size` | 否 | 分页 | + +第一版固定只返回 `ACTIVE` Room Type,不接 `arrival_date` / `departure_date`,日期适用范围过滤后置。 + +响应草案: + +```json +{ + "hotel_id": "HOTEL-TEST", + "catalog_type": "ROOM_TYPE", + "catalog_source": "FIXED_SEED_IMPORT", + "catalog_version": "seed-20260719-v1", + "stale": false, + "items": [ + { + "code": "RM2", + "display_name": "RM2", + "status": "ACTIVE", + "catalog_source": "FIXED_SEED_IMPORT", + "adult_capacity": 2 + } + ], + "page": { + "page_num": 1, + "page_size": 20, + "total": 1 + }, + "warnings": [] +} +``` + +前端用途: + +- `fields[].options_source=reservation_v4_room_type_catalog` 时调用。 +- V4 业务卡里的 `room_items[].room_type_code`、`business_fields.after.room_items[].room_type_code`、加床目标房型等都应从该接口选。 +- 前端不要把旧固定种子当 PMS 全量房型。 + +### 8.3 Rate Code Lookup + +```text +GET /api/reservation/lookups/rate-codes +``` + +查询参数: + +| 参数 | 必需 | 中文说明 | +| --- | --- | --- | +| `hotel_id` | 否 | 当前酒店 ID | +| `keyword` | 否 | 匹配 Rate Code 或显示名 | +| `page_num` / `page_size` | 否 | 分页 | + +第一版固定只返回 `ACTIVE` Rate Code,不接 `booking_type`、`account_code`、`arrival_date` / `departure_date`,适用范围和价格过滤后置。 + +响应草案: + +```json +{ + "hotel_id": "HOTEL-TEST", + "catalog_type": "RATE_CODE", + "catalog_source": "FIXED_SEED_IMPORT", + "catalog_version": "seed-20260719-v1", + "stale": false, + "items": [ + { + "code": "GROUP", + "display_name": "GROUP", + "status": "ACTIVE", + "catalog_source": "FIXED_SEED_IMPORT", + "pricing_available": false + } + ], + "page": { + "page_num": 1, + "page_size": 20, + "total": 1 + }, + "warnings": [] +} +``` + +前端用途: + +- `fields[].options_source=reservation_v4_rate_code_catalog` 时调用。 +- 第一版只选 `rate_code`,不展示或计算真实价格。 +- V4 契约仍禁止 `UPDATE_BOOKING` 携带 Rate Code;lookup API 不改变该规则。 + +### 8.4 Market / Source Lookup + +Market / Source 第一版不作为用户可编辑字段,不建议给普通业务表单单独开放选择。 + +如果后续系统管理后台需要维护 Market / Source,可使用管理接口或通用目录接口,但不应让 V4 Basic Information 页面绕过 Account 派生规则。 + +## 9. `fields[]` 与 lookup 的关系 + +后端任务详情字段仍是前端渲染白名单。Lookup API 只解决选项来源,不决定字段是否展示或可编辑。 + +| `options_source` | 对应 lookup | 前端行为 | +| --- | --- | --- | +| `reservation_v4_account_catalog` | `GET /api/reservation/lookups/accounts` | 渲染 Account 下拉 / 搜索选择,展示派生 Market / Source | +| `reservation_v4_room_type_catalog` | `GET /api/reservation/lookups/room-types` | 渲染房型搜索选择 | +| `reservation_v4_rate_code_catalog` | `GET /api/reservation/lookups/rate-codes` | 渲染 Rate Code 搜索选择 | +| `static_enum` | 使用 `fields[].enum_options` | 不调用 lookup | +| `system_case_lookup` | 后续订单 / 任务对象 lookup | 不属于本目录 checkpoint | + +前端提交时仍按卡片确认或复核接口提交字段值。后端确认前再次校验目录,不能因为前端选项来自 lookup 就跳过后端校验。 + +## 10. 缓存设计 + +缓存是优化,不是事实源。本系统本地目录表才是业务查询事实源。 + +后续建议。CP11 第一版暂不加内存缓存,直接读取本系统本地目录表;原因是当前种子目录规模很小,先保证目录事实源、酒店隔离和确认校验一致。 + +- 目录 Service 增加内存缓存,缓存 key 包含 `hotel_id`、`catalog_type`、`keyword`、`active_only`、分页和过滤参数。 +- 精确校验类查询,例如 `findAccount(hotelId, accountCode)`、`isKnownRoomTypeCode(hotelId, code)`,使用单独短 TTL 缓存。 +- TTL 建议 5 分钟;真实同步后可根据 `catalog_version` 主动失效。 +- 管理后台修改目录或同步 run 成功后,清理对应酒店和目录类型缓存。 +- 多节点部署时,第一版可以依赖短 TTL;后续如目录变更频繁,再接 Redis 或事件广播失效。 + +响应应返回: + +| 字段 | 中文说明 | +| --- | --- | +| `catalog_version` | 当前目录版本,前端可用于调试和避免重复请求 | +| `catalog_source` | 当前目录主要来源,例如 `SYSTEM_MANAGED` / `PMS_SYNC` | +| `stale` | 当前是否为过期但可用的最后成功快照 | +| `warnings[]` | 非阻塞警告,例如 PMS 同步失败但仍返回本地快照 | + +## 11. 失败兜底 + +| 场景 | Lookup API 行为 | 确认 / 复核行为 | +| --- | --- | --- | +| 目录有本地 Active 快照 | 返回 200 和可选项 | 按目录校验,通过后确认 | +| PMS 同步失败,但有上次成功快照 | 返回 200,`stale=true`,带 warning | 仍可按本地快照确认,并在审计或 payload 中保留目录版本 | +| PMS 同步失败且无本地快照 | 返回 200 空列表和 warning,或按后续实现返回明确错误 | 必填目录字段 fail closed,返回 `V4_CATALOG_UNAVAILABLE` 或 `V4_FIELD_VALIDATION_FAILED` | +| SuperAgent 输出未知非空 code | 任务卡进入 `REVIEW_REQUIRED`,`fields[].validation_errors` 指向该字段 | 用户必须选择已知 code;不能自由输入原值 | +| 目录 code 后续停用 | 已确认卡保持历史确认快照不回滚 | 新确认不能选择停用 code,除非后续设计允许历史兼容选择 | +| 前端提交未返回字段或自由 code | 后端忽略未开放字段;目录字段校验失败 | 不写入 `confirmed_payload_json` | + +生产建议: + +- `FIXED_SEED` 可用于 dev/test 和初始化导入。 +- 生产如果真实目录为空,不应静默接受固定种子;应让卡片进入复核或目录不可用错误,避免写入错误 PMS code。 + +## 12. 权限、酒店隔离和审计 + +### 12.1 Lookup 查询权限 + +第一版 lookup 查询建议: + +| 接口 | 分类 | 权限 | 酒店隔离 | 审计 | +| --- | --- | --- | --- | --- | +| `GET /api/reservation/lookups/accounts` | `FRONTEND_USER` | `RESERVATION_TASK_READ` | 按用户可访问酒店校验 | 只读不写业务审计 | +| `GET /api/reservation/lookups/room-types` | `FRONTEND_USER` | `RESERVATION_TASK_READ` | 按用户可访问酒店校验 | 只读不写业务审计 | +| `GET /api/reservation/lookups/rate-codes` | `FRONTEND_USER` | `RESERVATION_TASK_READ` | 按用户可访问酒店校验 | 只读不写业务审计 | + +复用 `RESERVATION_TASK_READ` 的原因: + +- Lookup 是任务详情表单选项的辅助查询能力。 +- 目录值本身不包含邮件正文、附件、AI payload 或 PMS Secret。 +- 可以避免第一版为了表单下拉再新增一个普通用户权限码,降低前端角色配置复杂度。 + +### 12.2 目录维护权限 + +目录维护不在本 checkpoint 实现。后续如果做系统管理维护,建议新增: + +```text +RESERVATION_CATALOG_MANAGE +``` + +用于 Account、Market、Source、临时 Room Type、Rate Code 的系统管理维护页面。写操作必须记录管理审计或业务配置审计。 + +### 12.3 第三方接口边界 + +SuperAgent 当前不调用本 lookup API。SuperAgent 目录供给后续有两种方式: + +1. 线下或配置文件方式把目录版本给 SuperAgent。 +2. 单独设计机器接口,例如 `GET /api/integrations/superagent/catalogs/...`,使用 HMAC 鉴权和最小字段。 + +不得让 SuperAgent 使用前端 Bearer token 或前端 lookup API。 + +## 13. 前端使用方式 + +前端建议: + +1. 先读取 V4 订单任务详情。 +2. 遍历每张卡 `fields[]`。 +3. 只有字段 `editable=true` 且 `control_type=select/lookup` 时才加载 lookup。 +4. 根据 `options_source` 选择 lookup 接口。 +5. 搜索输入做 debounce,不一次性拉全量。 +6. 显示 `stale=true` 或 `warnings[]` 时给用户非阻塞提醒。 +7. 用户提交确认或复核时只提交 code,不提交显示名、Market / Source 派生值或目录完整对象。 +8. 确认成功后以后端返回的 `confirmed_payload_json` / 刷新详情为准更新页面。 + +前端禁止: + +- 硬编码 PMS 房型或 Rate Code 全集。 +- 把目录显示名当业务 code 提交。 +- 绕过 `fields[]` 自行补业务字段。 +- 使用 lookup API 给 SuperAgent、AgentBus 或 Debug 链路拼接输入。 +- 在浏览器保存目录中的外部 PMS ID、同步错误详情或任何 Secret。 + +## 14. 后续开发 checkpoint + +| Checkpoint | 目标 | 主要交付 | +| --- | --- | --- | +| M002-V4-CP11 | DB 管理目录与 Lookup API V1 | 新增 Account / Code 目录表、Repository、DirectoryService DB 实现、Account / Room Type / Rate Code lookup 查询接口、权限和测试 | +| M002-V4-CP12 | 前端 Lookup 接入 | V4 卡片字段渲染按 `options_source` 调用 lookup,替换固定种子硬编码选项,处理 stale / warning / 空目录 | +| M002-V4-CP13 | 目录管理后台 V1 | Account / Market / Source 管理,临时 Room Type / Rate Code 管理,`RESERVATION_CATALOG_MANAGE` 权限和管理审计 | +| M002-V4-CP14 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run 表、失败重试、最后成功快照、同步状态管理入口 | +| M002-V4-CP15 | SuperAgent 目录供给 | 明确目录版本如何给 SuperAgent,必要时新增机器目录接口或导出包 | + +CP11 已作为后端第一步落地,因为它不依赖真实 PMS,也能让前端后续不再硬编码当前固定种子。 + +## 15. 仍需确认的问题 + +1. Account 的第一版真实维护入口是否放在现有系统管理后台,还是先用导入 SQL / Excel 导入。 +2. Account code 是否继续使用本系统定义的稳定 code,例如 `QBD_TRAVEL`,还是必须对齐 PMS profile code。 +3. Market / Source 是否只允许随 Account 派生,还是未来允许用户在 Basic Information 中单独改选。 +4. Room Type 第一版真实目录是否先由系统管理维护,还是等 PMS / OHIP 同步后再替换。 +5. Rate Code 第一版是否只校验 code,还是需要按 `booking_type`、`account_code`、入住日期过滤。 +6. 生产是否允许 `FIXED_SEED` 作为兜底,还是只允许 dev/test 使用。 +7. SuperAgent 是否需要读取目录;如果需要,是离线给目录包,还是新增 HMAC 机器接口。 + +在这些问题未确认前,CP11 仍可以先按 DB 管理目录 + 前端 lookup 查询实现,不接真实 PMS,也不替换 SuperAgent 输入契约。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index aad33f0..c7ef250 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -51,10 +51,13 @@ | `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload;`card_status` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 | | `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回 `ai_payload_json` 或附件 URL;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 | | `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload 或附件 URL;前端普通通知卡如遇 URL-like 附件字符串必须二次脱敏 | +| `GET /api/reservation/lookups/accounts` | `FRONTEND_USER` | 已实现 M002 V4 CP11;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 Account code、显示名、派生 Market / Source 和目录安全元数据 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、Secret 或外部同步错误详情 | +| `GET /api/reservation/lookups/room-types` | `FRONTEND_USER` | 已实现 M002 V4 CP11;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回当前酒店可选 ACTIVE 房型目录快照 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、价格敏感细节或跨酒店房型 | +| `GET /api/reservation/lookups/rate-codes` | `FRONTEND_USER` | 已实现 M002 V4 CP11;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回当前酒店可选 ACTIVE Rate Code 目录快照,不做真实价格计算 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、价格明细、Secret 或跨酒店 Rate Plan | | `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 | | `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6/CP8;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 + 第一版目录校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload;Basic Account、Room Type、Rate Code 目录错误返回 `V4_FIELD_VALIDATION_FAILED` | 必须写业务审计,actor 使用当前登录用户 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7/CP8;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 + 第一版目录校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡可编辑业务字段,优先按显式 `missing_fields[]`、未解决叶子值或 `validation_errors_json` 指向字段收口;订单归属未解决时必须提交当前酒店下真实可见订单 ID,已 `RESOLVED` 的订单任务不得换绑不同订单 | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 AI 原始 payload | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6/CP8/CP11;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 + 当前酒店数据库目录校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload;Basic Account、Room Type、Rate Code 目录错误返回 `V4_FIELD_VALIDATION_FAILED` | 必须写业务审计,actor 使用当前登录用户 | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7/CP8/CP11;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 + 当前酒店数据库目录校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡可编辑业务字段,优先按显式 `missing_fields[]`、未解决叶子值或 `validation_errors_json` 指向字段收口;订单归属未解决时必须提交当前酒店下真实可见订单 ID,已 `RESOLVED` 的订单任务不得换绑不同订单 | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 AI 原始 payload | | `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 `route_code=S10/S99` 的 V4 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞;重复 ack 幂等返回当前状态且不新增审计 | 首次确认必须写业务审计,记录已读 / 已处理确认,actor 使用当前登录用户 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计和原因 | | `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计 | @@ -133,6 +136,7 @@ | `RESERVATION_AUDIT_READ` | 查看业务审计流水 | 任务审计列表 | | `RESERVATION_INVOICE_GENERATE` | 生成 Reservation Proforma Invoice | Manual Invoice 生成、未来任务 / 订单预填生成 | | `RESERVATION_ROOMING_LIST_GENERATE` | 生成 Reservation Rooming List Excel | Rooming List 上传名单并生成 `.xlsx` 下载 | +| `RESERVATION_CATALOG_MANAGE` | 维护 Reservation 受控目录 | 后续 Account、Market、Source、临时 Room Type / Rate Code 目录管理后台;当前尚未实现 | | `SOURCE_MESSAGE_READ` | 查看来源邮件安全摘要 | SourceMessage 列表、详情、会话摘要 | | `SOURCE_MESSAGE_ORIGINAL_READ` | 查看邮件正文、HTML 和附件外链 | original / conversation 完整正文;必须叠加 `SOURCE_MESSAGE_READ` 使用 | | `SYSTEM_DEBUG_EML_RUN` | 使用 Debug EML 调试链路 | Debug EML 上传、查询、stream | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java index 3653184..89b856b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4AccountCatalogItem.java @@ -1,7 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.dto; /** - * Reservation V4 第一版 Account 目录项。当前为后端固定种子,后续可替换为酒店目录表。 + * Reservation V4 Account 目录项。由当前酒店数据库目录快照转换而来。 * * @param accountCode Account 稳定代码,SuperAgent 与前端提交均使用该值 * @param accountName Account 显示名称 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogAccountSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogAccountSnapshot.java new file mode 100644 index 0000000..5f29310 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogAccountSnapshot.java @@ -0,0 +1,33 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 Account 目录快照。Repository 输出快照,避免 Service 直接依赖 Entity。 + * + * @param id 目录记录 ID + * @param hotelId 酒店 ID + * @param accountCode Account 稳定代码 + * @param accountName Account 显示名称 + * @param marketCode 关联 Market 代码 + * @param sourceCode 关联 Source 代码 + * @param status 目录状态 + * @param sourceSystem 目录来源 + * @param catalogVersion 目录版本 + * @param lastSyncedAt 最近同步 UTC 时间 + * @param metadataJson 扩展属性 JSON + */ +public record ReservationV4CatalogAccountSnapshot( + Long id, + String hotelId, + String accountCode, + String accountName, + String marketCode, + String sourceCode, + String status, + String sourceSystem, + String catalogVersion, + LocalDateTime lastSyncedAt, + String metadataJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogCodeSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogCodeSnapshot.java new file mode 100644 index 0000000..9ad94f5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationV4CatalogCodeSnapshot.java @@ -0,0 +1,33 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto; + +import java.time.LocalDateTime; + +/** + * Reservation V4 通用代码目录快照。覆盖 Room Type、Rate Code、Market、Source。 + * + * @param id 目录记录 ID + * @param hotelId 酒店 ID + * @param catalogType 目录类型 + * @param code 稳定代码 + * @param displayName 显示名称 + * @param status 目录状态 + * @param sourceSystem 目录来源 + * @param sortOrder 展示排序 + * @param catalogVersion 目录版本 + * @param lastSyncedAt 最近同步 UTC 时间 + * @param metadataJson 扩展属性 JSON + */ +public record ReservationV4CatalogCodeSnapshot( + Long id, + String hotelId, + String catalogType, + String code, + String displayName, + String status, + String sourceSystem, + Integer sortOrder, + String catalogVersion, + LocalDateTime lastSyncedAt, + String metadataJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogSourceSystem.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogSourceSystem.java new file mode 100644 index 0000000..166b8d6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogSourceSystem.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 目录来源稳定代码。前端可用该值判断目录是否来自固定初始化、后台维护或未来 PMS 同步。 + */ +public enum ReservationV4CatalogSourceSystem { + FIXED_SEED_IMPORT, + SYSTEM_MANAGED, + PMS_SYNC +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogStatus.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogStatus.java new file mode 100644 index 0000000..f8d13c3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogStatus.java @@ -0,0 +1,9 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 目录状态。第一版 lookup 和字段校验只接受 ACTIVE 目录。 + */ +public enum ReservationV4CatalogStatus { + ACTIVE, + DISABLED +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogType.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogType.java new file mode 100644 index 0000000..9944cda --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/enums/ReservationV4CatalogType.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.common.enums; + +/** + * Reservation V4 目录类型。Account 单独成表,其余稳定代码放在通用目录表。 + */ +public enum ReservationV4CatalogType { + ACCOUNT, + ROOM_TYPE, + RATE_CODE, + MARKET, + SOURCE +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogLookupRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogLookupRequest.java new file mode 100644 index 0000000..ce100c7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogLookupRequest.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +/** + * Reservation V4 目录 lookup 查询请求。Controller 负责承接 HTTP 参数,Service 负责酒店隔离和分页默认值。 + * + * @param hotelId 请求酒店 ID,未传时使用当前登录用户默认酒店 + * @param catalogType 目录类型 + * @param keyword 代码或名称关键字 + * @param pageNum 页码,从 1 开始 + * @param pageSize 每页数量 + */ +public record ReservationV4CatalogLookupRequest( + String hotelId, + String catalogType, + String keyword, + Integer pageNum, + Integer pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupItemResult.java new file mode 100644 index 0000000..88dec39 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupItemResult.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 lookup 单条目录返回项。不同目录类型共享该结构,未适用字段返回 null。 + * + * @param code 目录稳定代码 + * @param displayName 目录显示名称 + * @param status 目录状态 + * @param catalogSource 单条目录来源 + * @param marketCode Account 关联 Market 代码 + * @param marketName Account 关联 Market 显示名称 + * @param sourceCode Account 关联 Source 代码 + * @param sourceName Account 关联 Source 显示名称 + * @param adultCapacity 房型成人容量,未配置时为空 + * @param pricingAvailable Rate Code 是否已有价格能力,第一版固定为 false + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ReservationV4CatalogLookupItemResult( + String code, + @JsonProperty("display_name") + String displayName, + String status, + @JsonProperty("catalog_source") + String catalogSource, + @JsonProperty("market_code") + String marketCode, + @JsonProperty("market_name") + String marketName, + @JsonProperty("source_code") + String sourceCode, + @JsonProperty("source_name") + String sourceName, + @JsonProperty("adult_capacity") + Integer adultCapacity, + @JsonProperty("pricing_available") + Boolean pricingAvailable +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupResult.java new file mode 100644 index 0000000..24003c2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogLookupResult.java @@ -0,0 +1,32 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Reservation V4 lookup 统一 wrapper。前端所有目录下拉都使用同一层级读取。 + * + * @param hotelId 酒店 ID + * @param catalogType 目录类型 + * @param catalogSource 本页目录主要来源 + * @param catalogVersion 本页目录版本 + * @param stale 是否为过期或兜底数据 + * @param items 目录项列表 + * @param page 分页元数据 + * @param warnings 安全提示或兜底提示 + */ +public record ReservationV4CatalogLookupResult( + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("catalog_type") + String catalogType, + @JsonProperty("catalog_source") + String catalogSource, + @JsonProperty("catalog_version") + String catalogVersion, + boolean stale, + List items, + ReservationPaginationResult page, + List warnings +) { +} 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 3a2e217..7ea84bc 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 @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice(assignableTypes = { ReservationTaskController.class, ReservationFrontendQueryController.class, + ReservationV4CatalogLookupController.class, ReservationV4QueryController.class, ReservationV4CommandController.class, ReservationDemoDataController.class, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupController.java new file mode 100644 index 0000000..8dbca34 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupController.java @@ -0,0 +1,87 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogLookupRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupResult; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogLookupService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reservation V4 目录 lookup Controller。为前端字段控件提供 Account、Room Type、Rate Code 下拉数据。 + */ +@RestController +@RequestMapping("/api/reservation/lookups") +public class ReservationV4CatalogLookupController { + + private final ReservationV4CatalogLookupService lookupService; + private final FrontendAuthorizationService authorizationService; + + /** + * 注入目录 lookup 服务和前端鉴权服务,Controller 只负责 HTTP 边界和权限入口。 + */ + public ReservationV4CatalogLookupController( + ReservationV4CatalogLookupService lookupService, + FrontendAuthorizationService authorizationService) { + this.lookupService = lookupService; + this.authorizationService = authorizationService; + } + + /** + * 查询 Account 目录,用于 Basic Information 的 Account 选择控件。 + */ + @GetMapping(value = "/accounts", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogLookupResult listAccounts( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(required = false) String keyword, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return lookupService.listAccounts(new ReservationV4CatalogLookupRequest( + hotelId, + "ACCOUNT", + keyword, + pageNum, + pageSize)); + } + + /** + * 查询 Room Type 目录,用于房型选择控件和 V4 字段校验。 + */ + @GetMapping(value = "/room-types", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogLookupResult listRoomTypes( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(required = false) String keyword, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return lookupService.listRoomTypes(new ReservationV4CatalogLookupRequest( + hotelId, + "ROOM_TYPE", + keyword, + pageNum, + pageSize)); + } + + /** + * 查询 Rate Code 目录,用于 Rate Code 选择控件和 V4 字段校验。 + */ + @GetMapping(value = "/rate-codes", produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogLookupResult listRateCodes( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(required = false) String keyword, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return lookupService.listRateCodes(new ReservationV4CatalogLookupRequest( + hotelId, + "RATE_CODE", + keyword, + pageNum, + pageSize)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationCatalogAccountEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationCatalogAccountEntity.java new file mode 100644 index 0000000..cf5f99c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationCatalogAccountEntity.java @@ -0,0 +1,84 @@ +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 Account 目录实体。承载酒店维度的客户/旅行社 Account 代码。 + */ +@TableName("workflow_reservation_catalog_account") +public class ReservationCatalogAccountEntity { + + /** 目录记录 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店 ID。 */ + private String hotelId; + /** Account 稳定代码。 */ + private String accountCode; + /** Account 显示名称。 */ + private String accountName; + /** Account 关联 Market 代码。 */ + private String marketCode; + /** Account 关联 Source 代码。 */ + private String sourceCode; + /** 目录状态。 */ + private String status; + /** 目录来源。 */ + private String sourceSystem; + /** 外部系统 Account ID。 */ + private String externalAccountId; + /** 目录版本。 */ + private String catalogVersion; + /** 最近同步 UTC 时间。 */ + private LocalDateTime lastSyncedAt; + /** Account 扩展属性 JSON。 */ + private String metadataJson; + /** 乐观锁版本。 */ + 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 String getAccountCode() { return accountCode; } + public void setAccountCode(String accountCode) { this.accountCode = accountCode; } + public String getAccountName() { return accountName; } + public void setAccountName(String accountName) { this.accountName = accountName; } + public String getMarketCode() { return marketCode; } + public void setMarketCode(String marketCode) { this.marketCode = marketCode; } + public String getSourceCode() { return sourceCode; } + public void setSourceCode(String sourceCode) { this.sourceCode = sourceCode; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getSourceSystem() { return sourceSystem; } + public void setSourceSystem(String sourceSystem) { this.sourceSystem = sourceSystem; } + public String getExternalAccountId() { return externalAccountId; } + public void setExternalAccountId(String externalAccountId) { this.externalAccountId = externalAccountId; } + public String getCatalogVersion() { return catalogVersion; } + public void setCatalogVersion(String catalogVersion) { this.catalogVersion = catalogVersion; } + public LocalDateTime getLastSyncedAt() { return lastSyncedAt; } + public void setLastSyncedAt(LocalDateTime lastSyncedAt) { this.lastSyncedAt = lastSyncedAt; } + public String getMetadataJson() { return metadataJson; } + public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; } + 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/ReservationCatalogCodeEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationCatalogCodeEntity.java new file mode 100644 index 0000000..25110d0 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationCatalogCodeEntity.java @@ -0,0 +1,84 @@ +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 通用代码目录实体。承载 Room Type、Rate Code、Market、Source 等酒店目录。 + */ +@TableName("workflow_reservation_catalog_code") +public class ReservationCatalogCodeEntity { + + /** 目录记录 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** 酒店 ID。 */ + private String hotelId; + /** 目录类型。 */ + private String catalogType; + /** 目录稳定代码。 */ + private String code; + /** 目录显示名称。 */ + private String displayName; + /** 目录状态。 */ + private String status; + /** 目录来源。 */ + private String sourceSystem; + /** 外部系统目录 ID。 */ + private String externalId; + /** 展示排序。 */ + private Integer sortOrder; + /** 目录版本。 */ + private String catalogVersion; + /** 最近同步 UTC 时间。 */ + private LocalDateTime lastSyncedAt; + /** 目录扩展属性 JSON。 */ + private String metadataJson; + /** 乐观锁版本。 */ + 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 String getCatalogType() { return catalogType; } + public void setCatalogType(String catalogType) { this.catalogType = catalogType; } + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getSourceSystem() { return sourceSystem; } + public void setSourceSystem(String sourceSystem) { this.sourceSystem = sourceSystem; } + public String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public String getCatalogVersion() { return catalogVersion; } + public void setCatalogVersion(String catalogVersion) { this.catalogVersion = catalogVersion; } + public LocalDateTime getLastSyncedAt() { return lastSyncedAt; } + public void setLastSyncedAt(LocalDateTime lastSyncedAt) { this.lastSyncedAt = lastSyncedAt; } + public String getMetadataJson() { return metadataJson; } + public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; } + 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/ReservationCatalogAccountMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationCatalogAccountMapper.java new file mode 100644 index 0000000..195ae93 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationCatalogAccountMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation Account 目录 Mapper。自带 CRUD 由 MyBatis-Plus 提供。 + */ +@Mapper +public interface ReservationCatalogAccountMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationCatalogCodeMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationCatalogCodeMapper.java new file mode 100644 index 0000000..6383cc1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationCatalogCodeMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.mapper; + +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * Reservation 通用代码目录 Mapper。自带 CRUD 由 MyBatis-Plus 提供。 + */ +@Mapper +public interface ReservationCatalogCodeMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java new file mode 100644 index 0000000..7801a3a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4CatalogRepository.java @@ -0,0 +1,171 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogStatus; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity; +import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationCatalogAccountMapper; +import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationCatalogCodeMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +/** + * Reservation V4 目录 MyBatis-Plus 持久化实现。所有读取默认排除逻辑删除和非 ACTIVE 目录。 + */ +@Repository +public class MybatisReservationV4CatalogRepository implements ReservationV4CatalogRepository { + + private final ReservationCatalogAccountMapper accountMapper; + private final ReservationCatalogCodeMapper codeMapper; + + /** + * 注入目录 Mapper,Repository 统一处理查询条件和快照转换。 + */ + public MybatisReservationV4CatalogRepository( + ReservationCatalogAccountMapper accountMapper, + ReservationCatalogCodeMapper codeMapper) { + this.accountMapper = accountMapper; + this.codeMapper = codeMapper; + } + + /** + * 按酒店和 Account Code 查询 ACTIVE Account 目录。 + */ + @Override + public Optional findActiveAccount(String hotelId, String accountCode) { + String normalizedCode = trimToNull(accountCode); + if (trimToNull(hotelId) == null || normalizedCode == null) { + return Optional.empty(); + } + ReservationCatalogAccountEntity entity = accountMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationCatalogAccountEntity::getHotelId, hotelId) + .eq(ReservationCatalogAccountEntity::getAccountCode, normalizedCode) + .eq(ReservationCatalogAccountEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name()) + .isNull(ReservationCatalogAccountEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toAccountSnapshot); + } + + /** + * 按酒店、目录类型和代码查询 ACTIVE 通用目录。 + */ + @Override + public Optional findActiveCatalogCode( + String hotelId, + String catalogType, + String code) { + String normalizedType = trimToNull(catalogType); + String normalizedCode = trimToNull(code); + if (trimToNull(hotelId) == null || normalizedType == null || normalizedCode == null) { + return Optional.empty(); + } + ReservationCatalogCodeEntity entity = codeMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationCatalogCodeEntity::getHotelId, hotelId) + .eq(ReservationCatalogCodeEntity::getCatalogType, normalizedType) + .eq(ReservationCatalogCodeEntity::getCode, normalizedCode) + .eq(ReservationCatalogCodeEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name()) + .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toCodeSnapshot); + } + + /** + * 分页查询 Account 目录,用于前端 lookup 下拉。 + */ + @Override + public ReservationPageSnapshot queryActiveAccounts( + String hotelId, + String keyword, + int pageNum, + int pageSize) { + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .eq(ReservationCatalogAccountEntity::getHotelId, hotelId) + .eq(ReservationCatalogAccountEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name()) + .isNull(ReservationCatalogAccountEntity::getLogicDeletedAt) + .and(trimToNull(keyword) != null, wrapper -> wrapper + .like(ReservationCatalogAccountEntity::getAccountCode, trimToNull(keyword)) + .or() + .like(ReservationCatalogAccountEntity::getAccountName, trimToNull(keyword))) + .orderByAsc(ReservationCatalogAccountEntity::getAccountCode) + .orderByAsc(ReservationCatalogAccountEntity::getId); + Page page = accountMapper.selectPage(Page.of(pageNum, pageSize), query); + List items = page.getRecords().stream() + .map(this::toAccountSnapshot) + .toList(); + return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize); + } + + /** + * 分页查询通用代码目录,用于前端 Room Type、Rate Code lookup 下拉。 + */ + @Override + public ReservationPageSnapshot queryActiveCatalogCodes( + String hotelId, + String catalogType, + String keyword, + int pageNum, + int pageSize) { + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .eq(ReservationCatalogCodeEntity::getHotelId, hotelId) + .eq(ReservationCatalogCodeEntity::getCatalogType, catalogType) + .eq(ReservationCatalogCodeEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name()) + .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt) + .and(trimToNull(keyword) != null, wrapper -> wrapper + .like(ReservationCatalogCodeEntity::getCode, trimToNull(keyword)) + .or() + .like(ReservationCatalogCodeEntity::getDisplayName, trimToNull(keyword))) + .orderByAsc(ReservationCatalogCodeEntity::getSortOrder) + .orderByAsc(ReservationCatalogCodeEntity::getCode) + .orderByAsc(ReservationCatalogCodeEntity::getId); + Page page = codeMapper.selectPage(Page.of(pageNum, pageSize), query); + List items = page.getRecords().stream() + .map(this::toCodeSnapshot) + .toList(); + return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize); + } + + private ReservationV4CatalogAccountSnapshot toAccountSnapshot(ReservationCatalogAccountEntity entity) { + return new ReservationV4CatalogAccountSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getAccountCode(), + entity.getAccountName(), + entity.getMarketCode(), + entity.getSourceCode(), + entity.getStatus(), + entity.getSourceSystem(), + entity.getCatalogVersion(), + entity.getLastSyncedAt(), + entity.getMetadataJson()); + } + + private ReservationV4CatalogCodeSnapshot toCodeSnapshot(ReservationCatalogCodeEntity entity) { + return new ReservationV4CatalogCodeSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getCatalogType(), + entity.getCode(), + entity.getDisplayName(), + entity.getStatus(), + entity.getSourceSystem(), + entity.getSortOrder(), + entity.getCatalogVersion(), + entity.getLastSyncedAt(), + entity.getMetadataJson()); + } + + private String trimToNull(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java new file mode 100644 index 0000000..b9a3d68 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4CatalogRepository.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.workflows.reservation.repository; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot; +import java.util.Optional; + +/** + * Reservation V4 目录持久化边界。封装 Account、Room Type、Rate Code 等数据库目录读取。 + */ +public interface ReservationV4CatalogRepository { + + /** + * 按酒店和 Account Code 查询 ACTIVE Account 目录。 + */ + Optional findActiveAccount(String hotelId, String accountCode); + + /** + * 按酒店、目录类型和代码查询 ACTIVE 通用目录。 + */ + Optional findActiveCatalogCode(String hotelId, String catalogType, String code); + + /** + * 分页查询 Account 目录,用于前端 lookup 下拉。 + */ + ReservationPageSnapshot queryActiveAccounts( + String hotelId, + String keyword, + int pageNum, + int pageSize); + + /** + * 分页查询通用代码目录,用于前端 Room Type、Rate Code lookup 下拉。 + */ + ReservationPageSnapshot queryActiveCatalogCodes( + String hotelId, + String catalogType, + String keyword, + int pageNum, + int pageSize); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CatalogLookupService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CatalogLookupService.java new file mode 100644 index 0000000..e3f2ce2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CatalogLookupService.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogLookupRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupResult; + +/** + * Reservation V4 目录 lookup 查询服务。面向前端下拉、选择控件提供酒店隔离后的目录数据。 + */ +public interface ReservationV4CatalogLookupService { + + /** + * 查询 Account 目录下拉数据。 + */ + ReservationV4CatalogLookupResult listAccounts(ReservationV4CatalogLookupRequest request); + + /** + * 查询 Room Type 目录下拉数据。 + */ + ReservationV4CatalogLookupResult listRoomTypes(ReservationV4CatalogLookupRequest request); + + /** + * 查询 Rate Code 目录下拉数据。 + */ + ReservationV4CatalogLookupResult listRateCodes(ReservationV4CatalogLookupRequest request); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java index 9b39193..f0b7dfd 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4DirectoryService.java @@ -4,22 +4,22 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCa import java.util.Optional; /** - * Reservation V4 第一版目录服务。封装 Account、房型和 Rate Code 的目录边界。 + * Reservation V4 目录服务。封装 Account、房型和 Rate Code 的酒店维度目录边界。 */ public interface ReservationV4DirectoryService { /** - * 按 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。 + * 按酒店和 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。 */ - Optional findAccount(String accountCode); + Optional findAccount(String hotelId, String accountCode); /** - * 判断房型代码是否在第一版固定目录中;空值由业务字段必填规则处理。 + * 判断房型代码是否在当前酒店目录中;空值由业务字段必填规则处理。 */ - boolean isKnownRoomTypeCode(String roomTypeCode); + boolean isKnownRoomTypeCode(String hotelId, String roomTypeCode); /** - * 判断 Rate Code 是否在第一版固定目录中;空值由业务字段必填规则处理。 + * 判断 Rate Code 是否在当前酒店目录中;空值由业务字段必填规则处理。 */ - boolean isKnownRateCode(String rateCode); + boolean isKnownRateCode(String hotelId, String rateCode); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java deleted file mode 100644 index 87ff27e..0000000 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/FixedReservationV4DirectoryServiceImpl.java +++ /dev/null @@ -1,70 +0,0 @@ -package cn.nianxx.thhotel.workflows.reservation.service.impl; - -import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; -import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import org.springframework.stereotype.Service; - -/** - * Reservation V4 固定种子目录实现。开发阶段先用代码种子,后续可替换为数据库目录。 - */ -@Service -public class FixedReservationV4DirectoryServiceImpl implements ReservationV4DirectoryService { - - private static final Map ACCOUNT_CATALOG = Map.of( - "QBD_TRAVEL", new ReservationV4AccountCatalogItem( - "QBD_TRAVEL", - "Q.B.D. TRAVEL GROUP CO., LTD", - "LEISURE", - "TRAVEL_AGENT"), - "LIAN_TAI", new ReservationV4AccountCatalogItem( - "LIAN_TAI", - "LIAN TAI TRAVEL (THAILAND) CO., LTD.", - "LEISURE", - "TRAVEL_AGENT"), - "HANATOUR_TD", new ReservationV4AccountCatalogItem( - "HANATOUR_TD", - "HANATOUR TD CO., LTD.", - "LEISURE", - "TRAVEL_AGENT") - ); - private static final Set ROOM_TYPE_CODES = Set.of("TWN", "KING", "DBL", "SGL", "TRP", "RM1", "RM2", "RM3"); - private static final Set RATE_CODES = Set.of("BAR", "RACK", "PACKAGE", "GROUP", "FIT"); - - /** - * 按 Account Code 查询固定种子目录,比较保持大小写敏感。 - */ - @Override - public Optional findAccount(String accountCode) { - String normalized = trimToNull(accountCode); - return normalized == null ? Optional.empty() : Optional.ofNullable(ACCOUNT_CATALOG.get(normalized)); - } - - /** - * 判断房型代码是否存在于第一版固定目录。 - */ - @Override - public boolean isKnownRoomTypeCode(String roomTypeCode) { - String normalized = trimToNull(roomTypeCode); - return normalized != null && ROOM_TYPE_CODES.contains(normalized); - } - - /** - * 判断 Rate Code 是否存在于第一版固定目录。 - */ - @Override - public boolean isKnownRateCode(String rateCode) { - String normalized = trimToNull(rateCode); - return normalized != null && RATE_CODES.contains(normalized); - } - - private String trimToNull(String value) { - if (value == null) { - return null; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } -} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java new file mode 100644 index 0000000..c76de97 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogLookupServiceImpl.java @@ -0,0 +1,296 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAccountSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogCodeSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogType; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogLookupRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupItemResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogLookupResult; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4CatalogRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogLookupService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.springframework.stereotype.Service; + +/** + * Reservation V4 目录 lookup 查询服务实现。查询数据库目录并转换为前端统一 wrapper。 + */ +@Service +public class ReservationV4CatalogLookupServiceImpl implements ReservationV4CatalogLookupService { + + private static final int DEFAULT_PAGE_NUM = 1; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_NUM = 1000; + private static final int MAX_PAGE_SIZE = 100; + private static final String EMPTY_CATALOG_SOURCE = "DATABASE_EMPTY"; + private static final String EMPTY_CATALOG_VERSION = "unknown"; + + private final ReservationV4CatalogRepository catalogRepository; + private final HotelContextService hotelContextService; + private final ObjectMapper objectMapper; + + /** + * 注入目录 Repository、酒店上下文和 JSON 解析器,Service 负责权限后的酒店隔离和响应组装。 + */ + public ReservationV4CatalogLookupServiceImpl( + ReservationV4CatalogRepository catalogRepository, + HotelContextService hotelContextService, + ObjectMapper objectMapper) { + this.catalogRepository = catalogRepository; + this.hotelContextService = hotelContextService; + this.objectMapper = objectMapper; + } + + /** + * 查询 Account 目录下拉数据。 + */ + @Override + public ReservationV4CatalogLookupResult listAccounts(ReservationV4CatalogLookupRequest request) { + String hotelId = resolveHotelId(request == null ? null : request.hotelId()); + int pageNum = normalizePageNum(request == null ? null : request.pageNum()); + int pageSize = normalizePageSize(request == null ? null : request.pageSize()); + ReservationPageSnapshot page = catalogRepository.queryActiveAccounts( + hotelId, + request == null ? null : request.keyword(), + pageNum, + pageSize); + List items = page.items().stream() + .map(this::accountItem) + .toList(); + return new ReservationV4CatalogLookupResult( + hotelId, + ReservationV4CatalogType.ACCOUNT.name(), + sourceSystem(page.items()), + catalogVersion(page.items()), + false, + items, + new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()), + warnings(page.items())); + } + + /** + * 查询 Room Type 目录下拉数据。 + */ + @Override + public ReservationV4CatalogLookupResult listRoomTypes(ReservationV4CatalogLookupRequest request) { + return listCatalogCodes(request, ReservationV4CatalogType.ROOM_TYPE); + } + + /** + * 查询 Rate Code 目录下拉数据。 + */ + @Override + public ReservationV4CatalogLookupResult listRateCodes(ReservationV4CatalogLookupRequest request) { + return listCatalogCodes(request, ReservationV4CatalogType.RATE_CODE); + } + + /** + * 查询 Room Type / Rate Code 等通用代码目录,并包装为统一 lookup 响应。 + */ + private ReservationV4CatalogLookupResult listCatalogCodes( + ReservationV4CatalogLookupRequest request, + ReservationV4CatalogType catalogType) { + String hotelId = resolveHotelId(request == null ? null : request.hotelId()); + int pageNum = normalizePageNum(request == null ? null : request.pageNum()); + int pageSize = normalizePageSize(request == null ? null : request.pageSize()); + ReservationPageSnapshot page = catalogRepository.queryActiveCatalogCodes( + hotelId, + catalogType.name(), + request == null ? null : request.keyword(), + pageNum, + pageSize); + List items = page.items().stream() + .map(snapshot -> codeItem(catalogType, snapshot)) + .toList(); + return new ReservationV4CatalogLookupResult( + hotelId, + catalogType.name(), + sourceSystem(page.items()), + catalogVersion(page.items()), + false, + items, + new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()), + warnings(page.items())); + } + + /** + * 将 Account 快照转换为前端统一目录项,并补齐 Market / Source 显示名。 + */ + private ReservationV4CatalogLookupItemResult accountItem(ReservationV4CatalogAccountSnapshot snapshot) { + String marketName = catalogRepository.findActiveCatalogCode( + snapshot.hotelId(), + ReservationV4CatalogType.MARKET.name(), + snapshot.marketCode()) + .map(ReservationV4CatalogCodeSnapshot::displayName) + .orElse(snapshot.marketCode()); + String sourceName = catalogRepository.findActiveCatalogCode( + snapshot.hotelId(), + ReservationV4CatalogType.SOURCE.name(), + snapshot.sourceCode()) + .map(ReservationV4CatalogCodeSnapshot::displayName) + .orElse(snapshot.sourceCode()); + return new ReservationV4CatalogLookupItemResult( + snapshot.accountCode(), + snapshot.accountName(), + snapshot.status(), + snapshot.sourceSystem(), + snapshot.marketCode(), + marketName, + snapshot.sourceCode(), + sourceName, + null, + null); + } + + /** + * 将通用代码快照转换为前端统一目录项,并按目录类型解析扩展 metadata。 + */ + private ReservationV4CatalogLookupItemResult codeItem( + ReservationV4CatalogType catalogType, + ReservationV4CatalogCodeSnapshot snapshot) { + return new ReservationV4CatalogLookupItemResult( + snapshot.code(), + snapshot.displayName(), + snapshot.status(), + snapshot.sourceSystem(), + null, + null, + null, + null, + catalogType == ReservationV4CatalogType.ROOM_TYPE ? metadataInteger(snapshot.metadataJson(), "adult_capacity") : null, + catalogType == ReservationV4CatalogType.RATE_CODE ? metadataBoolean(snapshot.metadataJson(), "pricing_available", false) : null); + } + + /** + * 从当前页第一条目录项推导目录来源;空页返回数据库空目录标记。 + */ + private String sourceSystem(List items) { + if (items == null || items.isEmpty()) { + return EMPTY_CATALOG_SOURCE; + } + Object first = items.get(0); + if (first instanceof ReservationV4CatalogAccountSnapshot account) { + return account.sourceSystem(); + } + if (first instanceof ReservationV4CatalogCodeSnapshot code) { + return code.sourceSystem(); + } + return EMPTY_CATALOG_SOURCE; + } + + /** + * 从当前页第一条目录项推导目录版本;空页返回 unknown,提示前端不可缓存为正式版本。 + */ + private String catalogVersion(List items) { + if (items == null || items.isEmpty()) { + return EMPTY_CATALOG_VERSION; + } + Object first = items.get(0); + if (first instanceof ReservationV4CatalogAccountSnapshot account) { + return account.catalogVersion(); + } + if (first instanceof ReservationV4CatalogCodeSnapshot code) { + return code.catalogVersion(); + } + return EMPTY_CATALOG_VERSION; + } + + /** + * 根据目录来源生成前端可展示的非阻塞提示。 + */ + private List warnings(List items) { + if (items == null || items.isEmpty()) { + return List.of("当前酒店暂无可用目录数据。"); + } + if ("FIXED_SEED_IMPORT".equals(sourceSystem(items))) { + return List.of("当前目录来自固定种子初始化,后续接入真实 PMS 或后台维护后会替换。"); + } + return List.of(); + } + + /** + * 从目录 metadata JSON 中读取整数扩展字段,格式异常时安全降级为空。 + */ + private Integer metadataInteger(String metadataJson, String fieldName) { + JsonNode node = metadataNode(metadataJson); + if (node == null || !node.has(fieldName) || !node.get(fieldName).canConvertToInt()) { + return null; + } + return node.get(fieldName).asInt(); + } + + /** + * 从目录 metadata JSON 中读取布尔扩展字段,缺失或格式异常时返回默认值。 + */ + private Boolean metadataBoolean(String metadataJson, String fieldName, boolean defaultValue) { + JsonNode node = metadataNode(metadataJson); + if (node == null || !node.has(fieldName)) { + return defaultValue; + } + return node.get(fieldName).asBoolean(defaultValue); + } + + /** + * 解析目录 metadata JSON,坏数据不向前端透出异常细节。 + */ + private JsonNode metadataNode(String metadataJson) { + String normalized = trimToNull(metadataJson); + if (normalized == null) { + return null; + } + try { + return objectMapper.readTree(normalized); + } catch (Exception ignored) { + return null; + } + } + + /** + * 解析当前请求酒店并套用登录用户的酒店访问权校验。 + */ + private String resolveHotelId(String hotelId) { + try { + return hotelContextService.resolveCurrentHotelId(hotelId); + } catch (HotelContextException exception) { + throw new ReservationTaskWorkflowException( + exception.getStatus(), + exception.getErrorCode(), + exception.getMessage()); + } + } + + /** + * 归一化页码,避免异常大页码或非法页码影响查询稳定性。 + */ + private int normalizePageNum(Integer pageNum) { + if (pageNum == null || pageNum < 1) { + return DEFAULT_PAGE_NUM; + } + return Math.min(pageNum, MAX_PAGE_NUM); + } + + /** + * 归一化分页大小,第一版限制单次最多返回 100 条。 + */ + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + /** + * 去除字符串首尾空白,空白值统一视为 null。 + */ + private String trimToNull(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java index 256af21..910433e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java @@ -142,7 +142,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ ensureNoPriorOrderTaskBlocking(orderTask); ensureBasicInformationConfirmed(orderTask, card); - String confirmedPayloadJson = confirmedPayloadJson(card, request == null ? null : request.confirmedPayload()); + String confirmedPayloadJson = confirmedPayloadJson( + orderTask.hotelId(), + card, + request == null ? null : request.confirmedPayload()); String actorId = actorIdentifier(actor); boolean updated = workflowRepository.confirmTaskCardWithVersion( orderTask.hotelId(), @@ -186,7 +189,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ card, confirmedPayload, request == null ? null : request.fieldOverrides()); - validateAndEnrichConfirmedPayload(card, confirmedPayload); + validateAndEnrichConfirmedPayload(orderTask.hotelId(), card, confirmedPayload); String actorId = actorIdentifier(actor); String confirmedPayloadJson = toJson(confirmedPayload); String reviewResolutionJson = reviewResolutionJson( @@ -469,9 +472,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。"); } - private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { + private String confirmedPayloadJson(String hotelId, ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { ObjectNode payload = confirmedPayloadObject(card, confirmedPayload); - validateAndEnrichConfirmedPayload(card, payload); + validateAndEnrichConfirmedPayload(hotelId, card, payload); return toJson(payload); } @@ -610,12 +613,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0)); } - private void validateAndEnrichConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload) { + private void validateAndEnrichConfirmedPayload(String hotelId, ReservationV4TaskCardSnapshot card, ObjectNode payload) { List details = new ArrayList<>(); if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { - validateAndEnrichBasicInformation(payload, details); + validateAndEnrichBasicInformation(hotelId, payload, details); } else { - validateBusinessCardPayload(payload, details); + validateBusinessCardPayload(hotelId, payload, details); } if (!details.isEmpty()) { throw new ReservationTaskWorkflowException( @@ -626,14 +629,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } - private void validateAndEnrichBasicInformation(ObjectNode payload, List details) { + private void validateAndEnrichBasicInformation(String hotelId, ObjectNode payload, List details) { ObjectNode basicInformation = ensureBasicInformationObject(payload); String accountCode = textAt(basicInformation, "account_code"); if (accountCode == null) { details.add("basic_information.account_code: Account Code 不能为空。"); return; } - ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null); + ReservationV4AccountCatalogItem account = directoryService.findAccount(hotelId, accountCode).orElse(null); if (account == null) { details.add("basic_information.account_code: Account Code 不在信息系统目录中。"); return; @@ -663,13 +666,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } - private void validateBusinessCardPayload(ObjectNode payload, List details) { + private void validateBusinessCardPayload(String hotelId, ObjectNode payload, List details) { JsonNode businessFields = payload.path("business_fields"); JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload; - validateBusinessCatalogFields(fieldRoot, "business_fields", details); + validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details); } - private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List details) { + private void validateBusinessCatalogFields(String hotelId, JsonNode node, String fieldPath, List details) { if (node == null || node.isMissingNode() || node.isNull()) { return; } @@ -679,30 +682,39 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ Map.Entry field = fields.next(); String childPath = fieldPath + "." + field.getKey(); if ("rate_code".equals(field.getKey())) { - validateRateCode(field.getValue(), childPath, details); + validateRateCode(hotelId, field.getValue(), childPath, details); + } else if ("target_room_type_code".equals(field.getKey())) { + validateRoomTypeCode(hotelId, field.getValue(), childPath, details); } else if ("room_items".equals(field.getKey())) { - validateRoomItems(field.getValue(), childPath, details); + validateRoomItems(hotelId, field.getValue(), childPath, details); } else { - validateBusinessCatalogFields(field.getValue(), childPath, details); + validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details); } } return; } if (node.isArray()) { for (int index = 0; index < node.size(); index++) { - validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details); + validateBusinessCatalogFields(hotelId, node.get(index), fieldPath + "." + index, details); } } } - private void validateRateCode(JsonNode rateCode, String fieldPath, List details) { + private void validateRateCode(String hotelId, JsonNode rateCode, String fieldPath, List details) { String code = textValue(rateCode); - if (code != null && !directoryService.isKnownRateCode(code)) { + if (code != null && !directoryService.isKnownRateCode(hotelId, code)) { details.add(fieldPath + ": Rate Code 不在第一版目录中。"); } } - private void validateRoomItems(JsonNode roomItems, String fieldPath, List details) { + private void validateRoomTypeCode(String hotelId, JsonNode roomTypeCode, String fieldPath, List details) { + String code = textValue(roomTypeCode); + if (code != null && !directoryService.isKnownRoomTypeCode(hotelId, code)) { + details.add(fieldPath + ": 房型代码不在第一版目录中。"); + } + } + + private void validateRoomItems(String hotelId, JsonNode roomItems, String fieldPath, List details) { if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) { return; } @@ -716,10 +728,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ details.add(fieldPath + "." + index + ": 房型明细必须是对象。"); continue; } - String roomTypeCode = textAt(item, "room_type_code"); - if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) { - details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。"); - } + validateRoomTypeCode(hotelId, item.get("room_type_code"), fieldPath + "." + index + ".room_type_code", details); } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4DatabaseDirectoryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4DatabaseDirectoryServiceImpl.java new file mode 100644 index 0000000..ca5913e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4DatabaseDirectoryServiceImpl.java @@ -0,0 +1,59 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogType; +import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4CatalogRepository; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService; +import java.util.Optional; +import org.springframework.stereotype.Service; + +/** + * Reservation V4 数据库目录服务实现。优先使用酒店维度目录表,不再把固定种子作为全局运行时事实。 + */ +@Service +public class ReservationV4DatabaseDirectoryServiceImpl implements ReservationV4DirectoryService { + + private final ReservationV4CatalogRepository catalogRepository; + + /** + * 注入目录 Repository,目录服务负责把持久化快照转换为业务校验需要的稳定目录项。 + */ + public ReservationV4DatabaseDirectoryServiceImpl(ReservationV4CatalogRepository catalogRepository) { + this.catalogRepository = catalogRepository; + } + + /** + * 按酒店和 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。 + */ + @Override + public Optional findAccount(String hotelId, String accountCode) { + return catalogRepository.findActiveAccount(hotelId, accountCode) + .map(snapshot -> new ReservationV4AccountCatalogItem( + snapshot.accountCode(), + snapshot.accountName(), + snapshot.marketCode(), + snapshot.sourceCode())); + } + + /** + * 判断房型代码是否在当前酒店 ACTIVE Room Type 目录中。 + */ + @Override + public boolean isKnownRoomTypeCode(String hotelId, String roomTypeCode) { + return catalogRepository.findActiveCatalogCode( + hotelId, + ReservationV4CatalogType.ROOM_TYPE.name(), + roomTypeCode).isPresent(); + } + + /** + * 判断 Rate Code 是否在当前酒店 ACTIVE Rate Code 目录中。 + */ + @Override + public boolean isKnownRateCode(String hotelId, String rateCode) { + return catalogRepository.findActiveCatalogCode( + hotelId, + ReservationV4CatalogType.RATE_CODE.name(), + rateCode).isPresent(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java index bb628d4..4a37c1e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java @@ -802,7 +802,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService return List.of(); } if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { - return basicInformationFields(displayPayload, validationErrors, availability); + return basicInformationFields(card.hotelId(), displayPayload, validationErrors, availability); } if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()) || ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) { @@ -812,12 +812,13 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService } private List basicInformationFields( + String hotelId, JsonNode displayPayload, JsonNode validationErrors, ReservationV4ActionAvailabilityResult availability) { JsonNode basicInformation = basicInformationNode(displayPayload); String accountCode = textAt(basicInformation, "account_code"); - Optional account = directoryService.findAccount(accountCode); + Optional account = directoryService.findAccount(hotelId, accountCode); String marketCode = firstText(textAt(basicInformation, "market_code"), account.map(ReservationV4AccountCatalogItem::marketCode).orElse(null)); String sourceCode = firstText(textAt(basicInformation, "source_code"), account.map(ReservationV4AccountCatalogItem::sourceCode).orElse(null)); List fields = new ArrayList<>(); @@ -834,7 +835,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService "reservation_v4_account_catalog", false, validationMessages(validationErrors, "/basic_information/account_code", "basic_information.account_code"), - "第一版 Account 使用后端固定目录,不能自由输入。")); + "Account 使用酒店目录 lookup,不能自由输入。")); fields.add(field( "basic_information.market_code", "/basic_information/market_code", diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java index 19babd4..5e1aabd 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4TaskIntakeServiceImpl.java @@ -182,7 +182,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta ObjectNode safeBasicInformation = basicInformation.isObject() ? ((ObjectNode) basicInformation).deepCopy() : objectMapper.createObjectNode(); - List validationDetails = validateAndEnrichBasicInformation(safeBasicInformation); + List validationDetails = validateAndEnrichBasicInformation(sourceMessage.hotelId(), safeBasicInformation); boolean reviewRequired = manualReviewRequired || !validationDetails.isEmpty(); ObjectNode displayPayload = objectMapper.createObjectNode(); displayPayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name()); @@ -215,7 +215,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta ReservationV4AcceptedEventDraft acceptedEvent, LocalDateTime now) { ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson()); - List validationDetails = validateBusinessCardDisplayPayload(displayPayload); + List validationDetails = validateBusinessCardDisplayPayload(sourceMessage.hotelId(), displayPayload); boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty(); workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft( sourceMessage.hotelId(), @@ -376,14 +376,14 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta /** * 校验并派生 Basic Information 的 Account 目录字段,错误只写展示卡,不改 AI 原始 payload。 */ - private List validateAndEnrichBasicInformation(ObjectNode basicInformation) { + private List validateAndEnrichBasicInformation(String hotelId, ObjectNode basicInformation) { List details = new ArrayList<>(); String accountCode = trimToNull(textAt(basicInformation, "account_code")); if (accountCode == null) { details.add("basic_information.account_code: Account Code 不能为空。"); return details; } - ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null); + ReservationV4AccountCatalogItem account = directoryService.findAccount(hotelId, accountCode).orElse(null); if (account == null) { details.add("basic_information.account_code: Account Code 不在信息系统目录中。"); return details; @@ -397,15 +397,15 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta /** * 校验 V4 业务卡展示字段中的第一版目录值,失败时卡片进入人工复核。 */ - private List validateBusinessCardDisplayPayload(ObjectNode displayPayload) { + private List validateBusinessCardDisplayPayload(String hotelId, ObjectNode displayPayload) { List details = new ArrayList<>(); JsonNode businessFields = displayPayload.path("business_fields"); JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload; - validateBusinessCatalogFields(fieldRoot, "business_fields", details); + validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details); return details; } - private void validateBusinessCatalogFields(JsonNode node, String fieldPath, List details) { + private void validateBusinessCatalogFields(String hotelId, JsonNode node, String fieldPath, List details) { if (node == null || node.isMissingNode() || node.isNull()) { return; } @@ -415,30 +415,39 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta Map.Entry field = fields.next(); String childPath = fieldPath + "." + field.getKey(); if ("rate_code".equals(field.getKey())) { - validateRateCode(field.getValue(), childPath, details); + validateRateCode(hotelId, field.getValue(), childPath, details); + } else if ("target_room_type_code".equals(field.getKey())) { + validateRoomTypeCode(hotelId, field.getValue(), childPath, details); } else if ("room_items".equals(field.getKey())) { - validateRoomItems(field.getValue(), childPath, details); + validateRoomItems(hotelId, field.getValue(), childPath, details); } else { - validateBusinessCatalogFields(field.getValue(), childPath, details); + validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details); } } return; } if (node.isArray()) { for (int index = 0; index < node.size(); index++) { - validateBusinessCatalogFields(node.get(index), fieldPath + "." + index, details); + validateBusinessCatalogFields(hotelId, node.get(index), fieldPath + "." + index, details); } } } - private void validateRateCode(JsonNode rateCode, String fieldPath, List details) { + private void validateRateCode(String hotelId, JsonNode rateCode, String fieldPath, List details) { String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText()); - if (code != null && !directoryService.isKnownRateCode(code)) { + if (code != null && !directoryService.isKnownRateCode(hotelId, code)) { details.add(fieldPath + ": Rate Code 不在第一版目录中。"); } } - private void validateRoomItems(JsonNode roomItems, String fieldPath, List details) { + private void validateRoomTypeCode(String hotelId, JsonNode roomTypeCode, String fieldPath, List details) { + String code = trimToNull(roomTypeCode == null || !roomTypeCode.isTextual() ? null : roomTypeCode.asText()); + if (code != null && !directoryService.isKnownRoomTypeCode(hotelId, code)) { + details.add(fieldPath + ": 房型代码不在第一版目录中。"); + } + } + + private void validateRoomItems(String hotelId, JsonNode roomItems, String fieldPath, List details) { if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) { return; } @@ -452,10 +461,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta details.add(fieldPath + "." + index + ": 房型明细必须是对象。"); continue; } - String roomTypeCode = trimToNull(textAt(item, "room_type_code")); - if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) { - details.add(fieldPath + "." + index + ".room_type_code: 房型代码不在第一版目录中。"); - } + validateRoomTypeCode(hotelId, item.get("room_type_code"), fieldPath + "." + index + ".room_type_code", details); } } diff --git a/server/src/main/resources/db/migration/V24__create_reservation_catalog_tables.sql b/server/src/main/resources/db/migration/V24__create_reservation_catalog_tables.sql new file mode 100644 index 0000000..52c7d3c --- /dev/null +++ b/server/src/main/resources/db/migration/V24__create_reservation_catalog_tables.sql @@ -0,0 +1,185 @@ +CREATE TABLE workflow_reservation_catalog_account ( + id BIGINT NOT NULL COMMENT 'Reservation 目录 Account ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID', + account_code VARCHAR(64) NOT NULL COMMENT 'Account 稳定代码,前端和 SuperAgent 使用该代码提交', + account_name VARCHAR(255) NOT NULL COMMENT 'Account 显示名称', + market_code VARCHAR(64) NOT NULL COMMENT 'Account 关联 Market 代码', + source_code VARCHAR(64) NOT NULL COMMENT 'Account 关联 Source 代码', + status VARCHAR(32) NOT NULL COMMENT '目录状态:ACTIVE、DISABLED', + source_system VARCHAR(32) NOT NULL COMMENT '目录来源:FIXED_SEED_IMPORT、SYSTEM_MANAGED、PMS_SYNC', + external_account_id VARCHAR(128) NULL COMMENT '外部系统 Account ID,未接 PMS 时为空', + catalog_version VARCHAR(64) NOT NULL COMMENT '目录版本,用于前端缓存和排查目录来源', + last_synced_at DATETIME(6) NULL COMMENT '最近同步 UTC 时间,固定种子导入时为空', + metadata_json LONGTEXT NULL COMMENT 'Account 扩展属性 JSON,第一版不参与业务校验', + 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_catalog_account_code (hotel_id, account_code), + KEY idx_reservation_catalog_account_status (hotel_id, status, account_code), + KEY idx_reservation_catalog_account_name (hotel_id, account_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 Account 目录表'; + +CREATE TABLE workflow_reservation_catalog_code ( + id BIGINT NOT NULL COMMENT 'Reservation 通用代码目录 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID', + catalog_type VARCHAR(32) NOT NULL COMMENT '目录类型:ROOM_TYPE、RATE_CODE、MARKET、SOURCE', + code VARCHAR(64) NOT NULL COMMENT '目录稳定代码,前端和 SuperAgent 使用该代码提交', + display_name VARCHAR(255) NOT NULL COMMENT '目录显示名称', + status VARCHAR(32) NOT NULL COMMENT '目录状态:ACTIVE、DISABLED', + source_system VARCHAR(32) NOT NULL COMMENT '目录来源:FIXED_SEED_IMPORT、SYSTEM_MANAGED、PMS_SYNC', + external_id VARCHAR(128) NULL COMMENT '外部系统目录 ID,未接 PMS 时为空', + sort_order INT NOT NULL DEFAULT 0 COMMENT '目录展示排序', + catalog_version VARCHAR(64) NOT NULL COMMENT '目录版本,用于前端缓存和排查目录来源', + last_synced_at DATETIME(6) NULL COMMENT '最近同步 UTC 时间,固定种子导入时为空', + metadata_json LONGTEXT NULL COMMENT '目录扩展属性 JSON,例如房型人数、Rate Code 价格能力', + 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_catalog_code (hotel_id, catalog_type, code), + KEY idx_reservation_catalog_code_status (hotel_id, catalog_type, status, sort_order), + KEY idx_reservation_catalog_code_name (hotel_id, catalog_type, display_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 通用代码目录表'; + +INSERT INTO workflow_reservation_catalog_account ( + id, hotel_id, account_code, account_name, market_code, source_code, status, source_system, + external_account_id, catalog_version, last_synced_at, metadata_json, version, created_at, updated_at, + logic_deleted_at, logic_deleted_reason +) VALUES + (240000000001, 'HOTEL-TEST', 'QBD_TRAVEL', 'Q.B.D. TRAVEL GROUP CO., LTD', 'LEISURE', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000000002, 'HOTEL-TEST', 'LIAN_TAI', 'LIAN TAI TRAVEL (THAILAND) CO., LTD.', 'LEISURE', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000000003, 'HOTEL-TEST', 'HANATOUR_TD', 'HANATOUR TD CO., LTD.', 'LEISURE', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000001001, 'HOTEL-DEV', 'QBD_TRAVEL', 'Q.B.D. TRAVEL GROUP CO., LTD', 'LEISURE', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000001002, 'HOTEL-DEV', 'LIAN_TAI', 'LIAN TAI TRAVEL (THAILAND) CO., LTD.', 'LEISURE', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000001003, 'HOTEL-DEV', 'HANATOUR_TD', 'HANATOUR TD CO., LTD.', 'LEISURE', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL); + +INSERT INTO workflow_reservation_catalog_code ( + id, hotel_id, catalog_type, code, display_name, status, source_system, external_id, sort_order, + catalog_version, last_synced_at, metadata_json, version, created_at, updated_at, logic_deleted_at, + logic_deleted_reason +) VALUES + (240000010101, 'HOTEL-TEST', 'ROOM_TYPE', 'TWN', 'TWN', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010102, 'HOTEL-TEST', 'ROOM_TYPE', 'KING', 'KING', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 20, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010103, 'HOTEL-TEST', 'ROOM_TYPE', 'DBL', 'DBL', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 30, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010104, 'HOTEL-TEST', 'ROOM_TYPE', 'SGL', 'SGL', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 40, 'seed-20260719-v1', NULL, '{"adult_capacity":1}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010105, 'HOTEL-TEST', 'ROOM_TYPE', 'TRP', 'TRP', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 50, 'seed-20260719-v1', NULL, '{"adult_capacity":3}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010106, 'HOTEL-TEST', 'ROOM_TYPE', 'RM1', 'RM1', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 60, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010107, 'HOTEL-TEST', 'ROOM_TYPE', 'RM2', 'RM2', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 70, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010108, 'HOTEL-TEST', 'ROOM_TYPE', 'RM3', 'RM3', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 80, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010201, 'HOTEL-TEST', 'RATE_CODE', 'BAR', 'BAR', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010202, 'HOTEL-TEST', 'RATE_CODE', 'RACK', 'RACK', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 20, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010203, 'HOTEL-TEST', 'RATE_CODE', 'PACKAGE', 'PACKAGE', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 30, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010204, 'HOTEL-TEST', 'RATE_CODE', 'GROUP', 'GROUP', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 40, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010205, 'HOTEL-TEST', 'RATE_CODE', 'FIT', 'FIT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 50, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010301, 'HOTEL-TEST', 'MARKET', 'LEISURE', 'LEISURE', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000010401, 'HOTEL-TEST', 'SOURCE', 'TRAVEL_AGENT', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020101, 'HOTEL-DEV', 'ROOM_TYPE', 'TWN', 'TWN', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020102, 'HOTEL-DEV', 'ROOM_TYPE', 'KING', 'KING', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 20, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020103, 'HOTEL-DEV', 'ROOM_TYPE', 'DBL', 'DBL', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 30, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020104, 'HOTEL-DEV', 'ROOM_TYPE', 'SGL', 'SGL', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 40, 'seed-20260719-v1', NULL, '{"adult_capacity":1}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020105, 'HOTEL-DEV', 'ROOM_TYPE', 'TRP', 'TRP', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 50, 'seed-20260719-v1', NULL, '{"adult_capacity":3}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020106, 'HOTEL-DEV', 'ROOM_TYPE', 'RM1', 'RM1', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 60, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020107, 'HOTEL-DEV', 'ROOM_TYPE', 'RM2', 'RM2', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 70, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020108, 'HOTEL-DEV', 'ROOM_TYPE', 'RM3', 'RM3', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 80, 'seed-20260719-v1', NULL, '{"adult_capacity":2}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020201, 'HOTEL-DEV', 'RATE_CODE', 'BAR', 'BAR', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020202, 'HOTEL-DEV', 'RATE_CODE', 'RACK', 'RACK', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 20, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020203, 'HOTEL-DEV', 'RATE_CODE', 'PACKAGE', 'PACKAGE', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 30, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020204, 'HOTEL-DEV', 'RATE_CODE', 'GROUP', 'GROUP', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 40, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020205, 'HOTEL-DEV', 'RATE_CODE', 'FIT', 'FIT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 50, 'seed-20260719-v1', NULL, '{"pricing_available":false}', 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020301, 'HOTEL-DEV', 'MARKET', 'LEISURE', 'LEISURE', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL), + (240000020401, 'HOTEL-DEV', 'SOURCE', 'TRAVEL_AGENT', 'TRAVEL_AGENT', 'ACTIVE', 'FIXED_SEED_IMPORT', NULL, 10, 'seed-20260719-v1', NULL, NULL, 0, '2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL); + +-- 如果迁移执行时平台酒店已经存在,则给当前 ACTIVE 酒店也导入一份固定种子,避免非 HOTEL-TEST/DEV 环境 lookup 全空。 +INSERT INTO workflow_reservation_catalog_account ( + id, hotel_id, account_code, account_name, market_code, source_code, status, source_system, + external_account_id, catalog_version, last_synced_at, metadata_json, version, created_at, updated_at, + logic_deleted_at, logic_deleted_reason +) +SELECT + 1000000000000000 + MOD(h.id, 1000000000) * 1000 + seed.seed_offset, + h.hotel_id, + seed.account_code, + seed.account_name, + 'LEISURE', + 'TRAVEL_AGENT', + 'ACTIVE', + 'FIXED_SEED_IMPORT', + NULL, + 'seed-20260719-v1', + NULL, + NULL, + 0, + '2026-07-19 00:00:00.000000', + '2026-07-19 00:00:00.000000', + NULL, + NULL +FROM platform_hotel h +JOIN ( + SELECT 1 AS seed_offset, 'QBD_TRAVEL' AS account_code, 'Q.B.D. TRAVEL GROUP CO., LTD' AS account_name + UNION ALL SELECT 2, 'LIAN_TAI', 'LIAN TAI TRAVEL (THAILAND) CO., LTD.' + UNION ALL SELECT 3, 'HANATOUR_TD', 'HANATOUR TD CO., LTD.' +) seed ON 1 = 1 +WHERE h.hotel_status = 'ACTIVE' + AND h.hotel_id NOT IN ('HOTEL-TEST', 'HOTEL-DEV') + AND NOT EXISTS ( + SELECT 1 + FROM workflow_reservation_catalog_account existing + WHERE existing.hotel_id = h.hotel_id + AND existing.account_code = seed.account_code + ); + +INSERT INTO workflow_reservation_catalog_code ( + id, hotel_id, catalog_type, code, display_name, status, source_system, external_id, sort_order, + catalog_version, last_synced_at, metadata_json, version, created_at, updated_at, logic_deleted_at, + logic_deleted_reason +) +SELECT + 1000001000000000 + MOD(h.id, 1000000000) * 1000 + seed.seed_offset, + h.hotel_id, + seed.catalog_type, + seed.code, + seed.display_name, + 'ACTIVE', + 'FIXED_SEED_IMPORT', + NULL, + seed.sort_order, + 'seed-20260719-v1', + NULL, + seed.metadata_json, + 0, + '2026-07-19 00:00:00.000000', + '2026-07-19 00:00:00.000000', + NULL, + NULL +FROM platform_hotel h +JOIN ( + SELECT 101 AS seed_offset, 'ROOM_TYPE' AS catalog_type, 'TWN' AS code, 'TWN' AS display_name, 10 AS sort_order, '{"adult_capacity":2}' AS metadata_json + UNION ALL SELECT 102, 'ROOM_TYPE', 'KING', 'KING', 20, '{"adult_capacity":2}' + UNION ALL SELECT 103, 'ROOM_TYPE', 'DBL', 'DBL', 30, '{"adult_capacity":2}' + UNION ALL SELECT 104, 'ROOM_TYPE', 'SGL', 'SGL', 40, '{"adult_capacity":1}' + UNION ALL SELECT 105, 'ROOM_TYPE', 'TRP', 'TRP', 50, '{"adult_capacity":3}' + UNION ALL SELECT 106, 'ROOM_TYPE', 'RM1', 'RM1', 60, '{"adult_capacity":2}' + UNION ALL SELECT 107, 'ROOM_TYPE', 'RM2', 'RM2', 70, '{"adult_capacity":2}' + UNION ALL SELECT 108, 'ROOM_TYPE', 'RM3', 'RM3', 80, '{"adult_capacity":2}' + UNION ALL SELECT 201, 'RATE_CODE', 'BAR', 'BAR', 10, '{"pricing_available":false}' + UNION ALL SELECT 202, 'RATE_CODE', 'RACK', 'RACK', 20, '{"pricing_available":false}' + UNION ALL SELECT 203, 'RATE_CODE', 'PACKAGE', 'PACKAGE', 30, '{"pricing_available":false}' + UNION ALL SELECT 204, 'RATE_CODE', 'GROUP', 'GROUP', 40, '{"pricing_available":false}' + UNION ALL SELECT 205, 'RATE_CODE', 'FIT', 'FIT', 50, '{"pricing_available":false}' + UNION ALL SELECT 301, 'MARKET', 'LEISURE', 'LEISURE', 10, NULL + UNION ALL SELECT 401, 'SOURCE', 'TRAVEL_AGENT', 'TRAVEL_AGENT', 10, NULL +) seed ON 1 = 1 +WHERE h.hotel_status = 'ACTIVE' + AND h.hotel_id NOT IN ('HOTEL-TEST', 'HOTEL-DEV') + AND NOT EXISTS ( + SELECT 1 + FROM workflow_reservation_catalog_code existing + WHERE existing.hotel_id = h.hotel_id + AND existing.catalog_type = seed.catalog_type + AND existing.code = seed.code + ); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java new file mode 100644 index 0000000..09d7ea8 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java @@ -0,0 +1,147 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken; +import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cn.nianxx.thhotel.ThHotelApplication; +import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository; +import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus; +import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity; +import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository; +import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:reservation_v4_catalog_lookup_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=v4-catalog-admin", + "auth.bootstrap.admin.password=Admin@123456", + "auth.bootstrap.admin.display-name=V4目录管理员", + "auth.bootstrap.default-hotel-id=HOTEL-TEST", + "auth.bootstrap.default-hotel-name=测试酒店", + "auth.bootstrap.default-hotel-time-zone=Asia/Bangkok", + "auth.session.ttl-minutes=720" + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ReservationV4CatalogLookupControllerTest { + + private static final String HOTEL_ID = "HOTEL-TEST"; + private static final String OTHER_HOTEL_ID = "HOTEL-OTHER"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private PlatformIdentityRepository identityRepository; + + @Autowired + private PlatformHotelRepository hotelRepository; + + @Autowired + private AuthPasswordService passwordService; + + private String adminToken; + private String noPermissionToken; + + @BeforeEach + void ensureNoPermissionUser() { + PlatformUserEntity user = identityRepository.findUserByUsername("v4-catalog-no-permission") + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername("v4-catalog-no-permission"); + created.setPasswordHash(passwordService.hash("NoPerm@123456")); + created.setDisplayName("V4 目录无权限用户"); + created.setUserStatus(PlatformUserStatus.ACTIVE.name()); + created.setSuperAdmin(false); + created.setPasswordChangedAt(now); + created.setCreatedAt(now); + created.setUpdatedAt(now); + identityRepository.insertUser(created); + return created; + }); + hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true); + } + + @Test + void shouldReturnAccountLookupFromDatabaseSeed() throws Exception { + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts") + .param("hotel_id", HOTEL_ID) + .param("keyword", "QBD") + .param("page_num", "1") + .param("page_size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hotel_id").value(HOTEL_ID)) + .andExpect(jsonPath("$.catalog_type").value("ACCOUNT")) + .andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT")) + .andExpect(jsonPath("$.stale").value(false)) + .andExpect(jsonPath("$.items[0].code").value("QBD_TRAVEL")) + .andExpect(jsonPath("$.items[0].display_name").value("Q.B.D. TRAVEL GROUP CO., LTD")) + .andExpect(jsonPath("$.items[0].market_code").value("LEISURE")) + .andExpect(jsonPath("$.items[0].source_code").value("TRAVEL_AGENT")) + .andExpect(jsonPath("$.page.page_num").value(1)) + .andExpect(jsonPath("$.page.page_size").value(20)) + .andExpect(jsonPath("$.page.total").value(1)) + .andExpect(jsonPath("$.warnings").isArray()); + } + + @Test + void shouldReturnRoomTypeAndRateCodeLookupFromDatabaseSeed() throws Exception { + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/room-types") + .param("hotel_id", HOTEL_ID) + .param("keyword", "RM2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.catalog_type").value("ROOM_TYPE")) + .andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT")) + .andExpect(jsonPath("$.items[0].code").value("RM2")) + .andExpect(jsonPath("$.items[0].status").value("ACTIVE")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/rate-codes") + .param("hotel_id", HOTEL_ID) + .param("keyword", "GROUP")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.catalog_type").value("RATE_CODE")) + .andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT")) + .andExpect(jsonPath("$.items[0].code").value("GROUP")) + .andExpect(jsonPath("$.items[0].pricing_available").value(false)); + } + + @Test + void shouldRejectCatalogLookupWithoutPermissionOrHotelAccess() throws Exception { + performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/lookups/accounts") + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts") + .param("hotel_id", OTHER_HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); + } + + private String adminToken() throws Exception { + if (adminToken == null) { + adminToken = loginToken(mockMvc, "v4-catalog-admin", "Admin@123456"); + } + return adminToken; + } + + private String noPermissionToken() throws Exception { + if (noPermissionToken == null) { + noPermissionToken = loginToken(mockMvc, "v4-catalog-no-permission", "NoPerm@123456"); + } + return noPermissionToken; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java index 62605a1..1b87da7 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java @@ -246,6 +246,32 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.details[0]").value("business_fields.after.room_items.0.room_type_code: 房型代码不在第一版目录中。")); } + @Test + void shouldRejectBusinessCardConfirmWhenTraceTargetRoomTypeCodeUnknown() throws Exception { + SeededOrderTask seeded = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-confirm-unknown-trace-target-room-001", + Instant.parse("2026-07-19T01:13:05Z"), + null, + """ + {"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","text":"Please add one extra bed.","target_room_type_code":"UNKNOWN_ROOM","extra_bed_room_count":1,"department_code":"FO"}]}} + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value("business_fields.trace_items.0.target_room_type_code: 房型代码不在第一版目录中。")); + } + @Test void shouldConfirmWrappedBusinessFieldsAndDropInjectedFields() throws Exception { SeededOrderTask seeded = seedOrderTaskWithBusinessCard( 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 a6babfa..c7b47b1 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 @@ -1279,6 +1279,35 @@ class SuperAgentTaskResultControllerTest { .contains("房型代码不在第一版目录中"); } + @Test + void shouldMarkV4TraceReviewRequiredWhenTargetRoomTypeCodeUnknown() throws Exception { + SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-unknown-target-room-001"); + String body = v4CancelTraceRoomingListBody("mail-v4-trace-unknown-target-room-001") + .replace("GRP-V4-CTR-001", "GRP-V4-TRACE-UNKNOWN-ROOM-001") + .replace("\"item_type\": \"GENERAL\"", "\"item_type\": \"EXTRA_BED\"") + .replace("\"text\": \"Guest requests quiet rooms.\"", "\"text\": \"Please add one extra bed.\"") + .replace("\"department_code\": \"FO\"", "\"target_room_type_code\": \"UNKNOWN_ROOM\",\n" + + " \"extra_bed_room_count\": 1,\n" + + " \"department_code\": \"FO\""); + + mockMvc.perform(signedPost(body, "nonce-v4-trace-unknown-target-room-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)); + + String traceValidationErrors = jdbcTemplate.queryForObject(""" + SELECT validation_errors_json + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + AND card_type = 'TRACE_RESERVATION_NOTES' + AND card_status = 'REVIEW_REQUIRED' + AND review_status = 'PENDING' + LIMIT 1 + """, String.class, source.inboxId()); + assertThat(traceValidationErrors) + .contains("business_fields.trace_items.0.target_room_type_code") + .contains("房型代码不在第一版目录中"); + } + @Test void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception { SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");