diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index f8cc914..94930fa 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API 和前端 lookup 接入并行 | -| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端,并完成前端 V4 多卡字段 lookup 第一版接入;后续可做目录管理后台或真实 PMS / OPERA / OHIP 同步 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入和目录管理后台 CP1 后端 | +| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端、前端 V4 多卡字段 lookup 第一版接入,并完成目录管理后台 CP1 后端接口;后续可做目录管理前端页面或真实 PMS / OPERA / OHIP 同步 | ## 1. 当前 Checkpoint -- 名称:`M002-V4-CP12-frontend-lookup-and-multicard-integration` -- 状态:Done,前端已按 V4 任务卡 `fields[].options_source` 接入 Account / Room Type / Rate Code lookup API,替换前端硬编码目录选项;V4 多卡详情页会展示 lookup 空列表、stale、warnings、目录来源版本和 Account 派生 Market / Source,确认 / 复核仍只提交 code;订单详情页已消费 `v4_order_tasks[]` 展示 V4 订单任务时间线。 -- 目标:让 V4 任务详情字段控件不再依赖前端硬编码选项,后端入站、确认、复核统一按当前酒店数据库目录做校验。 -- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理后台,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不废弃旧 V2/V3 接口。 +- 名称:`M002-V4-CP13-catalog-admin-backend-cp1` +- 状态:Done,后端已开放 Account / Room Type / Rate Code 目录管理后台 CP1 接口,支持列表查询、新增、启用 / 停用;权限使用 `RESERVATION_CATALOG_MANAGE`,强制 Bearer 登录、权限码、酒店访问权和管理审计。 +- 目标:让测试机和后续系统设置页面可以通过后端维护第一版数据库目录,停用目录不再进入普通 V4 lookup。 +- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理前端页面,不做 Market / Source 独立管理页面,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不废弃旧 V2/V3 接口。 ## 2. 当前优先级 @@ -39,7 +39,7 @@ ## 5. Next Steps -- 后续如继续做 M002 V4,可优先进入 V4 多卡真实联调验收、目录管理后台、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。 +- 后续如继续做 M002 V4,可优先进入测试机 V4 冒烟、目录管理后台前端页面、真实 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 68e418c..a3ec07b 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -47,7 +47,8 @@ | `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 入站解析、持久化、查询、确认、复核和当前酒店数据库目录校验。 | | `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-v4-real-catalog-lookup-api-design.md` | 当前有效 | M002 V4 真实目录与 Lookup API 设计及 CP11 / CP13 CP1 实现记录,记录 Account、Market、Source、Room Type、Rate Code 从固定种子导入数据库、前端 lookup API、目录管理后端接口、权限、缓存后置、PMS / OPERA / OHIP 同步后置和失败兜底。 | +| `requirements/M002-v4-test-machine-smoke-checklist.md` | 当前有效 | M002 V4 测试机冒烟清单,覆盖登录、酒店权限、V4 工作台、订单任务详情、lookup、确认、复核解阻、S10/S99 ack、订单详情 V4 时间线和目录管理 CP1 排查点。 | | `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 模拟结果表。 | diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 1076db7..6db266e 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -61,6 +61,12 @@ | `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` 时调用。 | +| `GET /api/admin/reservation/catalogs/accounts` | 管理后台 Account 目录列表 | 必须带 Bearer token,需要 `RESERVATION_CATALOG_MANAGE` 和目标酒店访问权;支持 `hotel_id`、`keyword`、`status=ACTIVE/DISABLED`、`page_num`、`page_size`;返回 `items[] + page`,包含 `id`、`account_code`、`account_name`、`market_code`、`source_code`、`status`、`catalog_source`、`external_account_id`、`catalog_version`、`metadata_json`、`version`、`created_at`、`updated_at`。 | +| `POST /api/admin/reservation/catalogs/accounts` | 管理后台新增 Account 目录 | 必须带 `RESERVATION_CATALOG_MANAGE`;请求 `hotel_id`、`account_code`、`account_name`、`market_code`、`source_code`,可选 `external_account_id`、`catalog_version`、`metadata_json`;后端校验 Market / Source 当前酒店 ACTIVE,新增后默认 `ACTIVE`、`catalog_source=SYSTEM_MANAGED`,写管理审计。 | +| `PUT /api/admin/reservation/catalogs/accounts/{accountId}/status` | 管理后台启用 / 停用 Account | 必须带 `RESERVATION_CATALOG_MANAGE`;请求 `{"status":"ACTIVE"}` 或 `{"status":"DISABLED"}`;按记录所属酒店校验访问权;停用后普通 Account lookup 不再返回;如果提交的状态和当前状态一致,后端幂等返回当前记录,不新增管理审计。 | +| `GET /api/admin/reservation/catalogs/room-types` / `GET /api/admin/reservation/catalogs/rate-codes` | 管理后台 Room Type / Rate Code 目录列表 | 必须带 `RESERVATION_CATALOG_MANAGE`;支持 `hotel_id`、`keyword`、`status`、`page_num`、`page_size`;返回 `items[] + page`,包含 `id`、`catalog_type`、`code`、`display_name`、`status`、`catalog_source`、`external_id`、`sort_order`、`catalog_version`、`metadata_json`、`version`、`created_at`、`updated_at`。 | +| `POST /api/admin/reservation/catalogs/room-types` / `POST /api/admin/reservation/catalogs/rate-codes` | 管理后台新增 Room Type / Rate Code | 必须带 `RESERVATION_CATALOG_MANAGE`;请求 `hotel_id`、`code`、`display_name`,可选 `external_id`、`sort_order`、`catalog_version`、`metadata_json`;新增后默认 `ACTIVE`、`catalog_source=SYSTEM_MANAGED`,写管理审计。 | +| `PUT /api/admin/reservation/catalogs/room-types/{catalogId}/status` / `PUT /api/admin/reservation/catalogs/rate-codes/{catalogId}/status` | 管理后台启用 / 停用 Room Type / Rate Code | 必须带 `RESERVATION_CATALOG_MANAGE`;请求 `{"status":"ACTIVE"}` 或 `{"status":"DISABLED"}`;按记录所属酒店校验;停用后对应普通 lookup 不再返回;如果提交的状态和当前状态一致,后端幂等返回当前记录,不新增管理审计。 | | `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 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 | @@ -100,6 +106,7 @@ | `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[]` 非空时可做非阻塞提示。 | +| `GET/POST/PUT /api/admin/reservation/catalogs/...` | 新增 M002 V4 目录管理后台 CP1 后端接口。 | 仅供系统设置 / 管理后台页面使用,必须带 `RESERVATION_CATALOG_MANAGE`;支持 Account、Room Type、Rate Code 列表、新增、启用 / 停用;停用后普通 lookup 不再返回该目录项。 | V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100;`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。`order_task_status` 和 `card_status` 是稳定枚举查询参数,前端不要传中文文案或自造状态码。 @@ -495,13 +502,13 @@ 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 CP11 已把固定种子迁移到数据库目录,并开放 Account / Room Type / Rate Code lookup API。 +- 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;目录管理后台 CP1 已开放 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口。 - 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 CP11 已新增目录表、数据库种子和 lookup API。 +- 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;目录管理后台 CP1 已新增 `RESERVATION_CATALOG_MANAGE` 后端接口。 - V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false`、`editable=true`、`confirmable=false`、`reviewable=true`、`readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`。 - 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`。 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 index 3b787cc..67f09fd 100644 --- a/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md +++ b/docs/project/requirements/M002-v4-real-catalog-lookup-api-design.md @@ -4,11 +4,11 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | +| 文档版本 | 0.3 | | 日期 | 2026-07-19 | -| 状态 | CP11 已落地第一版数据库目录与 lookup API;后续真实 PMS 同步和目录管理后台继续后置 | +| 状态 | CP11 已落地第一版数据库目录与 lookup API;目录管理后台 CP1 已落地后端接口;真实 PMS 同步继续后置 | | 适用范围 | M002 V4 Account、Market、Source、Room Type、Rate Code 目录来源、数据模型、前端 lookup、缓存、酒店隔离、权限和失败兜底 | -| 不适用范围 | 真实 OPERA / OHIP 写操作、真实价格计算、前端页面实现、SuperAgent Prompt 修改、目录管理后台、SuperAgent 机器目录接口 | +| 不适用范围 | 真实 OPERA / OHIP 写操作、真实价格计算、前端页面实现、SuperAgent Prompt 修改、目录同步任务、SuperAgent 机器目录接口 | ## 1. 文档定位 @@ -19,7 +19,7 @@ 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` 平台酒店的固定种子目录;同时新增 `ReservationV4CatalogBootstrapRunner`,在平台默认酒店由启动流程创建后,如果该酒店目录为空,会补一份 `FIXED_SEED_IMPORT` 初始化目录。不再在运行时代码中把固定种子作为全局目录事实。`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` 初始化目录。不再在运行时代码中把固定种子作为全局目录事实。目录管理后台 CP1 已新增 Account、Room Type、Rate Code 后端查询、新增、启用 / 停用接口;`workflow_reservation_catalog_sync_run`、真实 PMS / OPERA / OHIP 同步、Market / Source 独立管理页面和 SuperAgent 机器目录供给接口继续后置。 后续若本文与 `M002-v4-agent-callback-field-contract.md` 的 Agent 输入字段冲突,以 Agent 字段契约为准;若与 `security-access-control-boundary.md` 的接口权限冲突,以安全边界为准。 @@ -29,11 +29,11 @@ CP11 之前固定种子实现位于后端 `ReservationV4DirectoryService` 和 `F | 目录 | 当前用途 | 当前固定值 | 当前限制 | | --- | --- | --- | --- | -| Account | Basic Information 可选目录;SuperAgent 和用户提交都使用稳定 code | `QBD_TRAVEL`、`LIAN_TAI`、`HANATOUR_TD` | 已进入数据库初始化目录,仍不是 PMS 全量 Account,管理后台后置 | +| Account | Basic Information 可选目录;SuperAgent 和用户提交都使用稳定 code | `QBD_TRAVEL`、`LIAN_TAI`、`HANATOUR_TD` | 已进入数据库初始化目录,仍不是 PMS 全量 Account;后台 CP1 可新增、启用 / 停用 | | 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` | 已进入数据库初始化目录,不按日期、账号、房型过滤,不含价格 | +| Room Type | 房型 code 校验和字段选项提示 | `TWN`、`KING`、`DBL`、`SGL`、`TRP`、`RM1`、`RM2`、`RM3` | 已进入数据库初始化目录,仍不是 PMS 全量房型;后台 CP1 可新增、启用 / 停用 | +| Rate Code | Rate Code 校验和字段选项提示 | `BAR`、`RACK`、`PACKAGE`、`GROUP`、`FIT` | 已进入数据库初始化目录,不按日期、账号、房型过滤,不含价格;后台 CP1 可新增、启用 / 停用 | 当前固定种子只能支撑开发和演示闭环,不能作为生产长期事实源。 @@ -139,7 +139,7 @@ uk_reservation_catalog_code(hotel_id, catalog_type, code) 如果接 PMS / OPERA / OHIP 同步,建议记录每次同步运行。 -CP11 本轮没有创建该表。原因是当前不接真实 PMS / OPERA / OHIP 同步,暂时没有同步运行事实可记录;后续做同步 worker 或目录管理后台时,再新增该表和对应 Repository。 +CP11 本轮没有创建该表。原因是当前不接真实 PMS / OPERA / OHIP 同步,暂时没有同步运行事实可记录;CP13 目录管理后台 CP1 也只维护系统管理目录项,不产生外部同步运行事实。后续做 PMS / OPERA / OHIP 同步 worker 时,再新增该表和对应 Repository。 | 字段 | 中文说明 | | --- | --- | @@ -432,13 +432,34 @@ Market / Source 第一版不作为用户可编辑字段,不建议给普通业 ### 12.2 目录维护权限 -目录维护不在本 checkpoint 实现。后续如果做系统管理维护,建议新增: +目录管理后台 CP1 已实现后端接口。该能力属于 `FRONTEND_ADMIN`,使用管理后台错误响应和管理审计,不复用普通 lookup 权限。 ```text RESERVATION_CATALOG_MANAGE ``` -用于 Account、Market、Source、临时 Room Type、Rate Code 的系统管理维护页面。写操作必须记录管理审计或业务配置审计。 +已实现接口: + +| 接口 | 中文说明 | 权限 | 酒店隔离 | 审计 | +| --- | --- | --- | --- | --- | +| `GET /api/admin/reservation/catalogs/accounts` | Account 管理列表 | `RESERVATION_CATALOG_MANAGE` | `hotel_id` 必须在当前用户可访问酒店内;不传时使用默认酒店 | 只读不写审计 | +| `POST /api/admin/reservation/catalogs/accounts` | 新增 Account | `RESERVATION_CATALOG_MANAGE` | 按请求 `hotel_id` 校验 | 写平台管理审计 | +| `PUT /api/admin/reservation/catalogs/accounts/{accountId}/status` | 启用 / 停用 Account | `RESERVATION_CATALOG_MANAGE` | 按记录所属酒店校验 | 状态实际变化时写平台管理审计;相同状态幂等返回,不新增审计 | +| `GET /api/admin/reservation/catalogs/room-types` | Room Type 管理列表 | `RESERVATION_CATALOG_MANAGE` | 同上 | 只读不写审计 | +| `POST /api/admin/reservation/catalogs/room-types` | 新增 Room Type | `RESERVATION_CATALOG_MANAGE` | 按请求 `hotel_id` 校验 | 写平台管理审计 | +| `PUT /api/admin/reservation/catalogs/room-types/{catalogId}/status` | 启用 / 停用 Room Type | `RESERVATION_CATALOG_MANAGE` | 按记录所属酒店校验 | 状态实际变化时写平台管理审计;相同状态幂等返回,不新增审计 | +| `GET /api/admin/reservation/catalogs/rate-codes` | Rate Code 管理列表 | `RESERVATION_CATALOG_MANAGE` | 同上 | 只读不写审计 | +| `POST /api/admin/reservation/catalogs/rate-codes` | 新增 Rate Code | `RESERVATION_CATALOG_MANAGE` | 按请求 `hotel_id` 校验 | 写平台管理审计 | +| `PUT /api/admin/reservation/catalogs/rate-codes/{catalogId}/status` | 启用 / 停用 Rate Code | `RESERVATION_CATALOG_MANAGE` | 按记录所属酒店校验 | 状态实际变化时写平台管理审计;相同状态幂等返回,不新增审计 | + +CP1 限制: + +- 新增目录默认 `ACTIVE`,`source_system=SYSTEM_MANAGED`。 +- 管理列表可按 `status=ACTIVE/DISABLED` 过滤;普通 lookup 仍只返回 `ACTIVE`。 +- Account 新增时 `market_code`、`source_code` 必须是当前酒店 `ACTIVE` 的 Market / Source code。 +- Market / Source 独立管理页面暂不做,仍沿用当前初始化目录。 +- 停用目录不会回滚已确认历史卡片,但新确认 / 复核会按当前 ACTIVE 目录校验。 +- 后续如做前端管理页面,应在系统设置下使用该权限码控制入口。 ### 12.3 第三方接口边界 @@ -476,20 +497,20 @@ SuperAgent 当前不调用本 lookup API。SuperAgent 目录供给后续有两 | --- | --- | --- | | 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-CP13 | 目录管理后台 V1 | CP1 已完成 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口、`RESERVATION_CATALOG_MANAGE` 权限和管理审计;前端页面、Market / Source 独立管理后置 | | M002-V4-CP14 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run 表、失败重试、最后成功快照、同步状态管理入口 | | M002-V4-CP15 | SuperAgent 目录供给 | 明确目录版本如何给 SuperAgent,必要时新增机器目录接口或导出包 | -CP11 已作为后端第一步落地,因为它不依赖真实 PMS,也能让前端后续不再硬编码当前固定种子。 +CP11 已作为后端第一步落地,因为它不依赖真实 PMS,也能让前端后续不再硬编码当前固定种子。CP13 CP1 继续沿用本地目录表,不接真实 PMS,也不改变 SuperAgent 输入契约。 ## 15. 仍需确认的问题 -1. Account 的第一版真实维护入口是否放在现有系统管理后台,还是先用导入 SQL / Excel 导入。 +1. Account 的第一版真实维护入口后端已放在系统管理后台接口;前端菜单和页面布局仍需后续确认。 2. Account code 是否继续使用本系统定义的稳定 code,例如 `QBD_TRAVEL`,还是必须对齐 PMS profile code。 3. Market / Source 是否只允许随 Account 派生,还是未来允许用户在 Basic Information 中单独改选。 -4. Room Type 第一版真实目录是否先由系统管理维护,还是等 PMS / OHIP 同步后再替换。 +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 输入契约。 +在这些问题未确认前,当前实现仍按 DB 管理目录 + 前端 lookup 查询 + 后台 CP1 手工维护闭环推进,不接真实 PMS,也不替换 SuperAgent 输入契约。 diff --git a/docs/project/requirements/M002-v4-test-machine-smoke-checklist.md b/docs/project/requirements/M002-v4-test-machine-smoke-checklist.md new file mode 100644 index 0000000..92c4b60 --- /dev/null +++ b/docs/project/requirements/M002-v4-test-machine-smoke-checklist.md @@ -0,0 +1,270 @@ +# M002 V4 测试机冒烟清单 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 0.1 | +| 日期 | 2026-07-19 | +| 状态 | 测试机 V4 前后端联调冒烟清单 | +| 适用范围 | 登录、酒店权限、V4 工作台、V4 订单任务详情、目录 lookup、卡片确认、复核解阻、S10/S99 ack、订单详情 V4 时间线 | +| 不适用范围 | 真实 OPERA / OHIP、目录同步、旧 V2/V3 全量回归、前端视觉验收 | + +## 1. 测试前置 + +1. 测试机后端使用最新包,Flyway 至少执行到 V24。 +2. `platform_hotel` 单酒店阶段只能有一家 `ACTIVE` 酒店,测试常用 `HOTEL-TEST`。 +3. 测试账号可以登录,并拥有目标酒店访问权。 +4. 普通 V4 业务测试账号至少需要: + - `RESERVATION_TASK_READ` + - `RESERVATION_ORDER_READ` + - `RESERVATION_TASK_CONFIRM` + - `RESERVATION_MANUAL_REVIEW_RESOLVE` +5. 目录管理测试账号需要: + - `RESERVATION_CATALOG_MANAGE` + - 目标酒店访问权 +6. 当前酒店已存在 Account、Room Type、Rate Code 目录。若为空,先检查 `workflow_reservation_catalog_account` 和 `workflow_reservation_catalog_code`。 +7. 准备三类 V4 测试数据: + - 普通业务包:至少包含 Basic Information 和一张业务卡。 + - `REVIEW_REQUIRED` 业务包:例如未知 Account、未知 Room Type 或缺失字段。 + - S10/S99 来源通知:不创建订单任务,只进入来源通知模型。 + +## 2. 冒烟步骤 + +### 2.1 登录与酒店权限 + +步骤: +1. 打开前端登录页。 +2. 使用测试账号登录。 +3. 选择或确认当前酒店为 `HOTEL-TEST`。 + +期望: +- 登录成功后前端能拿到 Bearer token。 +- 当前酒店在用户可访问酒店列表中。 +- 使用无酒店权限账号访问 V4 接口时返回 `HOTEL_ACCESS_DENIED`。 + +常见失败: +- `AUTH_TOKEN_REQUIRED`:前端未带 Bearer token。 +- `AUTH_SESSION_INVALID`:token 过期或后端重启导致会话失效。 +- `HOTEL_ACCESS_DENIED`:用户没有目标酒店访问权,或请求 `hotel_id` 与当前账号不匹配。 + +### 2.2 V4 工作台 + +接口: + +```text +GET /api/reservation/workbench-items?hotel_id=HOTEL-TEST&page_num=1&page_size=20 +``` + +期望: +- 普通 V4 业务订单任务可见。 +- S10/S99 来源通知可见。 +- 列表不返回邮件正文、附件 URL、AI 原始 payload。 +- 默认排序为最新来源消息在前;同一来源时间下按更新时间、创建时间和数字 ID 稳定倒序。 + +常见失败: +- 列表为空:先确认 SuperAgent 回调是否写入 V4 表,或是否只有旧 V2/V3 数据。 +- S10/S99 不可见:检查 `workflow_reservation_v4_source_notification` 是否有记录。 + +### 2.3 V4 订单任务详情 + +接口: + +```text +GET /api/reservation/order-tasks/{orderTaskId} +``` + +期望: +- 返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`availability`。 +- Basic Information 卡在未确认前应可确认。 +- 业务卡在 Basic Information 未确认前应只读,`readonly_reason_code` 能说明原因。 +- `fields[]` 是前端展示和编辑白名单,前端不自行补字段。 + +常见失败: +- 详情 404:检查 `orderTaskId` 是否来自 V4 工作台,不要拿旧任务 ID 调 V4 详情。 +- 卡片无法确认:先看 `availability` 和 `readonly_reason_code`。 + +### 2.4 Account / Room Type / Rate Code Lookup + +接口: + +```text +GET /api/reservation/lookups/accounts?hotel_id=HOTEL-TEST&keyword=QBD +GET /api/reservation/lookups/room-types?hotel_id=HOTEL-TEST&keyword=RM2 +GET /api/reservation/lookups/rate-codes?hotel_id=HOTEL-TEST&keyword=GROUP +``` + +期望: +- 三个接口均要求 Bearer token 和 `RESERVATION_TASK_READ`。 +- 只返回当前酒店 `ACTIVE` 目录项。 +- `keyword` 查不到时 `items=[]`,但不代表目录未初始化;应结合 `catalog_source`、`catalog_version`、`warnings[]` 判断。 +- 前端确认 / 复核时只提交 `code`,不要提交显示名或目录完整对象。 + +常见失败: +- 查不到新增目录:确认目录状态是否为 `ACTIVE`。 +- 前端提交未知 code 后 400:以后端目录校验为准,重新选择 lookup 返回项。 + +### 2.5 Basic Information 确认 + +接口: + +```text +POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm +``` + +期望: +- 请求带当前卡片 `version`。 +- Basic Information 确认成功后卡片状态进入 `CONFIRMED`。 +- 确认后卡片锁定,不允许重复确认。 +- 业务卡的可处理性随详情刷新更新。 + +常见失败: +- `V4_CARD_BASIC_INFORMATION_REQUIRED`:前端先确认了业务卡,应先确认 Basic Information。 +- `V4_FIELD_VALIDATION_FAILED`:Account 不存在、Room Type / Rate Code 不在当前酒店 ACTIVE 目录。 +- 版本冲突:刷新详情后使用最新 `version`。 + +### 2.6 业务卡确认 + +接口: + +```text +POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm +``` + +期望: +- Basic Information 已确认后,普通业务卡可确认。 +- 第一版不强制所有业务卡逐张顺序确认。 +- 成功后卡片 `CONFIRMED`,写入 `confirmed_payload_json`、`confirmed_at`、`confirmed_by`。 + +常见失败: +- 目录字段错误:看接口 `details[]` 和卡片 `fields[].validation_errors`,按字段 pointer 修正。 +- 只读卡无法确认:检查是否是来源邮件展示卡、技术错误卡或 S10/S99 通知。 + +### 2.7 REVIEW_REQUIRED 复核解阻 + +接口: + +```text +POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution +``` + +期望: +- 只用于 `card_status=REVIEW_REQUIRED` 的 V4 卡。 +- 请求带当前卡片 `version`。 +- `field_overrides[].field_pointer` 必须来自当前卡可编辑字段。 +- 订单归属未解决时,`confirmed_order_id` 必须是当前酒店真实可见订单。 +- 成功后卡片进入 `CONFIRMED`,`review_status=RESOLVED`。 + +常见失败: +- `TASK_REVIEW_POINTER_INVALID` 或 V4 对应非法 pointer 错误:前端提交了只读字段、对象根节点或不存在字段。 +- `HOTEL_ACCESS_DENIED`:确认的订单或任务不属于当前用户酒店。 +- 复核后仍失败:检查修正后的 Account / Room Type / Rate Code 是否在 ACTIVE 目录中。 + +### 2.8 S10/S99 来源通知 Ack + +接口: + +```text +POST /api/reservation/source-notifications/{notificationId}/ack +``` + +期望: +- 只用于 V4 `route_code=S10/S99` 来源通知。 +- 请求带当前通知 `version`。 +- 成功后 `notification_status=ACKED`,写 `ack_by`、`ack_at`。 +- 重复 ack 幂等返回当前状态,不新增业务任务或订单。 + +常见失败: +- 通知详情 404:确认从 V4 工作台拿的是来源通知 ID,不是订单任务 ID。 +- ack 按钮不显示:检查详情 `availability.ackable`。 + +### 2.9 订单详情 V4 时间线 + +接口: + +```text +GET /api/reservation/orders/{orderId} +``` + +期望: +- 返回旧 `tasks[]` 兼容字段。 +- 返回 V4 `v4_order_tasks[]` 时间线。 +- 每项包含 `order_task_id`、`order_ref`、`order_task_status`、`card_counts`、`source_message_summary`、`source_received_at`、`created_at`、`updated_at`、`latest_activity_at`。 +- S10/S99 来源通知不进入订单列表,也不进入订单详情 V4 时间线。 + +常见失败: +- 时间线为空:确认该 V4 订单任务是否已经绑定到当前订单。 +- 订单详情打不开:检查订单所属酒店和当前用户酒店访问权。 + +## 3. 目录管理后台 CP1 冒烟 + +接口: + +```text +GET /api/admin/reservation/catalogs/accounts +POST /api/admin/reservation/catalogs/accounts +PUT /api/admin/reservation/catalogs/accounts/{accountId}/status + +GET /api/admin/reservation/catalogs/room-types +POST /api/admin/reservation/catalogs/room-types +PUT /api/admin/reservation/catalogs/room-types/{catalogId}/status + +GET /api/admin/reservation/catalogs/rate-codes +POST /api/admin/reservation/catalogs/rate-codes +PUT /api/admin/reservation/catalogs/rate-codes/{catalogId}/status +``` + +期望: +- 全部接口要求 Bearer token、`RESERVATION_CATALOG_MANAGE` 和目标酒店访问权。 +- 列表支持 `hotel_id`、`keyword`、`status`、`page_num`、`page_size`。 +- 新增目录默认 `ACTIVE`,`catalog_source=SYSTEM_MANAGED`。 +- 停用后管理列表仍可按 `status=DISABLED` 查到,但普通 lookup 不再返回。 +- 启用后普通 lookup 恢复返回。 + +常见失败: +- `ADMIN_PERMISSION_DENIED`:角色未配置 `RESERVATION_CATALOG_MANAGE`。 +- `RESERVATION_CATALOG_CONFLICT`:当前酒店已有相同 code。 +- `RESERVATION_CATALOG_INVALID_REQUEST`:状态、JSON、必填字段或 Market / Source code 不合法。 + +## 4. 排查 SQL + +```sql +SELECT hotel_id, account_code, account_name, market_code, source_code, status, source_system, catalog_version, updated_at +FROM workflow_reservation_catalog_account +WHERE hotel_id = 'HOTEL-TEST' +ORDER BY account_code; + +SELECT hotel_id, catalog_type, code, display_name, status, source_system, catalog_version, updated_at +FROM workflow_reservation_catalog_code +WHERE hotel_id = 'HOTEL-TEST' +ORDER BY catalog_type, sort_order, code; + +SELECT id, hotel_id, order_ref, order_task_status, source_message_id, created_at, updated_at +FROM workflow_reservation_v4_order_task +WHERE hotel_id = 'HOTEL-TEST' +ORDER BY updated_at DESC, id DESC; + +SELECT id, hotel_id, route_code, notification_status, source_message_id, created_at, updated_at +FROM workflow_reservation_v4_source_notification +WHERE hotel_id = 'HOTEL-TEST' +ORDER BY updated_at DESC, id DESC; +``` + +## 5. 冒烟结论记录 + +建议每次测试机发版后记录: + +| 项目 | 结果 | 备注 | +| --- | --- | --- | +| 登录与酒店权限 | 待测 | | +| V4 工作台 | 待测 | | +| V4 订单任务详情 | 待测 | | +| Account Lookup | 待测 | | +| Room Type Lookup | 待测 | | +| Rate Code Lookup | 待测 | | +| Basic Information 确认 | 待测 | | +| 业务卡确认 | 待测 | | +| REVIEW_REQUIRED 复核解阻 | 待测 | | +| S10/S99 ack | 待测 | | +| 订单详情 V4 时间线 | 待测 | | +| 目录管理 CP1 | 待测 | | diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index c7ef250..43bfb33 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -85,6 +85,7 @@ | `/api/admin/permissions` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ROLE_MANAGE` | 保持只读;前端不能自造权限码 | 不需要写审计 | | `/api/admin/menus/**` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_MENU_MANAGE`;`GET /tree`、`PUT /tree-order` 已沿用该权限 | 保持;菜单可见性不替代后端权限;批量树排序只允许修改 `parent_id` 和 `sort_order`,`sort_order` 为空时按请求顺序生成稳定排序 | 写操作必须记录管理审计,树排序审计记录调整前后的父级和排序;树查询不写审计 | | `/api/admin/hotels/**` | `FRONTEND_ADMIN` | 已强制登录和 `HOTEL_MANAGE` | 保持;单酒店阶段只能一家 `ACTIVE` | 写操作必须记录管理审计 | +| `/api/admin/reservation/catalogs/**` | `FRONTEND_ADMIN` | 已实现目录管理后台 CP1;强制登录、`RESERVATION_CATALOG_MANAGE` 和目标酒店访问权 | 保持;第一版只开放 Account、Room Type、Rate Code 列表、新增、启用 / 停用;Market / Source 独立管理和真实 PMS 同步后置;停用目录不再进入普通 lookup | 新增和状态实际变化必须记录管理审计;重复提交相同状态按幂等返回,不新增审计;列表查询不写审计;不得返回 PMS 原始响应、Secret 或外部同步错误详情 | | `GET /api/admin/audits` | `FRONTEND_ADMIN` | 已强制登录和 `SYSTEM_ADMIN_CONSOLE_ACCESS` | 保持;不返回 Secret、密码或 token | 查询审计不再写审计 | ### 3.5 调试和系统接口 @@ -136,7 +137,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 目录管理后台;当前尚未实现 | +| `RESERVATION_CATALOG_MANAGE` | 维护 Reservation 受控目录 | 已用于 `/api/admin/reservation/catalogs/**`;第一版支持 Account、Room Type、Rate Code 管理,Market / Source 独立管理后置 | | `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/platform/access/common/enums/PlatformPermissionCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java index 46f2229..c4fd4c9 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java @@ -15,6 +15,7 @@ public enum PlatformPermissionCode { RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE, RESERVATION_ROOMING_LIST_GENERATE, + RESERVATION_CATALOG_MANAGE, HOTEL_SWITCH, SYSTEM_AUTH_READ, SYSTEM_USER_MANAGE, diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java index 2ca3050..a53fb83 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java @@ -324,6 +324,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { case RESERVATION_AUDIT_READ -> "读取任务审计"; case RESERVATION_INVOICE_GENERATE -> "生成预订发票"; case RESERVATION_ROOMING_LIST_GENERATE -> "生成 Rooming List"; + case RESERVATION_CATALOG_MANAGE -> "管理预订目录"; case HOTEL_SWITCH -> "切换酒店"; case SYSTEM_AUTH_READ -> "读取当前登录上下文"; case SYSTEM_USER_MANAGE -> "管理用户"; @@ -345,7 +346,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner { RESERVATION_TASK_CONFIRM, RESERVATION_MANUAL_REVIEW_RESOLVE, RESERVATION_OPERA_SIM_EXECUTE, RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE, - RESERVATION_ROOMING_LIST_GENERATE -> "RESERVATION"; + RESERVATION_ROOMING_LIST_GENERATE, RESERVATION_CATALOG_MANAGE -> "RESERVATION"; case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL"; default -> "SYSTEM"; }; 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 index 5f29310..388e5a9 100644 --- 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 @@ -13,9 +13,13 @@ import java.time.LocalDateTime; * @param sourceCode 关联 Source 代码 * @param status 目录状态 * @param sourceSystem 目录来源 + * @param externalAccountId 外部系统 Account ID * @param catalogVersion 目录版本 * @param lastSyncedAt 最近同步 UTC 时间 * @param metadataJson 扩展属性 JSON + * @param version 乐观锁版本 + * @param createdAt 创建 UTC 时间 + * @param updatedAt 更新 UTC 时间 */ public record ReservationV4CatalogAccountSnapshot( Long id, @@ -26,8 +30,12 @@ public record ReservationV4CatalogAccountSnapshot( String sourceCode, String status, String sourceSystem, + String externalAccountId, String catalogVersion, LocalDateTime lastSyncedAt, - String metadataJson + String metadataJson, + Long version, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { } 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 index 9ad94f5..c3d6a67 100644 --- 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 @@ -12,10 +12,14 @@ import java.time.LocalDateTime; * @param displayName 显示名称 * @param status 目录状态 * @param sourceSystem 目录来源 + * @param externalId 外部系统目录 ID * @param sortOrder 展示排序 * @param catalogVersion 目录版本 * @param lastSyncedAt 最近同步 UTC 时间 * @param metadataJson 扩展属性 JSON + * @param version 乐观锁版本 + * @param createdAt 创建 UTC 时间 + * @param updatedAt 更新 UTC 时间 */ public record ReservationV4CatalogCodeSnapshot( Long id, @@ -25,9 +29,13 @@ public record ReservationV4CatalogCodeSnapshot( String displayName, String status, String sourceSystem, + String externalId, Integer sortOrder, String catalogVersion, LocalDateTime lastSyncedAt, - String metadataJson + String metadataJson, + Long version, + LocalDateTime createdAt, + LocalDateTime updatedAt ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminCreateAccountRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminCreateAccountRequest.java new file mode 100644 index 0000000..f95d809 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminCreateAccountRequest.java @@ -0,0 +1,35 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 Account 目录新增请求。只允许后台管理入口创建系统维护目录。 + * + * @param hotelId 酒店 ID + * @param accountCode Account 稳定代码 + * @param accountName Account 显示名称 + * @param marketCode 关联 Market 代码 + * @param sourceCode 关联 Source 代码 + * @param externalAccountId 外部系统 Account ID,未接 PMS 时可为空 + * @param catalogVersion 目录版本,为空时后端生成系统维护版本 + * @param metadataJson 扩展属性 JSON,第一版只保存不参与业务计算 + */ +public record ReservationV4CatalogAdminCreateAccountRequest( + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("account_code") + String accountCode, + @JsonProperty("account_name") + String accountName, + @JsonProperty("market_code") + String marketCode, + @JsonProperty("source_code") + String sourceCode, + @JsonProperty("external_account_id") + String externalAccountId, + @JsonProperty("catalog_version") + String catalogVersion, + @JsonProperty("metadata_json") + String metadataJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminCreateCodeRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminCreateCodeRequest.java new file mode 100644 index 0000000..f80e2a9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminCreateCodeRequest.java @@ -0,0 +1,31 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Reservation V4 通用代码目录新增请求。Controller 固定 Room Type 或 Rate Code 类型。 + * + * @param hotelId 酒店 ID + * @param code 目录稳定代码 + * @param displayName 目录显示名称 + * @param externalId 外部系统目录 ID,未接 PMS 时可为空 + * @param sortOrder 展示排序 + * @param catalogVersion 目录版本,为空时后端生成系统维护版本 + * @param metadataJson 扩展属性 JSON,例如房型人数或 Rate Code 价格能力 + */ +public record ReservationV4CatalogAdminCreateCodeRequest( + @JsonProperty("hotel_id") + String hotelId, + String code, + @JsonProperty("display_name") + String displayName, + @JsonProperty("external_id") + String externalId, + @JsonProperty("sort_order") + Integer sortOrder, + @JsonProperty("catalog_version") + String catalogVersion, + @JsonProperty("metadata_json") + String metadataJson +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminStatusUpdateRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminStatusUpdateRequest.java new file mode 100644 index 0000000..12db5c5 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationV4CatalogAdminStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.common.request; + +/** + * Reservation V4 目录状态更新请求。 + * + * @param status 目标目录状态:ACTIVE 或 DISABLED + */ +public record ReservationV4CatalogAdminStatusUpdateRequest( + String status +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogAdminAccountResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogAdminAccountResult.java new file mode 100644 index 0000000..e44b53f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogAdminAccountResult.java @@ -0,0 +1,51 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * Reservation V4 Account 目录管理响应。 + * + * @param id 目录记录 ID 字符串 + * @param hotelId 酒店 ID + * @param accountCode Account 稳定代码 + * @param accountName Account 显示名称 + * @param marketCode 关联 Market 代码 + * @param sourceCode 关联 Source 代码 + * @param status 目录状态 + * @param catalogSource 目录来源 + * @param externalAccountId 外部系统 Account ID + * @param catalogVersion 目录版本 + * @param metadataJson 扩展属性 JSON + * @param version 乐观锁版本 + * @param createdAt 创建 UTC 时间 + * @param updatedAt 更新 UTC 时间 + */ +public record ReservationV4CatalogAdminAccountResult( + String id, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("account_code") + String accountCode, + @JsonProperty("account_name") + String accountName, + @JsonProperty("market_code") + String marketCode, + @JsonProperty("source_code") + String sourceCode, + String status, + @JsonProperty("catalog_source") + String catalogSource, + @JsonProperty("external_account_id") + String externalAccountId, + @JsonProperty("catalog_version") + String catalogVersion, + @JsonProperty("metadata_json") + String metadataJson, + Long version, + @JsonProperty("created_at") + OffsetDateTime createdAt, + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogAdminCodeResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogAdminCodeResult.java new file mode 100644 index 0000000..7358582 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationV4CatalogAdminCodeResult.java @@ -0,0 +1,50 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.OffsetDateTime; + +/** + * Reservation V4 Room Type / Rate Code 目录管理响应。 + * + * @param id 目录记录 ID 字符串 + * @param hotelId 酒店 ID + * @param catalogType 目录类型 + * @param code 稳定代码 + * @param displayName 显示名称 + * @param status 目录状态 + * @param catalogSource 目录来源 + * @param externalId 外部系统目录 ID + * @param sortOrder 展示排序 + * @param catalogVersion 目录版本 + * @param metadataJson 扩展属性 JSON + * @param version 乐观锁版本 + * @param createdAt 创建 UTC 时间 + * @param updatedAt 更新 UTC 时间 + */ +public record ReservationV4CatalogAdminCodeResult( + String id, + @JsonProperty("hotel_id") + String hotelId, + @JsonProperty("catalog_type") + String catalogType, + String code, + @JsonProperty("display_name") + String displayName, + String status, + @JsonProperty("catalog_source") + String catalogSource, + @JsonProperty("external_id") + String externalId, + @JsonProperty("sort_order") + Integer sortOrder, + @JsonProperty("catalog_version") + String catalogVersion, + @JsonProperty("metadata_json") + String metadataJson, + Long version, + @JsonProperty("created_at") + OffsetDateTime createdAt, + @JsonProperty("updated_at") + OffsetDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminController.java new file mode 100644 index 0000000..baaf22b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminController.java @@ -0,0 +1,171 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.AdminAuthorizationService; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateAccountRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateCodeRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminStatusUpdateRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminAccountResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminCodeResult; +import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogAdminService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reservation V4 目录管理 Controller。提供系统后台维护 Account、Room Type、Rate Code 的第一版接口。 + */ +@RestController +@RequestMapping("/api/admin/reservation/catalogs") +public class ReservationV4CatalogAdminController { + + private final AdminAuthorizationService authorizationService; + private final ReservationV4CatalogAdminService catalogAdminService; + + /** + * 注入管理鉴权和目录管理服务,Controller 只负责 HTTP 参数边界和权限入口。 + */ + public ReservationV4CatalogAdminController( + AdminAuthorizationService authorizationService, + ReservationV4CatalogAdminService catalogAdminService) { + this.authorizationService = authorizationService; + this.catalogAdminService = catalogAdminService; + } + + /** + * 分页查询 Account 目录管理列表,需要目录管理权限。 + */ + @GetMapping(value = "/accounts", produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listAccounts( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.listAccounts(hotelId, keyword, status, pageNum, pageSize); + } + + /** + * 新增 Account 目录项,需要目录管理权限并写入管理审计。 + */ + @PostMapping( + value = "/accounts", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogAdminAccountResult createAccount( + @RequestBody(required = false) ReservationV4CatalogAdminCreateAccountRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.createAccount(request, actor); + } + + /** + * 启用或停用 Account 目录项;状态实际变化时写入管理审计。 + */ + @PutMapping( + value = "/accounts/{accountId}/status", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogAdminAccountResult updateAccountStatus( + @PathVariable String accountId, + @RequestBody(required = false) ReservationV4CatalogAdminStatusUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.updateAccountStatus(accountId, request, actor); + } + + /** + * 分页查询 Room Type 目录管理列表,需要目录管理权限。 + */ + @GetMapping(value = "/room-types", produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listRoomTypes( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.listRoomTypes(hotelId, keyword, status, pageNum, pageSize); + } + + /** + * 新增 Room Type 目录项,需要目录管理权限并写入管理审计。 + */ + @PostMapping( + value = "/room-types", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogAdminCodeResult createRoomType( + @RequestBody(required = false) ReservationV4CatalogAdminCreateCodeRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.createRoomType(request, actor); + } + + /** + * 启用或停用 Room Type 目录项;状态实际变化时写入管理审计。 + */ + @PutMapping( + value = "/room-types/{catalogId}/status", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogAdminCodeResult updateRoomTypeStatus( + @PathVariable String catalogId, + @RequestBody(required = false) ReservationV4CatalogAdminStatusUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.updateRoomTypeStatus(catalogId, request, actor); + } + + /** + * 分页查询 Rate Code 目录管理列表,需要目录管理权限。 + */ + @GetMapping(value = "/rate-codes", produces = MediaType.APPLICATION_JSON_VALUE) + public PlatformPageResult listRateCodes( + @RequestParam(name = "hotel_id", required = false) String hotelId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(name = "page_num", required = false) Integer pageNum, + @RequestParam(name = "page_size", required = false) Integer pageSize) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.listRateCodes(hotelId, keyword, status, pageNum, pageSize); + } + + /** + * 新增 Rate Code 目录项,需要目录管理权限并写入管理审计。 + */ + @PostMapping( + value = "/rate-codes", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogAdminCodeResult createRateCode( + @RequestBody(required = false) ReservationV4CatalogAdminCreateCodeRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.createRateCode(request, actor); + } + + /** + * 启用或停用 Rate Code 目录项;状态实际变化时写入管理审计。 + */ + @PutMapping( + value = "/rate-codes/{catalogId}/status", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ReservationV4CatalogAdminCodeResult updateRateCodeStatus( + @PathVariable String catalogId, + @RequestBody(required = false) ReservationV4CatalogAdminStatusUpdateRequest request) { + AuthenticatedUserContext actor = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_CATALOG_MANAGE.name()); + return catalogAdminService.updateRateCodeStatus(catalogId, request, actor); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminControllerAdvice.java new file mode 100644 index 0000000..1acd712 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminControllerAdvice.java @@ -0,0 +1,33 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.platform.common.exception.AdminOperationException; +import cn.nianxx.thhotel.platform.common.result.PlatformErrorResponse; +import cn.nianxx.thhotel.platform.security.service.impl.AdminAuthorizationException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Reservation V4 目录管理后台异常处理。保持平台错误响应结构,同时避免 platform 模块反向依赖 workflow。 + */ +@RestControllerAdvice(assignableTypes = ReservationV4CatalogAdminController.class) +public class ReservationV4CatalogAdminControllerAdvice { + + /** + * 处理目录管理后台登录和权限异常。 + */ + @ExceptionHandler(AdminAuthorizationException.class) + public ResponseEntity handleAuthorization(AdminAuthorizationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new PlatformErrorResponse(exception.getErrorCode(), exception.getMessage())); + } + + /** + * 处理目录管理后台受控业务异常。 + */ + @ExceptionHandler(AdminOperationException.class) + public ResponseEntity handleOperation(AdminOperationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new PlatformErrorResponse(exception.getErrorCode(), exception.getMessage())); + } +} 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 index af3a6f2..6e009c8 100644 --- 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 @@ -11,6 +11,7 @@ import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationCatalogCodeMapp 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.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -132,6 +133,140 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal return new ReservationPageSnapshot<>(items, page.getTotal(), pageNum, pageSize); } + /** + * 管理后台分页查询 Account 目录,可包含 DISABLED 记录。 + */ + @Override + public ReservationPageSnapshot queryAdminAccounts( + String hotelId, + String keyword, + String status, + int pageNum, + int pageSize) { + String normalizedKeyword = trimToNull(keyword); + String normalizedStatus = trimToNull(status); + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .eq(ReservationCatalogAccountEntity::getHotelId, hotelId) + .eq(normalizedStatus != null, ReservationCatalogAccountEntity::getStatus, normalizedStatus) + .isNull(ReservationCatalogAccountEntity::getLogicDeletedAt) + .and(normalizedKeyword != null, wrapper -> wrapper + .like(ReservationCatalogAccountEntity::getAccountCode, normalizedKeyword) + .or() + .like(ReservationCatalogAccountEntity::getAccountName, normalizedKeyword)) + .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 目录,可包含 DISABLED 记录。 + */ + @Override + public ReservationPageSnapshot queryAdminCatalogCodes( + String hotelId, + String catalogType, + String keyword, + String status, + int pageNum, + int pageSize) { + String normalizedKeyword = trimToNull(keyword); + String normalizedStatus = trimToNull(status); + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .eq(ReservationCatalogCodeEntity::getHotelId, hotelId) + .eq(ReservationCatalogCodeEntity::getCatalogType, catalogType) + .eq(normalizedStatus != null, ReservationCatalogCodeEntity::getStatus, normalizedStatus) + .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt) + .and(normalizedKeyword != null, wrapper -> wrapper + .like(ReservationCatalogCodeEntity::getCode, normalizedKeyword) + .or() + .like(ReservationCatalogCodeEntity::getDisplayName, normalizedKeyword)) + .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); + } + + /** + * 按内部 ID 查询 Account 目录。 + */ + @Override + public Optional findAccountById(Long id) { + if (id == null) { + return Optional.empty(); + } + ReservationCatalogAccountEntity entity = accountMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationCatalogAccountEntity::getId, id) + .isNull(ReservationCatalogAccountEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toAccountSnapshot); + } + + /** + * 按内部 ID 查询通用代码目录。 + */ + @Override + public Optional findCatalogCodeById(Long id) { + if (id == null) { + return Optional.empty(); + } + ReservationCatalogCodeEntity entity = codeMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ReservationCatalogCodeEntity::getId, id) + .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toCodeSnapshot); + } + + /** + * 按酒店和 Account Code 查询未逻辑删除 Account 目录。 + */ + @Override + public Optional findAccountByCode(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) + .isNull(ReservationCatalogAccountEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toAccountSnapshot); + } + + /** + * 按酒店、目录类型和代码查询未逻辑删除通用目录。 + */ + @Override + public Optional findCatalogCodeByCode( + 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) + .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt) + .last("LIMIT 1")); + return Optional.ofNullable(entity).map(this::toCodeSnapshot); + } + /** * 判断当前酒店是否已有 Account 目录记录;包含非 ACTIVE 记录,避免启动种子覆盖人工维护数据。 */ @@ -196,6 +331,64 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal } } + /** + * 新增 Account 目录记录。 + */ + @Override + public void insertAccount(ReservationCatalogAccountEntity entity) { + accountMapper.insert(entity); + } + + /** + * 新增通用代码目录记录。 + */ + @Override + public void insertCatalogCode(ReservationCatalogCodeEntity entity) { + codeMapper.insert(entity); + } + + /** + * 按版本更新 Account 状态,避免后台并发覆盖。 + */ + @Override + public boolean updateAccountStatus( + Long id, + String status, + long expectedVersion, + long nextVersion, + LocalDateTime updatedAt) { + ReservationCatalogAccountEntity entity = new ReservationCatalogAccountEntity(); + entity.setStatus(status); + entity.setVersion(nextVersion); + entity.setUpdatedAt(updatedAt); + int updated = accountMapper.update(entity, Wrappers.lambdaUpdate() + .eq(ReservationCatalogAccountEntity::getId, id) + .eq(ReservationCatalogAccountEntity::getVersion, expectedVersion) + .isNull(ReservationCatalogAccountEntity::getLogicDeletedAt)); + return updated > 0; + } + + /** + * 按版本更新通用代码目录状态,避免后台并发覆盖。 + */ + @Override + public boolean updateCatalogCodeStatus( + Long id, + String status, + long expectedVersion, + long nextVersion, + LocalDateTime updatedAt) { + ReservationCatalogCodeEntity entity = new ReservationCatalogCodeEntity(); + entity.setStatus(status); + entity.setVersion(nextVersion); + entity.setUpdatedAt(updatedAt); + int updated = codeMapper.update(entity, Wrappers.lambdaUpdate() + .eq(ReservationCatalogCodeEntity::getId, id) + .eq(ReservationCatalogCodeEntity::getVersion, expectedVersion) + .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt)); + return updated > 0; + } + private ReservationV4CatalogAccountSnapshot toAccountSnapshot(ReservationCatalogAccountEntity entity) { return new ReservationV4CatalogAccountSnapshot( entity.getId(), @@ -206,9 +399,13 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal entity.getSourceCode(), entity.getStatus(), entity.getSourceSystem(), + entity.getExternalAccountId(), entity.getCatalogVersion(), entity.getLastSyncedAt(), - entity.getMetadataJson()); + entity.getMetadataJson(), + entity.getVersion(), + entity.getCreatedAt(), + entity.getUpdatedAt()); } private ReservationV4CatalogCodeSnapshot toCodeSnapshot(ReservationCatalogCodeEntity entity) { @@ -220,10 +417,14 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal entity.getDisplayName(), entity.getStatus(), entity.getSourceSystem(), + entity.getExternalId(), entity.getSortOrder(), entity.getCatalogVersion(), entity.getLastSyncedAt(), - entity.getMetadataJson()); + entity.getMetadataJson(), + entity.getVersion(), + entity.getCreatedAt(), + entity.getUpdatedAt()); } private String trimToNull(String value) { 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 index 65ea284..2ea6e94 100644 --- 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 @@ -5,6 +5,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4CatalogAc 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.time.LocalDateTime; import java.util.Optional; /** @@ -41,6 +42,47 @@ public interface ReservationV4CatalogRepository { int pageNum, int pageSize); + /** + * 管理后台分页查询 Account 目录,可按 ACTIVE / DISABLED 过滤。 + */ + ReservationPageSnapshot queryAdminAccounts( + String hotelId, + String keyword, + String status, + int pageNum, + int pageSize); + + /** + * 管理后台分页查询 Room Type / Rate Code 目录,可按 ACTIVE / DISABLED 过滤。 + */ + ReservationPageSnapshot queryAdminCatalogCodes( + String hotelId, + String catalogType, + String keyword, + String status, + int pageNum, + int pageSize); + + /** + * 按内部 ID 查询 Account 目录,管理后台写操作需要包含 DISABLED 记录。 + */ + Optional findAccountById(Long id); + + /** + * 按内部 ID 查询通用代码目录,管理后台写操作需要包含 DISABLED 记录。 + */ + Optional findCatalogCodeById(Long id); + + /** + * 按酒店和 Account Code 查询未逻辑删除目录,管理后台新增时用于重复校验。 + */ + Optional findAccountByCode(String hotelId, String accountCode); + + /** + * 按酒店、目录类型和代码查询未逻辑删除目录,管理后台新增时用于重复校验。 + */ + Optional findCatalogCodeByCode(String hotelId, String catalogType, String code); + /** * 判断当前酒店是否已有任意 Account 目录记录,用于启动初始化避免覆盖人工目录。 */ @@ -60,4 +102,24 @@ public interface ReservationV4CatalogRepository { * 通用目录 Code 不存在时插入固定种子目录,存在时保持原数据不变。 */ void insertCatalogCodeIfAbsent(ReservationCatalogCodeEntity entity); + + /** + * 新增 Account 目录记录,调用方负责业务校验和审计。 + */ + void insertAccount(ReservationCatalogAccountEntity entity); + + /** + * 新增通用代码目录记录,调用方负责业务校验和审计。 + */ + void insertCatalogCode(ReservationCatalogCodeEntity entity); + + /** + * 更新 Account 目录状态并推进乐观锁版本。 + */ + boolean updateAccountStatus(Long id, String status, long expectedVersion, long nextVersion, LocalDateTime updatedAt); + + /** + * 更新通用代码目录状态并推进乐观锁版本。 + */ + boolean updateCatalogCodeStatus(Long id, String status, long expectedVersion, long nextVersion, LocalDateTime updatedAt); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CatalogAdminService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CatalogAdminService.java new file mode 100644 index 0000000..801a07f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationV4CatalogAdminService.java @@ -0,0 +1,90 @@ +package cn.nianxx.thhotel.workflows.reservation.service; + +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateAccountRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateCodeRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminStatusUpdateRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminAccountResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminCodeResult; + +/** + * Reservation V4 目录管理服务。第一版只管理 Account、Room Type 和 Rate Code。 + */ +public interface ReservationV4CatalogAdminService { + + /** + * 分页查询 Account 目录管理列表。 + */ + PlatformPageResult listAccounts( + String hotelId, + String keyword, + String status, + Integer pageNum, + Integer pageSize); + + /** + * 新增 Account 目录项。 + */ + ReservationV4CatalogAdminAccountResult createAccount( + ReservationV4CatalogAdminCreateAccountRequest request, + AuthenticatedUserContext actor); + + /** + * 更新 Account 目录状态。 + */ + ReservationV4CatalogAdminAccountResult updateAccountStatus( + String accountId, + ReservationV4CatalogAdminStatusUpdateRequest request, + AuthenticatedUserContext actor); + + /** + * 分页查询 Room Type 目录管理列表。 + */ + PlatformPageResult listRoomTypes( + String hotelId, + String keyword, + String status, + Integer pageNum, + Integer pageSize); + + /** + * 新增 Room Type 目录项。 + */ + ReservationV4CatalogAdminCodeResult createRoomType( + ReservationV4CatalogAdminCreateCodeRequest request, + AuthenticatedUserContext actor); + + /** + * 更新 Room Type 目录状态。 + */ + ReservationV4CatalogAdminCodeResult updateRoomTypeStatus( + String catalogId, + ReservationV4CatalogAdminStatusUpdateRequest request, + AuthenticatedUserContext actor); + + /** + * 分页查询 Rate Code 目录管理列表。 + */ + PlatformPageResult listRateCodes( + String hotelId, + String keyword, + String status, + Integer pageNum, + Integer pageSize); + + /** + * 新增 Rate Code 目录项。 + */ + ReservationV4CatalogAdminCodeResult createRateCode( + ReservationV4CatalogAdminCreateCodeRequest request, + AuthenticatedUserContext actor); + + /** + * 更新 Rate Code 目录状态。 + */ + ReservationV4CatalogAdminCodeResult updateRateCodeStatus( + String catalogId, + ReservationV4CatalogAdminStatusUpdateRequest request, + AuthenticatedUserContext actor); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogAdminServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogAdminServiceImpl.java new file mode 100644 index 0000000..6f53481 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CatalogAdminServiceImpl.java @@ -0,0 +1,583 @@ +package cn.nianxx.thhotel.workflows.reservation.service.impl; + +import cn.nianxx.thhotel.platform.audit.common.dto.PlatformAdminAuditLogDraft; +import cn.nianxx.thhotel.platform.audit.service.PlatformAdminAuditLogService; +import cn.nianxx.thhotel.platform.common.exception.AdminOperationException; +import cn.nianxx.thhotel.platform.common.result.PlatformPageResult; +import cn.nianxx.thhotel.platform.common.result.PlatformPaginationResult; +import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextException; +import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +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.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.common.request.ReservationV4CatalogAdminCreateAccountRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminCreateCodeRequest; +import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CatalogAdminStatusUpdateRequest; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminAccountResult; +import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4CatalogAdminCodeResult; +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 cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CatalogAdminService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reservation V4 目录管理服务实现。写操作只修改目录表,不触碰业务任务历史 payload。 + */ +@Service +public class ReservationV4CatalogAdminServiceImpl implements ReservationV4CatalogAdminService { + + 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 int MAX_CODE_LENGTH = 64; + private static final int MAX_NAME_LENGTH = 255; + private static final int MAX_EXTERNAL_ID_LENGTH = 128; + private static final int MAX_METADATA_LENGTH = 65535; + private static final String TARGET_TYPE_ACCOUNT = "RESERVATION_V4_CATALOG_ACCOUNT"; + private static final String TARGET_TYPE_CODE = "RESERVATION_V4_CATALOG_CODE"; + private static final DateTimeFormatter VERSION_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final ReservationV4CatalogRepository catalogRepository; + private final HotelContextService hotelContextService; + private final PlatformAdminAuditLogService auditLogService; + private final ObjectMapper objectMapper; + + /** + * 注入目录仓储、酒店上下文、管理审计和 JSON 工具。 + */ + public ReservationV4CatalogAdminServiceImpl( + ReservationV4CatalogRepository catalogRepository, + HotelContextService hotelContextService, + PlatformAdminAuditLogService auditLogService, + ObjectMapper objectMapper) { + this.catalogRepository = catalogRepository; + this.hotelContextService = hotelContextService; + this.auditLogService = auditLogService; + this.objectMapper = objectMapper; + } + + /** + * 分页查询 Account 目录管理列表,支持 ACTIVE / DISABLED 过滤。 + */ + @Override + public PlatformPageResult listAccounts( + String hotelId, + String keyword, + String status, + Integer pageNum, + Integer pageSize) { + String accessibleHotelId = resolveHotelId(hotelId); + ReservationPageSnapshot page = catalogRepository.queryAdminAccounts( + accessibleHotelId, + trimToNull(keyword), + normalizeNullableStatus(status), + normalizePageNum(pageNum), + normalizePageSize(pageSize)); + return new PlatformPageResult<>( + page.items().stream().map(this::toAccountResult).toList(), + new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + /** + * 新增 Account 目录项,并记录管理审计。 + */ + @Override + @Transactional + public ReservationV4CatalogAdminAccountResult createAccount( + ReservationV4CatalogAdminCreateAccountRequest request, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("Account 目录新增请求不能为空。"); + } + String hotelId = resolveHotelId(request.hotelId()); + String accountCode = requireCode(request.accountCode(), "Account Code 不能为空。"); + if (catalogRepository.findAccountByCode(hotelId, accountCode).isPresent()) { + throw conflict("Account Code 已存在。"); + } + String accountName = requireText(request.accountName(), "Account 名称不能为空。", MAX_NAME_LENGTH); + String marketCode = requireCode(request.marketCode(), "Market Code 不能为空。"); + String sourceCode = requireCode(request.sourceCode(), "Source Code 不能为空。"); + ensureActiveCatalogCode(hotelId, ReservationV4CatalogType.MARKET, marketCode, "Market Code 不存在或未启用。"); + ensureActiveCatalogCode(hotelId, ReservationV4CatalogType.SOURCE, sourceCode, "Source Code 不存在或未启用。"); + String metadataJson = normalizeMetadataJson(request.metadataJson()); + LocalDateTime now = nowUtc(); + ReservationCatalogAccountEntity entity = new ReservationCatalogAccountEntity(); + entity.setHotelId(hotelId); + entity.setAccountCode(accountCode); + entity.setAccountName(accountName); + entity.setMarketCode(marketCode); + entity.setSourceCode(sourceCode); + entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name()); + entity.setSourceSystem(ReservationV4CatalogSourceSystem.SYSTEM_MANAGED.name()); + entity.setExternalAccountId(optionalText(request.externalAccountId(), MAX_EXTERNAL_ID_LENGTH, "外部 Account ID 过长。")); + entity.setCatalogVersion(resolveCatalogVersion(request.catalogVersion(), now)); + entity.setLastSyncedAt(null); + entity.setMetadataJson(metadataJson); + entity.setVersion(0L); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + catalogRepository.insertAccount(entity); + } catch (DuplicateKeyException exception) { + throw conflict("Account Code 已存在。"); + } + ReservationV4CatalogAdminAccountResult after = getAccountResult(entity.getId()); + audit(actor, TARGET_TYPE_ACCOUNT, stringId(entity.getId()), "CREATE_ACCOUNT_CATALOG", null, after); + return after; + } + + /** + * 更新 Account 目录状态,DISABLED 后普通 lookup 不再返回该 Account。 + */ + @Override + @Transactional + public ReservationV4CatalogAdminAccountResult updateAccountStatus( + String accountId, + ReservationV4CatalogAdminStatusUpdateRequest request, + AuthenticatedUserContext actor) { + Long id = parseId(accountId, "Account 目录 ID 不合法。"); + String targetStatus = normalizeRequiredStatus(request); + ReservationV4CatalogAccountSnapshot beforeSnapshot = catalogRepository.findAccountById(id) + .orElseThrow(() -> notFound("Account 目录不存在。")); + resolveHotelId(beforeSnapshot.hotelId()); + ReservationV4CatalogAdminAccountResult before = toAccountResult(beforeSnapshot); + if (targetStatus.equals(beforeSnapshot.status())) { + return before; + } + long expectedVersion = beforeSnapshot.version() == null ? 0L : beforeSnapshot.version(); + boolean updated = catalogRepository.updateAccountStatus( + id, + targetStatus, + expectedVersion, + expectedVersion + 1, + nowUtc()); + if (!updated) { + throw conflict("Account 目录已被其他操作更新,请刷新后重试。"); + } + ReservationV4CatalogAdminAccountResult after = getAccountResult(id); + audit(actor, TARGET_TYPE_ACCOUNT, stringId(id), "UPDATE_ACCOUNT_CATALOG_STATUS", before, after); + return after; + } + + /** + * 分页查询 Room Type 目录管理列表。 + */ + @Override + public PlatformPageResult listRoomTypes( + String hotelId, + String keyword, + String status, + Integer pageNum, + Integer pageSize) { + return listCatalogCodes(hotelId, ReservationV4CatalogType.ROOM_TYPE, keyword, status, pageNum, pageSize); + } + + /** + * 新增 Room Type 目录项。 + */ + @Override + @Transactional + public ReservationV4CatalogAdminCodeResult createRoomType( + ReservationV4CatalogAdminCreateCodeRequest request, + AuthenticatedUserContext actor) { + return createCatalogCode(request, ReservationV4CatalogType.ROOM_TYPE, actor); + } + + /** + * 更新 Room Type 目录状态。 + */ + @Override + @Transactional + public ReservationV4CatalogAdminCodeResult updateRoomTypeStatus( + String catalogId, + ReservationV4CatalogAdminStatusUpdateRequest request, + AuthenticatedUserContext actor) { + return updateCatalogCodeStatus(catalogId, ReservationV4CatalogType.ROOM_TYPE, request, actor); + } + + /** + * 分页查询 Rate Code 目录管理列表。 + */ + @Override + public PlatformPageResult listRateCodes( + String hotelId, + String keyword, + String status, + Integer pageNum, + Integer pageSize) { + return listCatalogCodes(hotelId, ReservationV4CatalogType.RATE_CODE, keyword, status, pageNum, pageSize); + } + + /** + * 新增 Rate Code 目录项。 + */ + @Override + @Transactional + public ReservationV4CatalogAdminCodeResult createRateCode( + ReservationV4CatalogAdminCreateCodeRequest request, + AuthenticatedUserContext actor) { + return createCatalogCode(request, ReservationV4CatalogType.RATE_CODE, actor); + } + + /** + * 更新 Rate Code 目录状态。 + */ + @Override + @Transactional + public ReservationV4CatalogAdminCodeResult updateRateCodeStatus( + String catalogId, + ReservationV4CatalogAdminStatusUpdateRequest request, + AuthenticatedUserContext actor) { + return updateCatalogCodeStatus(catalogId, ReservationV4CatalogType.RATE_CODE, request, actor); + } + + /** + * 分页查询通用代码目录管理列表。 + */ + private PlatformPageResult listCatalogCodes( + String hotelId, + ReservationV4CatalogType catalogType, + String keyword, + String status, + Integer pageNum, + Integer pageSize) { + String accessibleHotelId = resolveHotelId(hotelId); + ReservationPageSnapshot page = catalogRepository.queryAdminCatalogCodes( + accessibleHotelId, + catalogType.name(), + trimToNull(keyword), + normalizeNullableStatus(status), + normalizePageNum(pageNum), + normalizePageSize(pageSize)); + return new PlatformPageResult<>( + page.items().stream().map(this::toCodeResult).toList(), + new PlatformPaginationResult(page.pageNum(), page.pageSize(), page.total())); + } + + /** + * 新增 Room Type / Rate Code 目录项。 + */ + private ReservationV4CatalogAdminCodeResult createCatalogCode( + ReservationV4CatalogAdminCreateCodeRequest request, + ReservationV4CatalogType catalogType, + AuthenticatedUserContext actor) { + if (request == null) { + throw invalidRequest("目录新增请求不能为空。"); + } + String hotelId = resolveHotelId(request.hotelId()); + String code = requireCode(request.code(), "目录代码不能为空。"); + if (catalogRepository.findCatalogCodeByCode(hotelId, catalogType.name(), code).isPresent()) { + throw conflict("目录代码已存在。"); + } + String displayName = requireText(request.displayName(), "目录显示名称不能为空。", MAX_NAME_LENGTH); + String metadataJson = normalizeMetadataJson(request.metadataJson()); + LocalDateTime now = nowUtc(); + ReservationCatalogCodeEntity entity = new ReservationCatalogCodeEntity(); + entity.setHotelId(hotelId); + entity.setCatalogType(catalogType.name()); + entity.setCode(code); + entity.setDisplayName(displayName); + entity.setStatus(ReservationV4CatalogStatus.ACTIVE.name()); + entity.setSourceSystem(ReservationV4CatalogSourceSystem.SYSTEM_MANAGED.name()); + entity.setExternalId(optionalText(request.externalId(), MAX_EXTERNAL_ID_LENGTH, "外部目录 ID 过长。")); + entity.setSortOrder(request.sortOrder() == null ? 0 : request.sortOrder()); + entity.setCatalogVersion(resolveCatalogVersion(request.catalogVersion(), now)); + entity.setLastSyncedAt(null); + entity.setMetadataJson(metadataJson); + entity.setVersion(0L); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + catalogRepository.insertCatalogCode(entity); + } catch (DuplicateKeyException exception) { + throw conflict("目录代码已存在。"); + } + ReservationV4CatalogAdminCodeResult after = getCodeResult(entity.getId()); + audit(actor, TARGET_TYPE_CODE, stringId(entity.getId()), "CREATE_" + catalogType.name() + "_CATALOG", null, after); + return after; + } + + /** + * 更新 Room Type / Rate Code 目录状态,并校验 ID 所属目录类型。 + */ + private ReservationV4CatalogAdminCodeResult updateCatalogCodeStatus( + String catalogId, + ReservationV4CatalogType catalogType, + ReservationV4CatalogAdminStatusUpdateRequest request, + AuthenticatedUserContext actor) { + Long id = parseId(catalogId, "目录 ID 不合法。"); + String targetStatus = normalizeRequiredStatus(request); + ReservationV4CatalogCodeSnapshot beforeSnapshot = catalogRepository.findCatalogCodeById(id) + .orElseThrow(() -> notFound("目录不存在。")); + if (!catalogType.name().equals(beforeSnapshot.catalogType())) { + throw notFound("目录不存在。"); + } + resolveHotelId(beforeSnapshot.hotelId()); + ReservationV4CatalogAdminCodeResult before = toCodeResult(beforeSnapshot); + if (targetStatus.equals(beforeSnapshot.status())) { + return before; + } + long expectedVersion = beforeSnapshot.version() == null ? 0L : beforeSnapshot.version(); + boolean updated = catalogRepository.updateCatalogCodeStatus( + id, + targetStatus, + expectedVersion, + expectedVersion + 1, + nowUtc()); + if (!updated) { + throw conflict("目录已被其他操作更新,请刷新后重试。"); + } + ReservationV4CatalogAdminCodeResult after = getCodeResult(id); + audit(actor, TARGET_TYPE_CODE, stringId(id), "UPDATE_" + catalogType.name() + "_CATALOG_STATUS", before, after); + return after; + } + + private ReservationV4CatalogAdminAccountResult getAccountResult(Long id) { + return catalogRepository.findAccountById(id) + .map(this::toAccountResult) + .orElseThrow(() -> notFound("Account 目录不存在。")); + } + + private ReservationV4CatalogAdminCodeResult getCodeResult(Long id) { + return catalogRepository.findCatalogCodeById(id) + .map(this::toCodeResult) + .orElseThrow(() -> notFound("目录不存在。")); + } + + private ReservationV4CatalogAdminAccountResult toAccountResult(ReservationV4CatalogAccountSnapshot snapshot) { + return new ReservationV4CatalogAdminAccountResult( + stringId(snapshot.id()), + snapshot.hotelId(), + snapshot.accountCode(), + snapshot.accountName(), + snapshot.marketCode(), + snapshot.sourceCode(), + snapshot.status(), + snapshot.sourceSystem(), + snapshot.externalAccountId(), + snapshot.catalogVersion(), + snapshot.metadataJson(), + snapshot.version(), + UtcTimeFormatter.toUtcOffsetDateTime(snapshot.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(snapshot.updatedAt())); + } + + private ReservationV4CatalogAdminCodeResult toCodeResult(ReservationV4CatalogCodeSnapshot snapshot) { + return new ReservationV4CatalogAdminCodeResult( + stringId(snapshot.id()), + snapshot.hotelId(), + snapshot.catalogType(), + snapshot.code(), + snapshot.displayName(), + snapshot.status(), + snapshot.sourceSystem(), + snapshot.externalId(), + snapshot.sortOrder(), + snapshot.catalogVersion(), + snapshot.metadataJson(), + snapshot.version(), + UtcTimeFormatter.toUtcOffsetDateTime(snapshot.createdAt()), + UtcTimeFormatter.toUtcOffsetDateTime(snapshot.updatedAt())); + } + + private void ensureActiveCatalogCode( + String hotelId, + ReservationV4CatalogType catalogType, + String code, + String message) { + if (catalogRepository.findActiveCatalogCode(hotelId, catalogType.name(), code).isEmpty()) { + throw invalidRequest(message); + } + } + + private String resolveHotelId(String hotelId) { + try { + return hotelContextService.resolveCurrentHotelId(hotelId); + } catch (HotelContextException exception) { + throw new AdminOperationException(exception.getStatus(), exception.getErrorCode(), exception.getMessage()); + } + } + + private String normalizeRequiredStatus(ReservationV4CatalogAdminStatusUpdateRequest request) { + if (request == null) { + throw invalidRequest("目录状态更新请求不能为空。"); + } + String status = normalizeNullableStatus(request.status()); + if (status == null) { + throw invalidRequest("目录状态不能为空。"); + } + return status; + } + + private String normalizeNullableStatus(String status) { + String normalized = trimToNull(status); + if (normalized == null) { + return null; + } + String upper = normalized.toUpperCase(Locale.ROOT); + if (!ReservationV4CatalogStatus.ACTIVE.name().equals(upper) + && !ReservationV4CatalogStatus.DISABLED.name().equals(upper)) { + throw invalidRequest("目录状态不合法。"); + } + return upper; + } + + private String requireCode(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw invalidRequest(message); + } + if (normalized.length() > MAX_CODE_LENGTH) { + throw invalidRequest("目录代码过长。"); + } + return normalized.toUpperCase(Locale.ROOT); + } + + private String requireText(String value, String message, int maxLength) { + String normalized = trimToNull(value); + if (normalized == null) { + throw invalidRequest(message); + } + if (normalized.length() > maxLength) { + throw invalidRequest("文本长度超过限制。"); + } + return normalized; + } + + private String optionalText(String value, int maxLength, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + return null; + } + if (normalized.length() > maxLength) { + throw invalidRequest(message); + } + return normalized; + } + + private String normalizeMetadataJson(String value) { + String normalized = trimToNull(value); + if (normalized == null) { + return null; + } + if (normalized.length() > MAX_METADATA_LENGTH) { + throw invalidRequest("目录扩展属性 JSON 过长。"); + } + try { + objectMapper.readTree(normalized); + } catch (Exception exception) { + throw invalidRequest("目录扩展属性 JSON 不合法。"); + } + return normalized; + } + + private String resolveCatalogVersion(String requestedVersion, LocalDateTime now) { + String normalized = trimToNull(requestedVersion); + if (normalized != null) { + if (normalized.length() > MAX_CODE_LENGTH) { + throw invalidRequest("目录版本过长。"); + } + return normalized; + } + return "system-managed-" + VERSION_TIME_FORMATTER.format(now); + } + + private Long parseId(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw invalidRequest(message); + } + try { + return Long.parseLong(normalized); + } catch (NumberFormatException exception) { + throw invalidRequest(message); + } + } + + private int normalizePageNum(Integer pageNum) { + if (pageNum == null || pageNum < 1) { + return DEFAULT_PAGE_NUM; + } + return Math.min(pageNum, MAX_PAGE_NUM); + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(pageSize, MAX_PAGE_SIZE); + } + + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + private String stringId(Long id) { + return id == null ? null : id.toString(); + } + + private AdminOperationException invalidRequest(String message) { + return new AdminOperationException(HttpStatus.BAD_REQUEST, "RESERVATION_CATALOG_INVALID_REQUEST", message); + } + + private AdminOperationException notFound(String message) { + return new AdminOperationException(HttpStatus.NOT_FOUND, "RESERVATION_CATALOG_NOT_FOUND", message); + } + + private AdminOperationException conflict(String message) { + return new AdminOperationException(HttpStatus.CONFLICT, "RESERVATION_CATALOG_CONFLICT", message); + } + + private void audit( + AuthenticatedUserContext actor, + String targetType, + String targetId, + String action, + Object before, + Object after) { + auditLogService.record(new PlatformAdminAuditLogDraft( + actor, + targetType, + targetId, + action, + toJson(before), + toJson(after))); + } + + private String toJson(Object value) { + if (value == null) { + return null; + } + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new AdminOperationException( + HttpStatus.INTERNAL_SERVER_ERROR, + "ADMIN_AUDIT_SERIALIZE_FAILED", + "系统管理审计序列化失败。"); + } + } + + private String trimToNull(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java index bf46bd9..d41c5b6 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java @@ -78,6 +78,7 @@ class AuthControllerTest { "RESERVATION_AUDIT_READ", "RESERVATION_INVOICE_GENERATE", "RESERVATION_ROOMING_LIST_GENERATE", + "RESERVATION_CATALOG_MANAGE", "HOTEL_SWITCH", "SYSTEM_AUTH_READ", "SYSTEM_USER_MANAGE", diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminControllerTest.java new file mode 100644 index 0000000..817d753 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogAdminControllerTest.java @@ -0,0 +1,290 @@ +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.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +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 com.fasterxml.jackson.databind.ObjectMapper; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest( + classes = ThHotelApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:reservation_v4_catalog_admin_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE", + "auth.bootstrap.admin.username=v4-catalog-admin-cp1", + "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 ReservationV4CatalogAdminControllerTest { + + private static final String HOTEL_ID = "HOTEL-TEST"; + private static final String OTHER_HOTEL_ID = "HOTEL-OTHER"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @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-admin-no-permission") + .orElseGet(() -> { + LocalDateTime now = LocalDateTime.now(); + PlatformUserEntity created = new PlatformUserEntity(); + created.setUsername("v4-catalog-admin-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 shouldListAndCreateAccountCatalog() throws Exception { + performAuthorized(mockMvc, adminToken(), get("/api/admin/reservation/catalogs/accounts") + .param("hotel_id", HOTEL_ID) + .param("keyword", "QBD") + .param("status", "ACTIVE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].account_code").value("QBD_TRAVEL")) + .andExpect(jsonPath("$.items[0].status").value("ACTIVE")) + .andExpect(jsonPath("$.page.page_num").value(1)); + + MvcResult created = performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "account_code":"CP1_ACCOUNT", + "account_name":"CP1 Managed Account", + "market_code":"LEISURE", + "source_code":"TRAVEL_AGENT", + "metadata_json":"{\\"note\\":\\"manual\\"}" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.account_code").value("CP1_ACCOUNT")) + .andExpect(jsonPath("$.account_name").value("CP1 Managed Account")) + .andExpect(jsonPath("$.catalog_source").value("SYSTEM_MANAGED")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andReturn(); + String accountId = objectMapper.readTree(created.getResponse().getContentAsString()).path("id").asText(); + + performAuthorized(mockMvc, adminToken(), get("/api/admin/reservation/catalogs/accounts") + .param("hotel_id", HOTEL_ID) + .param("keyword", "CP1_ACCOUNT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].account_code").value("CP1_ACCOUNT")) + .andExpect(jsonPath("$.items[0].market_code").value("LEISURE")); + + performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "account_code":"CP1_ACCOUNT", + "account_name":"Duplicate Account", + "market_code":"LEISURE", + "source_code":"TRAVEL_AGENT" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("RESERVATION_CATALOG_CONFLICT")); + + performAuthorized(mockMvc, adminToken(), put("/api/admin/reservation/catalogs/accounts/{accountId}/status", accountId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"status":"DISABLED"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DISABLED")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts") + .param("hotel_id", HOTEL_ID) + .param("keyword", "CP1_ACCOUNT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isEmpty()); + } + + @Test + void shouldCreateRoomTypeAndRateCodeCatalogs() throws Exception { + performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/room-types") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "code":"CP1_ROOM", + "display_name":"CP1 Room", + "sort_order":88, + "metadata_json":"{\\"adult_capacity\\":2}" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.catalog_type").value("ROOM_TYPE")) + .andExpect(jsonPath("$.code").value("CP1_ROOM")) + .andExpect(jsonPath("$.catalog_source").value("SYSTEM_MANAGED")) + .andExpect(jsonPath("$.status").value("ACTIVE")); + + MvcResult rateCreated = performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/rate-codes") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "code":"CP1_RATE", + "display_name":"CP1 Rate", + "sort_order":89, + "metadata_json":"{\\"pricing_available\\":false}" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.catalog_type").value("RATE_CODE")) + .andExpect(jsonPath("$.code").value("CP1_RATE")) + .andExpect(jsonPath("$.catalog_source").value("SYSTEM_MANAGED")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andReturn(); + String rateCodeId = objectMapper.readTree(rateCreated.getResponse().getContentAsString()).path("id").asText(); + + performAuthorized(mockMvc, adminToken(), put("/api/admin/reservation/catalogs/rate-codes/{catalogId}/status", rateCodeId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"status":"DISABLED"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DISABLED")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/rate-codes") + .param("hotel_id", HOTEL_ID) + .param("keyword", "CP1_RATE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isEmpty()); + } + + @Test + void shouldEnableAndDisableCatalogAndKeepLookupActiveOnly() throws Exception { + MvcResult created = performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/room-types") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "code":"CP1_DISABLE_ROOM", + "display_name":"CP1 Disabled Room", + "sort_order":91, + "metadata_json":"{\\"adult_capacity\\":2}" + } + """)) + .andExpect(status().isOk()) + .andReturn(); + String roomTypeId = objectMapper.readTree(created.getResponse().getContentAsString()).path("id").asText(); + + performAuthorized(mockMvc, adminToken(), put("/api/admin/reservation/catalogs/room-types/{catalogId}/status", roomTypeId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"status":"DISABLED"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DISABLED")); + + performAuthorized(mockMvc, adminToken(), get("/api/admin/reservation/catalogs/room-types") + .param("hotel_id", HOTEL_ID) + .param("keyword", "CP1_DISABLE_ROOM") + .param("status", "DISABLED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].code").value("CP1_DISABLE_ROOM")) + .andExpect(jsonPath("$.items[0].status").value("DISABLED")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/room-types") + .param("hotel_id", HOTEL_ID) + .param("keyword", "CP1_DISABLE_ROOM")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isEmpty()); + + performAuthorized(mockMvc, adminToken(), put("/api/admin/reservation/catalogs/room-types/{catalogId}/status", roomTypeId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"status":"ACTIVE"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ACTIVE")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/room-types") + .param("hotel_id", HOTEL_ID) + .param("keyword", "CP1_DISABLE_ROOM")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].code").value("CP1_DISABLE_ROOM")); + } + + @Test + void shouldRejectCatalogAdminWithoutPermissionOrHotelAccess() throws Exception { + mockMvc.perform(get("/api/admin/reservation/catalogs/accounts") + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isUnauthorized()); + + performAuthorized(mockMvc, noPermissionToken(), get("/api/admin/reservation/catalogs/accounts") + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error_code").value("ADMIN_PERMISSION_DENIED")); + + performAuthorized(mockMvc, adminToken(), get("/api/admin/reservation/catalogs/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-cp1", "Admin@123456"); + } + return adminToken; + } + + private String noPermissionToken() throws Exception { + if (noPermissionToken == null) { + noPermissionToken = loginToken(mockMvc, "v4-catalog-admin-no-permission", "NoPerm@123456"); + } + return noPermissionToken; + } +}