From 739fccd9552ea7ac908e62b823343bb95b23cfad Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 23:44:59 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DV4=E7=9B=AE=E5=BD=95=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E4=B8=8E=E5=B9=82=E7=AD=89=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend-to-frontend-notes.md | 10 +- .../M002-v4-real-catalog-lookup-api-design.md | 2 + ...MybatisReservationV4CatalogRepository.java | 28 ++-- ...servationV4CatalogAdminControllerTest.java | 136 ++++++++++++++++++ ...ervationV4CatalogLookupControllerTest.java | 21 +++ 5 files changed, 184 insertions(+), 13 deletions(-) diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 6db266e..6f4e05e 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -58,13 +58,13 @@ | `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id`、`order_id`、`order_task_status`、`card_status`、`keyword`、`page_num`、`page_size`;`order_task_status` 非 `OPEN` / `COMPLETED` 返回 400,`card_status` 非 V4 卡状态返回 400;`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 | | `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | -| `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,支持 `hotel_id`、`keyword`、`page_num`、`page_size`;返回统一 wrapper:`hotel_id`、`catalog_type=ACCOUNT`、`catalog_source`、`catalog_version`、`stale`、`items[]`、`page`、`warnings[]`。`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`。 | +| `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` 匹配目录 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`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` 匹配房型 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`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` 匹配 Rate Code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`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`;`keyword` 搜索 code 时大小写不敏感,显示名仍按数据库比较规则;返回 `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`。 | +| `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`;`keyword` 搜索 code 时大小写不敏感,显示名仍按数据库比较规则;返回 `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` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | 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 67f09fd..64bafe8 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 @@ -203,6 +203,7 @@ Lookup API 属于前端业务查询接口,不给 SuperAgent 或 AgentBus 调 - 酒店隔离:`hotel_id` 可选;不传时按当前用户默认酒店解析,传入时必须校验用户可访问。 - 返回:只返回目录 code、显示名、状态、来源和安全元数据,不返回外部 PMS 原始响应。 - 分页:`page_num` 从 1 开始,`page_size` 后端限制最大值。 +- 搜索:`keyword` 匹配稳定 code 时,后端先按 `Locale.ROOT` 大写归一化后查询,因此前端或管理后台传小写 code 也能命中;匹配显示名时仍按数据库比较规则,不额外做大小写归一化。 - `keyword` 无匹配时,`items=[]` 且 `page.total=0`;如果当前酒店未过滤的 ACTIVE 目录仍存在,`catalog_source` / `catalog_version` 继续返回真实目录元数据,不把筛选无结果误报为 `DATABASE_EMPTY`。 ### 8.1 Account Lookup @@ -456,6 +457,7 @@ CP1 限制: - 新增目录默认 `ACTIVE`,`source_system=SYSTEM_MANAGED`。 - 管理列表可按 `status=ACTIVE/DISABLED` 过滤;普通 lookup 仍只返回 `ACTIVE`。 +- 管理列表 `keyword` 搜索稳定 code 时大小写不敏感,显示名仍按数据库比较规则。 - Account 新增时 `market_code`、`source_code` 必须是当前酒店 `ACTIVE` 的 Market / Source code。 - Market / Source 独立管理页面暂不做,仍沿用当前初始化目录。 - 停用目录不会回滚已确认历史卡片,但新确认 / 复核会按当前 ACTIVE 目录校验。 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 6e009c8..01fef0c 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 @@ -13,6 +13,7 @@ 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.Locale; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -87,14 +88,16 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal String keyword, int pageNum, int pageSize) { + String normalizedKeyword = trimToNull(keyword); + String normalizedCodeKeyword = normalizeCode(normalizedKeyword); LambdaQueryWrapper query = Wrappers.lambdaQuery() .eq(ReservationCatalogAccountEntity::getHotelId, hotelId) .eq(ReservationCatalogAccountEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name()) .isNull(ReservationCatalogAccountEntity::getLogicDeletedAt) - .and(trimToNull(keyword) != null, wrapper -> wrapper - .like(ReservationCatalogAccountEntity::getAccountCode, trimToNull(keyword)) + .and(normalizedKeyword != null, wrapper -> wrapper + .like(ReservationCatalogAccountEntity::getAccountCode, normalizedCodeKeyword) .or() - .like(ReservationCatalogAccountEntity::getAccountName, trimToNull(keyword))) + .like(ReservationCatalogAccountEntity::getAccountName, normalizedKeyword)) .orderByAsc(ReservationCatalogAccountEntity::getAccountCode) .orderByAsc(ReservationCatalogAccountEntity::getId); Page page = accountMapper.selectPage(Page.of(pageNum, pageSize), query); @@ -114,15 +117,17 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal String keyword, int pageNum, int pageSize) { + String normalizedKeyword = trimToNull(keyword); + String normalizedCodeKeyword = normalizeCode(normalizedKeyword); LambdaQueryWrapper query = Wrappers.lambdaQuery() .eq(ReservationCatalogCodeEntity::getHotelId, hotelId) .eq(ReservationCatalogCodeEntity::getCatalogType, catalogType) .eq(ReservationCatalogCodeEntity::getStatus, ReservationV4CatalogStatus.ACTIVE.name()) .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt) - .and(trimToNull(keyword) != null, wrapper -> wrapper - .like(ReservationCatalogCodeEntity::getCode, trimToNull(keyword)) + .and(normalizedKeyword != null, wrapper -> wrapper + .like(ReservationCatalogCodeEntity::getCode, normalizedCodeKeyword) .or() - .like(ReservationCatalogCodeEntity::getDisplayName, trimToNull(keyword))) + .like(ReservationCatalogCodeEntity::getDisplayName, normalizedKeyword)) .orderByAsc(ReservationCatalogCodeEntity::getSortOrder) .orderByAsc(ReservationCatalogCodeEntity::getCode) .orderByAsc(ReservationCatalogCodeEntity::getId); @@ -144,13 +149,14 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal int pageNum, int pageSize) { String normalizedKeyword = trimToNull(keyword); + String normalizedCodeKeyword = normalizeCode(normalizedKeyword); 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) + .like(ReservationCatalogAccountEntity::getAccountCode, normalizedCodeKeyword) .or() .like(ReservationCatalogAccountEntity::getAccountName, normalizedKeyword)) .orderByAsc(ReservationCatalogAccountEntity::getAccountCode) @@ -174,6 +180,7 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal int pageNum, int pageSize) { String normalizedKeyword = trimToNull(keyword); + String normalizedCodeKeyword = normalizeCode(normalizedKeyword); String normalizedStatus = trimToNull(status); LambdaQueryWrapper query = Wrappers.lambdaQuery() .eq(ReservationCatalogCodeEntity::getHotelId, hotelId) @@ -181,7 +188,7 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal .eq(normalizedStatus != null, ReservationCatalogCodeEntity::getStatus, normalizedStatus) .isNull(ReservationCatalogCodeEntity::getLogicDeletedAt) .and(normalizedKeyword != null, wrapper -> wrapper - .like(ReservationCatalogCodeEntity::getCode, normalizedKeyword) + .like(ReservationCatalogCodeEntity::getCode, normalizedCodeKeyword) .or() .like(ReservationCatalogCodeEntity::getDisplayName, normalizedKeyword)) .orderByAsc(ReservationCatalogCodeEntity::getSortOrder) @@ -433,4 +440,9 @@ public class MybatisReservationV4CatalogRepository implements ReservationV4Catal } return value.trim(); } + + private String normalizeCode(String value) { + String normalized = trimToNull(value); + return normalized == null ? null : normalized.toUpperCase(Locale.ROOT); + } } 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 index 817d753..dda7dd1 100644 --- 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 @@ -2,6 +2,7 @@ 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.assertj.core.api.Assertions.assertThat; 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; @@ -22,6 +23,7 @@ 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.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -60,6 +62,9 @@ class ReservationV4CatalogAdminControllerTest { @Autowired private AuthPasswordService passwordService; + @Autowired + private JdbcTemplate jdbcTemplate; + private String adminToken; private String noPermissionToken; @@ -188,6 +193,30 @@ class ReservationV4CatalogAdminControllerTest { .andReturn(); String rateCodeId = objectMapper.readTree(rateCreated.getResponse().getContentAsString()).path("id").asText(); + 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 Duplicate" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("RESERVATION_CATALOG_CONFLICT")); + + 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 Duplicate" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("RESERVATION_CATALOG_CONFLICT")); + performAuthorized(mockMvc, adminToken(), put("/api/admin/reservation/catalogs/rate-codes/{catalogId}/status", rateCodeId) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -257,6 +286,100 @@ class ReservationV4CatalogAdminControllerTest { .andExpect(jsonPath("$.items[0].code").value("CP1_DISABLE_ROOM")); } + @Test + void shouldSearchCatalogAdminCodeIgnoringKeywordCase() throws Exception { + performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "account_code":"CP1_CASE_ACCOUNT", + "account_name":"CP1 Case Account", + "market_code":"LEISURE", + "source_code":"TRAVEL_AGENT" + } + """)) + .andExpect(status().isOk()); + + performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/room-types") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "code":"CP1_CASE_ROOM", + "display_name":"CP1 Case Room" + } + """)) + .andExpect(status().isOk()); + + performAuthorized(mockMvc, adminToken(), post("/api/admin/reservation/catalogs/rate-codes") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "hotel_id":"HOTEL-TEST", + "code":"CP1_CASE_RATE", + "display_name":"CP1 Case Rate" + } + """)) + .andExpect(status().isOk()); + + performAuthorized(mockMvc, adminToken(), get("/api/admin/reservation/catalogs/accounts") + .param("hotel_id", HOTEL_ID) + .param("keyword", "cp1_case_account")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].account_code").value("CP1_CASE_ACCOUNT")); + + performAuthorized(mockMvc, adminToken(), get("/api/admin/reservation/catalogs/room-types") + .param("hotel_id", HOTEL_ID) + .param("keyword", "cp1_case_room")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].code").value("CP1_CASE_ROOM")); + + performAuthorized(mockMvc, adminToken(), get("/api/admin/reservation/catalogs/rate-codes") + .param("hotel_id", HOTEL_ID) + .param("keyword", "cp1_case_rate")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].code").value("CP1_CASE_RATE")); + } + + @Test + void shouldNotWriteAuditWhenStatusUpdateIsNoop() 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_NOOP_AUDIT_ROOM", + "display_name":"CP1 Noop Audit Room" + } + """)) + .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")); + + Long afterFirstUpdate = countCatalogStatusAudits(roomTypeId, "UPDATE_ROOM_TYPE_CATALOG_STATUS"); + assertThat(afterFirstUpdate).isEqualTo(1L); + + 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")); + + Long afterNoopUpdate = countCatalogStatusAudits(roomTypeId, "UPDATE_ROOM_TYPE_CATALOG_STATUS"); + assertThat(afterNoopUpdate).isEqualTo(1L); + } + @Test void shouldRejectCatalogAdminWithoutPermissionOrHotelAccess() throws Exception { mockMvc.perform(get("/api/admin/reservation/catalogs/accounts") @@ -287,4 +410,17 @@ class ReservationV4CatalogAdminControllerTest { } return noPermissionToken; } + + private Long countCatalogStatusAudits(String targetId, String action) { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(1) + FROM platform_admin_audit_log + WHERE target_type = 'RESERVATION_V4_CATALOG_CODE' + AND target_id = ? + AND action = ? + """, + Long.class, + targetId, + action); + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java index 71d2088..c33f6d6 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CatalogLookupControllerTest.java @@ -118,6 +118,27 @@ class ReservationV4CatalogLookupControllerTest { .andExpect(jsonPath("$.items[0].pricing_available").value(false)); } + @Test + void shouldSearchLookupCodeIgnoringKeywordCase() throws Exception { + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts") + .param("hotel_id", HOTEL_ID) + .param("keyword", "qbd_travel")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].code").value("QBD_TRAVEL")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/room-types") + .param("hotel_id", HOTEL_ID) + .param("keyword", "rm2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].code").value("RM2")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/rate-codes") + .param("hotel_id", HOTEL_ID) + .param("keyword", "group")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].code").value("GROUP")); + } + @Test void shouldKeepCatalogMetadataWhenKeywordMatchesNothing() throws Exception { performAuthorized(mockMvc, adminToken(), get("/api/reservation/lookups/accounts")