修复V4目录初始化和lookup空结果元数据
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
## 1. 当前 Checkpoint
|
||||
|
||||
- 名称:`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。
|
||||
- 状态: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;已补启动阶段默认酒店目录初始化和 lookup keyword 无结果时的 wrapper 元数据修正。
|
||||
- 目标:让 V4 任务详情字段控件不再依赖前端硬编码选项,后端入站、确认、复核统一按当前酒店数据库目录做校验。
|
||||
- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理后台,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不做前端页面。
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- `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[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录。V4 前端页面、目录管理后台、真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。
|
||||
- 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
|
||||
|
||||
@@ -58,9 +58,9 @@
|
||||
| `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。 |
|
||||
| `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` 时调用。 |
|
||||
| `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[]`。`keyword` 无匹配时 `items=[]` / `page.total=0`,但只要酒店未过滤目录存在,`catalog_source/catalog_version` 仍保持真实目录元数据,不代表目录未初始化。前端在 `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 全量房型。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `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` 表示后端未接真实价格,不要据此展示价格。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `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 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 |
|
||||
@@ -508,7 +508,7 @@ RESERVATION_ROOMING_LIST_GENERATE
|
||||
- 业务卡目录校验会递归检查 `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 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 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 或目录完整对象;后端确认前仍会重新校验目录。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source`、`catalog_version` 和 `warnings[]`。
|
||||
- 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 路由修订均已完成。
|
||||
|
||||
@@ -52,7 +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 创建,或不能使用固定种子作为业务事实源,必须补正式目录数据或新增导入脚本。
|
||||
- M002 V4 lookup 上线前,必须确认当前 `platform_hotel` 唯一 `ACTIVE` 酒店有可用目录数据。V24 会初始化 `HOTEL-TEST`、`HOTEL-DEV`,并给迁移执行时已经存在的 `ACTIVE` 酒店导入固定种子;如果生产真实酒店是在迁移后由 bootstrap 创建,`ReservationV4CatalogBootstrapRunner` 会在该酒店目录为空时补一份 `FIXED_SEED_IMPORT` 初始化目录。生产如不能使用固定种子作为业务事实源,必须在上线前补正式目录数据或新增导入脚本。
|
||||
- 管理后台启用后,不要继续把手工改库作为常规运营方式;用户、角色、菜单和酒店变更应通过 `/api/admin/**` 并写入管理审计。
|
||||
- 原文读取接口开启前,已经确认谁可以使用、在哪些场景使用、如何轮换访问 key。
|
||||
- 日志采集、错误响应和监控面板都不会展示邮件正文、HTML、附件 URL、Token、Cookie、客户姓名、邮箱、电话或支付信息。
|
||||
@@ -280,7 +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` 仍未实现。
|
||||
- V24 会新增 `workflow_reservation_catalog_account` 和 `workflow_reservation_catalog_code`,并初始化 `HOTEL-TEST`、`HOTEL-DEV` 以及迁移执行时已有 `ACTIVE` 酒店的 Account、Market、Source、Room Type、Rate Code 种子目录。启动后 `ReservationV4CatalogBootstrapRunner` 会补齐迁移后由平台 bootstrap 创建且目录为空的 `ACTIVE` 酒店。发布后需要确认 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 或测试库已经跑过。
|
||||
|
||||
@@ -485,5 +485,5 @@ M002 V4 CP2 设计文档已落地:
|
||||
- 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 价格 / 适用范围配置中心、目录管理后台和 SuperAgent 目录机器接口仍后置;当前 CP11 只是把固定种子导入数据库并开放前端 lookup API,不代表已接 PMS 全量目录。
|
||||
- 真实 PMS 目录同步、Rate Code 价格 / 适用范围配置中心、目录管理后台和 SuperAgent 目录机器接口仍后置;当前 CP11 只是把固定种子导入数据库、为迁移后创建的默认酒店做启动补种子并开放前端 lookup API,不代表已接 PMS 全量目录。
|
||||
- 真实 OPERA / OHIP、普通任务任意切换订单、字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构、历史旧 Parent Cancel Booking payload 批量迁移。
|
||||
|
||||
@@ -19,13 +19,13 @@ M002 V4 CP8 已实现第一版固定种子目录校验;M002 V4 CP11 已把该
|
||||
- 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 机器目录供给接口继续后置。
|
||||
本文记录真实目录和 lookup API 的设计与 CP11 第一版实现。CP11 新增 `workflow_reservation_catalog_account`、`workflow_reservation_catalog_code` 两张表,并通过 Flyway 初始化 `HOTEL-TEST`、`HOTEL-DEV` 以及迁移执行时已存在的 `ACTIVE` 平台酒店的固定种子目录;同时新增 `ReservationV4CatalogBootstrapRunner`,在平台默认酒店由启动流程创建后,如果该酒店目录为空,会补一份 `FIXED_SEED_IMPORT` 初始化目录。不再在运行时代码中把固定种子作为全局目录事实。`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`。
|
||||
CP11 之前固定种子实现位于后端 `ReservationV4DirectoryService` 和 `FixedReservationV4DirectoryServiceImpl`。CP11 起实现改为 `ReservationV4DatabaseDirectoryServiceImpl`,目录读取只走数据库目录表;固定种子通过 `V24__create_reservation_catalog_tables.sql` 初始化导入 dev/test 基准酒店和迁移时已有 `ACTIVE` 酒店,并由 `ReservationV4CatalogBootstrapRunner` 补齐迁移后才创建的默认 `ACTIVE` 酒店,来源标记为 `FIXED_SEED_IMPORT`。
|
||||
|
||||
| 目录 | 当前用途 | 当前固定值 | 当前限制 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -54,7 +54,7 @@ CP11 之前固定种子实现位于后端 `ReservationV4DirectoryService` 和 `F
|
||||
|
||||
| 阶段 | 来源 | 中文说明 | 适用目录 |
|
||||
| --- | --- | --- | --- |
|
||||
| Phase 0 | `FIXED_SEED_IMPORT` | CP11 已将固定种子通过 Flyway 导入数据库,不再作为运行时代码全局 Map | 当前 Account、Market、Source、Room Type、Rate Code |
|
||||
| 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 能力再接 |
|
||||
|
||||
@@ -203,6 +203,7 @@ Lookup API 属于前端业务查询接口,不给 SuperAgent 或 AgentBus 调
|
||||
- 酒店隔离:`hotel_id` 可选;不传时按当前用户默认酒店解析,传入时必须校验用户可访问。
|
||||
- 返回:只返回目录 code、显示名、状态、来源和安全元数据,不返回外部 PMS 原始响应。
|
||||
- 分页:`page_num` 从 1 开始,`page_size` 后端限制最大值。
|
||||
- `keyword` 无匹配时,`items=[]` 且 `page.total=0`;如果当前酒店未过滤的 ACTIVE 目录仍存在,`catalog_source` / `catalog_version` 继续返回真实目录元数据,不把筛选无结果误报为 `DATABASE_EMPTY`。
|
||||
|
||||
### 8.1 Account Lookup
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -32,6 +34,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* M003 登录权限底座启动初始化。只写入系统内置数据和首个超级管理员。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
|
||||
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
@@ -132,6 +132,70 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal
|
||||
return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前酒店是否已有 Account 目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。
|
||||
*/
|
||||
@Override
|
||||
public boolean hasAnyAccount(String hotelId) {
|
||||
String normalizedHotelId = trimToNull(hotelId);
|
||||
if (normalizedHotelId == null) {
|
||||
return false;
|
||||
}
|
||||
return accountMapper.selectCount(Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
|
||||
.eq(ReservationCatalogAccountEntity::getHotelId, normalizedHotelId)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前酒店是否已有通用代码目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。
|
||||
*/
|
||||
@Override
|
||||
public boolean hasAnyCatalogCode(String hotelId) {
|
||||
String normalizedHotelId = trimToNull(hotelId);
|
||||
if (normalizedHotelId == null) {
|
||||
return false;
|
||||
}
|
||||
return codeMapper.selectCount(Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
|
||||
.eq(ReservationCatalogCodeEntity::getHotelId, normalizedHotelId)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account Code 不存在时插入目录种子,存在时跳过以保持启动幂等。
|
||||
*/
|
||||
@Override
|
||||
public void insertAccountIfAbsent(ReservationCatalogAccountEntity entity) {
|
||||
if (entity == null
|
||||
|| trimToNull(entity.getHotelId()) == null
|
||||
|| trimToNull(entity.getAccountCode()) == null) {
|
||||
return;
|
||||
}
|
||||
Long existing = accountMapper.selectCount(Wrappers.<ReservationCatalogAccountEntity>lambdaQuery()
|
||||
.eq(ReservationCatalogAccountEntity::getHotelId, entity.getHotelId())
|
||||
.eq(ReservationCatalogAccountEntity::getAccountCode, entity.getAccountCode()));
|
||||
if (existing == null || existing == 0) {
|
||||
accountMapper.insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用目录 Code 不存在时插入目录种子,存在时跳过以保持启动幂等。
|
||||
*/
|
||||
@Override
|
||||
public void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity) {
|
||||
if (entity == null
|
||||
|| trimToNull(entity.getHotelId()) == null
|
||||
|| trimToNull(entity.getCatalogType()) == null
|
||||
|| trimToNull(entity.getCode()) == null) {
|
||||
return;
|
||||
}
|
||||
Long existing = codeMapper.selectCount(Wrappers.<ReservationCatalogCodeEntity>lambdaQuery()
|
||||
.eq(ReservationCatalogCodeEntity::getHotelId, entity.getHotelId())
|
||||
.eq(ReservationCatalogCodeEntity::getCatalogType, entity.getCatalogType())
|
||||
.eq(ReservationCatalogCodeEntity::getCode, entity.getCode()));
|
||||
if (existing == null || existing == 0) {
|
||||
codeMapper.insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private ReservationV4CatalogAccountSnapshot toAccountSnapshot(ReservationCatalogAccountEntity entity) {
|
||||
return new ReservationV4CatalogAccountSnapshot(
|
||||
entity.getId(),
|
||||
|
||||
@@ -3,6 +3,8 @@ 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.domain.ReservationCatalogAccountEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -38,4 +40,24 @@ public interface ReservationV4CatalogRepository {
|
||||
String keyword,
|
||||
int pageNum,
|
||||
int pageSize);
|
||||
|
||||
/**
|
||||
* 判断当前酒店是否已有任意 Account 目录记录,用于启动初始化避免覆盖人工目录。
|
||||
*/
|
||||
boolean hasAnyAccount(String hotelId);
|
||||
|
||||
/**
|
||||
* 判断当前酒店是否已有任意通用代码目录记录,用于启动初始化避免覆盖人工目录。
|
||||
*/
|
||||
boolean hasAnyCatalogCode(String hotelId);
|
||||
|
||||
/**
|
||||
* Account Code 不存在时插入固定种子目录,存在时保持原数据不变。
|
||||
*/
|
||||
void insertAccountIfAbsent(ReservationCatalogAccountEntity entity);
|
||||
|
||||
/**
|
||||
* 通用目录 Code 不存在时插入固定种子目录,存在时保持原数据不变。
|
||||
*/
|
||||
void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import cn.nianxx.thhotel.platform.hotel.domain.PlatformHotelEntity;
|
||||
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogSourceSystem;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CatalogType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogAccountEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationCatalogCodeEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4CatalogRepository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reservation V4 目录启动初始化器。补齐 Flyway 之后由平台启动流程创建的默认酒店目录种子。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
|
||||
public class ReservationV4CatalogBootstrapRunner implements ApplicationRunner {
|
||||
|
||||
private static final String SEED_CATALOG_VERSION = "seed-20260719-v1";
|
||||
private static final String MARKET_CODE = "LEISURE";
|
||||
private static final String SOURCE_CODE = "TRAVEL_AGENT";
|
||||
|
||||
private static final List<AccountSeed> ACCOUNT_SEEDS = List.of(
|
||||
new AccountSeed("QBD_TRAVEL", "Q.B.D. TRAVEL GROUP CO., LTD"),
|
||||
new AccountSeed("LIAN_TAI", "LIAN TAI TRAVEL (THAILAND) CO., LTD."),
|
||||
new AccountSeed("HANATOUR_TD", "HANATOUR TD CO., LTD."));
|
||||
|
||||
private static final List<CodeSeed> CODE_SEEDS = List.of(
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "TWN", "TWN", 10, "{\"adult_capacity\":2}"),
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "KING", "KING", 20, "{\"adult_capacity\":2}"),
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "DBL", "DBL", 30, "{\"adult_capacity\":2}"),
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "SGL", "SGL", 40, "{\"adult_capacity\":1}"),
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "TRP", "TRP", 50, "{\"adult_capacity\":3}"),
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM1", "RM1", 60, "{\"adult_capacity\":2}"),
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM2", "RM2", 70, "{\"adult_capacity\":2}"),
|
||||
new CodeSeed(ReservationV4CatalogType.ROOM_TYPE.name(), "RM3", "RM3", 80, "{\"adult_capacity\":2}"),
|
||||
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "BAR", "BAR", 10, "{\"pricing_available\":false}"),
|
||||
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "RACK", "RACK", 20, "{\"pricing_available\":false}"),
|
||||
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "PACKAGE", "PACKAGE", 30, "{\"pricing_available\":false}"),
|
||||
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "GROUP", "GROUP", 40, "{\"pricing_available\":false}"),
|
||||
new CodeSeed(ReservationV4CatalogType.RATE_CODE.name(), "FIT", "FIT", 50, "{\"pricing_available\":false}"),
|
||||
new CodeSeed(ReservationV4CatalogType.MARKET.name(), MARKET_CODE, MARKET_CODE, 10, null),
|
||||
new CodeSeed(ReservationV4CatalogType.SOURCE.name(), SOURCE_CODE, SOURCE_CODE, 10, null));
|
||||
|
||||
private final PlatformHotelRepository hotelRepository;
|
||||
private final ReservationV4CatalogRepository catalogRepository;
|
||||
|
||||
/**
|
||||
* 注入平台酒店 Repository 和 Reservation 目录 Repository,保持平台模块不反向依赖业务目录。
|
||||
*/
|
||||
public ReservationV4CatalogBootstrapRunner(
|
||||
PlatformHotelRepository hotelRepository,
|
||||
ReservationV4CatalogRepository catalogRepository) {
|
||||
this.hotelRepository = hotelRepository;
|
||||
this.catalogRepository = catalogRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时为当前 ACTIVE 酒店补齐初始化目录;已有目录的酒店不会被覆盖。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
List<PlatformHotelEntity> activeHotels = hotelRepository.listActiveHotels();
|
||||
if (activeHotels == null || activeHotels.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
for (PlatformHotelEntity hotel : activeHotels) {
|
||||
seedHotelCatalogIfEmpty(hotel == null ? null : hotel.getHotelId(), now);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前酒店目录为空时插入固定初始化目录,避免 Flyway 之后新建的默认酒店没有可用 lookup。
|
||||
*/
|
||||
private void seedHotelCatalogIfEmpty(String hotelId, LocalDateTime now) {
|
||||
String normalizedHotelId = trimToNull(hotelId);
|
||||
if (normalizedHotelId == null) {
|
||||
return;
|
||||
}
|
||||
if (!catalogRepository.hasAnyAccount(normalizedHotelId)) {
|
||||
for (AccountSeed seed : ACCOUNT_SEEDS) {
|
||||
catalogRepository.insertAccountIfAbsent(accountEntity(normalizedHotelId, seed, now));
|
||||
}
|
||||
}
|
||||
if (!catalogRepository.hasAnyCatalogCode(normalizedHotelId)) {
|
||||
for (CodeSeed seed : CODE_SEEDS) {
|
||||
catalogRepository.insertCatalogCodeIfAbsent(codeEntity(normalizedHotelId, seed, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Account 固定种子实体,主键交给 MyBatis-Plus 生成。
|
||||
*/
|
||||
private ReservationCatalogAccountEntity accountEntity(String hotelId, AccountSeed seed, LocalDateTime now) {
|
||||
ReservationCatalogAccountEntity entity = new ReservationCatalogAccountEntity();
|
||||
entity.setHotelId(hotelId);
|
||||
entity.setAccountCode(seed.accountCode());
|
||||
entity.setAccountName(seed.accountName());
|
||||
entity.setMarketCode(MARKET_CODE);
|
||||
entity.setSourceCode(SOURCE_CODE);
|
||||
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
|
||||
entity.setSourceSystem(ReservationV4CatalogSourceSystem.FIXED_SEED_IMPORT.name());
|
||||
entity.setCatalogVersion(SEED_CATALOG_VERSION);
|
||||
entity.setVersion(0L);
|
||||
entity.setCreatedAt(now);
|
||||
entity.setUpdatedAt(now);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造通用代码固定种子实体,主键交给 MyBatis-Plus 生成。
|
||||
*/
|
||||
private ReservationCatalogCodeEntity codeEntity(String hotelId, CodeSeed seed, LocalDateTime now) {
|
||||
ReservationCatalogCodeEntity entity = new ReservationCatalogCodeEntity();
|
||||
entity.setHotelId(hotelId);
|
||||
entity.setCatalogType(seed.catalogType());
|
||||
entity.setCode(seed.code());
|
||||
entity.setDisplayName(seed.displayName());
|
||||
entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name());
|
||||
entity.setSourceSystem(ReservationV4CatalogSourceSystem.FIXED_SEED_IMPORT.name());
|
||||
entity.setSortOrder(seed.sortOrder());
|
||||
entity.setCatalogVersion(SEED_CATALOG_VERSION);
|
||||
entity.setMetadataJson(seed.metadataJson());
|
||||
entity.setVersion(0L);
|
||||
entity.setCreatedAt(now);
|
||||
entity.setUpdatedAt(now);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除字符串首尾空白,空白值统一视为 null。
|
||||
*/
|
||||
private String trimToNull(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private record AccountSeed(String accountCode, String accountName) {
|
||||
}
|
||||
|
||||
private record CodeSeed(String catalogType, String code, String displayName, int sortOrder, String metadataJson) {
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
|
||||
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 int METADATA_PAGE_SIZE = 1;
|
||||
private static final String EMPTY_CATALOG_SOURCE = "DATABASE_EMPTY";
|
||||
private static final String EMPTY_CATALOG_VERSION = "unknown";
|
||||
|
||||
@@ -62,15 +63,20 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
|
||||
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
|
||||
.map(this::accountItem)
|
||||
.toList();
|
||||
List<ReservationV4CatalogAccountSnapshot> metadataItems = catalogRepository.queryActiveAccounts(
|
||||
hotelId,
|
||||
null,
|
||||
DEFAULT_PAGE_NUM,
|
||||
METADATA_PAGE_SIZE).items();
|
||||
return new ReservationV4CatalogLookupResult(
|
||||
hotelId,
|
||||
ReservationV4CatalogType.ACCOUNT.name(),
|
||||
sourceSystem(page.items()),
|
||||
catalogVersion(page.items()),
|
||||
sourceSystem(metadataItems),
|
||||
catalogVersion(metadataItems),
|
||||
false,
|
||||
items,
|
||||
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
|
||||
warnings(page.items()));
|
||||
warnings(metadataItems));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,15 +113,21 @@ public class ReservationV4CatalogLookupServiceImpl implements ReservationV4Catal
|
||||
List<ReservationV4CatalogLookupItemResult> items = page.items().stream()
|
||||
.map(snapshot -> codeItem(catalogType, snapshot))
|
||||
.toList();
|
||||
List<ReservationV4CatalogCodeSnapshot> metadataItems = catalogRepository.queryActiveCatalogCodes(
|
||||
hotelId,
|
||||
catalogType.name(),
|
||||
null,
|
||||
DEFAULT_PAGE_NUM,
|
||||
METADATA_PAGE_SIZE).items();
|
||||
return new ReservationV4CatalogLookupResult(
|
||||
hotelId,
|
||||
catalogType.name(),
|
||||
sourceSystem(page.items()),
|
||||
catalogVersion(page.items()),
|
||||
sourceSystem(metadataItems),
|
||||
catalogVersion(metadataItems),
|
||||
false,
|
||||
items,
|
||||
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()),
|
||||
warnings(page.items()));
|
||||
warnings(metadataItems));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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 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_bootstrap_runner_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=v4-catalog-fresh-admin",
|
||||
"auth.bootstrap.admin.password=Admin@123456",
|
||||
"auth.bootstrap.admin.display-name=V4目录新酒店管理员",
|
||||
"auth.bootstrap.default-hotel-id=HOTEL-FRESH",
|
||||
"auth.bootstrap.default-hotel-name=新酒店",
|
||||
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
|
||||
"auth.session.ttl-minutes=720"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ReservationV4CatalogBootstrapRunnerTest {
|
||||
|
||||
private static final String HOTEL_ID = "HOTEL-FRESH";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void shouldSeedCatalogForBootstrapDefaultHotelWhenFlywayDidNotSeeHotel() throws Exception {
|
||||
String token = loginToken(mockMvc, "v4-catalog-fresh-admin", "Admin@123456");
|
||||
|
||||
performAuthorized(mockMvc, token, get("/api/reservation/lookups/accounts")
|
||||
.param("hotel_id", HOTEL_ID)
|
||||
.param("keyword", "QBD"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.hotel_id").value(HOTEL_ID))
|
||||
.andExpect(jsonPath("$.catalog_source").value("FIXED_SEED_IMPORT"))
|
||||
.andExpect(jsonPath("$.catalog_version").value("seed-20260719-v1"))
|
||||
.andExpect(jsonPath("$.items[0].code").value("QBD_TRAVEL"));
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,20 @@ class ReservationV4CatalogLookupControllerTest {
|
||||
.andExpect(jsonPath("$.items[0].pricing_available").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepCatalogMetadataWhenKeywordMatchesNothing() throws Exception {
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts")
|
||||
.param("hotel_id", HOTEL_ID)
|
||||
.param("keyword", "NO_SUCH_ACCOUNT"))
|
||||
.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("$.catalog_version").value("seed-20260719-v1"))
|
||||
.andExpect(jsonPath("$.items").isEmpty())
|
||||
.andExpect(jsonPath("$.page.total").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCatalogLookupWithoutPermissionOrHotelAccess() throws Exception {
|
||||
performAuthorized(mockMvc, noPermissionToken(), get("/api/reservation/lookups/accounts")
|
||||
|
||||
Reference in New Issue
Block a user