From f40f90553f5bba1533e2a43937f32cd1544a1235 Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 20 Jul 2026 00:09:58 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0V4=E7=9B=AE=E5=BD=95=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 14 +- client/src/i18n/locales/en-US.ts | 46 + client/src/i18n/locales/th-TH.ts | 46 + client/src/i18n/locales/zh-CN.ts | 46 + client/src/layouts/ReservationAppShell.vue | 1 + client/src/router/index.ts | 12 + client/src/services/systemAdminService.ts | 81 ++ client/src/tests/reservationRouter.spec.ts | 15 + client/src/tests/systemAdminService.spec.ts | 114 +++ .../systemReservationCatalogsView.spec.ts | 274 ++++++ client/src/types/auth.ts | 1 + client/src/types/systemAdmin.ts | 64 ++ .../views/system/SystemAdminLayoutView.vue | 6 + .../system/SystemReservationCatalogsView.vue | 808 ++++++++++++++++++ docs/project/frontend-backend/README.md | 2 +- .../backend-to-frontend-notes.md | 3 + .../M002-v4-real-catalog-lookup-api-design.md | 4 +- .../security-access-control-boundary.md | 2 +- ...7-19-m002-v4-catalog-admin-frontend-cp1.md | 126 +++ 19 files changed, 1654 insertions(+), 11 deletions(-) create mode 100644 client/src/tests/systemReservationCatalogsView.spec.ts create mode 100644 client/src/views/system/SystemReservationCatalogsView.vue create mode 100644 docs/superpowers/plans/2026-07-19-m002-v4-catalog-admin-frontend-cp1.md diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 94930fa..ab696bc 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,15 +4,15 @@ | --- | --- | | 最近更新 | 2026-07-19 | | 当前分支 | `feature/huangting` | -| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入和目录管理后台 CP1 后端 | -| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端、前端 V4 多卡字段 lookup 第一版接入,并完成目录管理后台 CP1 后端接口;后续可做目录管理前端页面或真实 PMS / OPERA / OHIP 同步 | +| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录、Lookup API、前端 lookup 接入和目录管理后台 CP1 前后端 | +| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端、前端 V4 多卡字段 lookup 第一版接入,并完成系统设置下 Account / Room Type / Rate Code 目录管理前端 CP1;后续可做测试机联调、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案 | ## 1. 当前 Checkpoint -- 名称:`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 接口。 +- 名称:`M002-V4-CP13-catalog-admin-cp1` +- 状态:Done,后端已开放 Account / Room Type / Rate Code 目录管理后台 CP1 接口,前端已在系统设置新增 `/system/reservation-catalogs` 页面;支持列表查询、新增、启用 / 停用;权限使用 `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 目录管理与多卡 lookup 联调、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index d712a8d..ae70ebc 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -40,6 +40,7 @@ export default { systemRoles: 'Roles', systemMenus: 'Menus', systemHotels: 'Hotels', + systemReservationCatalogs: 'Reservation catalogs', systemAudits: 'Admin audits', }, common: { @@ -193,6 +194,51 @@ export default { sort: 'Sort', }, }, + catalogs: { + title: 'Reservation V4 catalog management', + subtitle: 'Maintain Account, Room Type, and Rate Code catalogs for V4 task card lookup and backend confirmation validation.', + typeNavigation: 'Reservation V4 catalog type', + filters: 'Catalog filters', + hotel: 'Hotel', + defaultHotel: 'Current default hotel', + keywordPlaceholder: 'Code / name', + catalogStatus: 'Catalog status', + createTitle: 'Create {type}', + createHint: 'V1 catalogs are maintained by system admins and become active after saving.', + createAction: 'Create catalog', + createSucceeded: 'Catalog created', + statusUpdated: 'Catalog status updated', + disabledLookupNotice: 'Disabled catalog items no longer appear in ordinary V4 task card lookup; confirmed historical cards are not rolled back.', + defaultActiveHint: 'New items are active by default. Account V1 uses fixed Market=LEISURE and Source=TRAVEL_AGENT.', + hotelRequired: 'Select a hotel first.', + tableTitle: '{type} data', + totalSuffix: 'catalog items', + accountCode: 'Account Code', + accountName: 'Account name', + marketCode: 'Market Code', + sourceCode: 'Source Code', + externalAccountId: 'External Account ID', + code: 'Code', + displayName: 'Display name', + externalId: 'External catalog ID', + sortOrder: 'Sort order', + catalogVersion: 'Catalog version', + metadataJson: 'Metadata JSON', + enableAction: 'Enable', + disableAction: 'Disable', + types: { + accounts: 'Account', + roomTypes: 'Room Type', + rateCodes: 'Rate Code', + }, + columns: { + catalog: 'Catalog', + status: 'Status', + marketSource: 'Market / Source', + source: 'Source / version', + sort: 'Sort', + }, + }, audits: { title: 'Audit filters', tableTitle: 'Admin audit data', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 4759a74..c5895ba 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -40,6 +40,7 @@ export default { systemRoles: 'บทบาท', systemMenus: 'เมนู', systemHotels: 'โรงแรม', + systemReservationCatalogs: 'แคตตาล็อกการจอง', systemAudits: 'บันทึกผู้ดูแล', }, common: { @@ -193,6 +194,51 @@ export default { sort: 'ลำดับ', }, }, + catalogs: { + title: 'จัดการแคตตาล็อก Reservation V4', + subtitle: 'ดูแล Account, Room Type และ Rate Code สำหรับ lookup ในการ์ดงาน V4 และการตรวจสอบตอนยืนยันโดยหลังบ้าน', + typeNavigation: 'ประเภทแคตตาล็อก Reservation V4', + filters: 'ตัวกรองแคตตาล็อก', + hotel: 'โรงแรม', + defaultHotel: 'โรงแรมเริ่มต้นปัจจุบัน', + keywordPlaceholder: 'รหัส / ชื่อ', + catalogStatus: 'สถานะแคตตาล็อก', + createTitle: 'สร้าง {type}', + createHint: 'เวอร์ชันแรกดูแลแคตตาล็อกโดยผู้ดูแลระบบ และหลังบันทึกจะเปิดใช้งานโดยอัตโนมัติ', + createAction: 'สร้างแคตตาล็อก', + createSucceeded: 'สร้างแคตตาล็อกแล้ว', + statusUpdated: 'อัปเดตสถานะแคตตาล็อกแล้ว', + disabledLookupNotice: 'รายการที่ปิดใช้งานจะไม่ถูกส่งกลับใน lookup ของการ์ดงาน V4 ทั่วไป แต่การ์ดที่ยืนยันแล้วในอดีตจะไม่ถูกย้อนกลับ', + defaultActiveHint: 'รายการใหม่เปิดใช้งานโดยค่าเริ่มต้น Account เวอร์ชันแรกใช้ Market=LEISURE และ Source=TRAVEL_AGENT แบบคงที่', + hotelRequired: 'โปรดเลือกโรงแรมก่อน', + tableTitle: 'ข้อมูล {type}', + totalSuffix: 'รายการแคตตาล็อก', + accountCode: 'Account Code', + accountName: 'ชื่อ Account', + marketCode: 'Market Code', + sourceCode: 'Source Code', + externalAccountId: 'External Account ID', + code: 'รหัส', + displayName: 'ชื่อแสดง', + externalId: 'External Catalog ID', + sortOrder: 'ลำดับ', + catalogVersion: 'เวอร์ชันแคตตาล็อก', + metadataJson: 'Metadata JSON', + enableAction: 'เปิดใช้งาน', + disableAction: 'ปิดใช้งาน', + types: { + accounts: 'Account', + roomTypes: 'Room Type', + rateCodes: 'Rate Code', + }, + columns: { + catalog: 'แคตตาล็อก', + status: 'สถานะ', + marketSource: 'Market / Source', + source: 'แหล่งที่มา / เวอร์ชัน', + sort: 'ลำดับ', + }, + }, audits: { title: 'ตัวกรองบันทึก', tableTitle: 'ข้อมูลบันทึกผู้ดูแล', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index a1b3479..0f3d31f 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -40,6 +40,7 @@ export default { systemRoles: '角色权限', systemMenus: '菜单管理', systemHotels: '酒店管理', + systemReservationCatalogs: '预订目录', systemAudits: '管理审计', }, common: { @@ -193,6 +194,51 @@ export default { sort: '排序', }, }, + catalogs: { + title: 'Reservation V4 目录管理', + subtitle: '维护 Account、Room Type 和 Rate Code,供 V4 任务卡 lookup 和后端确认校验使用。', + typeNavigation: 'Reservation V4 目录类型', + filters: '目录筛选', + hotel: '酒店', + defaultHotel: '当前默认酒店', + keywordPlaceholder: '代码 / 名称', + catalogStatus: '目录状态', + createTitle: '新增 {type}', + createHint: '第一版新增目录由系统管理维护,保存后默认启用。', + createAction: '新增目录', + createSucceeded: '目录已新增', + statusUpdated: '目录状态已更新', + disabledLookupNotice: '停用目录项后,普通 V4 任务卡 lookup 不再返回该项;已确认历史卡片不会被回滚。', + defaultActiveHint: '新增后默认启用。Account 第一版固定使用 Market=LEISURE、Source=TRAVEL_AGENT。', + hotelRequired: '请先选择酒店。', + tableTitle: '{type} 数据', + totalSuffix: '条目录', + accountCode: 'Account Code', + accountName: 'Account 名称', + marketCode: 'Market Code', + sourceCode: 'Source Code', + externalAccountId: '外部 Account ID', + code: '代码', + displayName: '显示名称', + externalId: '外部目录 ID', + sortOrder: '排序', + catalogVersion: '目录版本', + metadataJson: '扩展 JSON', + enableAction: '启用', + disableAction: '停用', + types: { + accounts: 'Account', + roomTypes: 'Room Type', + rateCodes: 'Rate Code', + }, + columns: { + catalog: '目录', + status: '状态', + marketSource: 'Market / Source', + source: '来源 / 版本', + sort: '排序', + }, + }, audits: { title: '审计筛选', tableTitle: '管理审计数据', diff --git a/client/src/layouts/ReservationAppShell.vue b/client/src/layouts/ReservationAppShell.vue index fd1c3c9..49a3206 100644 --- a/client/src/layouts/ReservationAppShell.vue +++ b/client/src/layouts/ReservationAppShell.vue @@ -179,6 +179,7 @@ const menuLabelKeys: Record = { RESERVATION_ROOMING_LISTS: 'nav.roomingList', DEBUG_EML_SUPERAGENT: 'nav.debugEml', SYSTEM_SETTINGS: 'nav.systemSettings', + SYSTEM_RESERVATION_CATALOGS: 'nav.systemReservationCatalogs', } const navItems = computed(() => authStore.visibleMenus) diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 9ee2b18..11ed2f7 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -178,6 +178,15 @@ export const router = createRouter({ permission: 'HOTEL_MANAGE', }, }, + { + path: 'reservation-catalogs', + name: 'system-reservation-catalogs', + component: () => import('@/views/system/SystemReservationCatalogsView.vue'), + meta: { + titleKey: 'nav.systemReservationCatalogs', + permission: 'RESERVATION_CATALOG_MANAGE', + }, + }, { path: 'audits', name: 'system-audits', @@ -258,5 +267,8 @@ function resolveSystemAdminEntryPath(authStore: AuthStore): string { if (authStore.hasPermission('HOTEL_MANAGE')) { return '/system/hotels' } + if (authStore.hasPermission('RESERVATION_CATALOG_MANAGE')) { + return '/system/reservation-catalogs' + } return '/system/audits' } diff --git a/client/src/services/systemAdminService.ts b/client/src/services/systemAdminService.ts index 1d5048a..0b0dc7b 100644 --- a/client/src/services/systemAdminService.ts +++ b/client/src/services/systemAdminService.ts @@ -14,6 +14,12 @@ import type { AdminMenuTreeResult, AdminMenuUpdateRequest, AdminPermissionResult, + AdminReservationCatalogAccountCreateRequest, + AdminReservationCatalogAccountResult, + AdminReservationCatalogCodeCreateRequest, + AdminReservationCatalogCodeResult, + AdminReservationCatalogListFilters, + AdminReservationCatalogStatusUpdateRequest, AdminRoleCreateRequest, AdminRoleDetailResult, AdminRoleListFilters, @@ -144,6 +150,81 @@ export async function fetchAdminHotels( return getJson>(withQuery('/api/admin/hotels', filters)) } +export async function fetchAdminReservationCatalogAccounts( + filters: AdminReservationCatalogListFilters = {}, +): Promise> { + return getJson>( + withQuery('/api/admin/reservation/catalogs/accounts', filters), + ) +} + +export async function createAdminReservationCatalogAccount( + request: AdminReservationCatalogAccountCreateRequest, +): Promise { + return sendJson('/api/admin/reservation/catalogs/accounts', 'POST', request) +} + +export async function updateAdminReservationCatalogAccountStatus( + accountId: string, + request: AdminReservationCatalogStatusUpdateRequest, +): Promise { + return sendJson( + `/api/admin/reservation/catalogs/accounts/${encodeURIComponent(accountId)}/status`, + 'PUT', + request, + ) +} + +export async function fetchAdminReservationCatalogRoomTypes( + filters: AdminReservationCatalogListFilters = {}, +): Promise> { + return getJson>( + withQuery('/api/admin/reservation/catalogs/room-types', filters), + ) +} + +export async function createAdminReservationCatalogRoomType( + request: AdminReservationCatalogCodeCreateRequest, +): Promise { + return sendJson('/api/admin/reservation/catalogs/room-types', 'POST', request) +} + +export async function updateAdminReservationCatalogRoomTypeStatus( + catalogId: string, + request: AdminReservationCatalogStatusUpdateRequest, +): Promise { + return sendJson( + `/api/admin/reservation/catalogs/room-types/${encodeURIComponent(catalogId)}/status`, + 'PUT', + request, + ) +} + +export async function fetchAdminReservationCatalogRateCodes( + filters: AdminReservationCatalogListFilters = {}, +): Promise> { + return getJson>( + withQuery('/api/admin/reservation/catalogs/rate-codes', filters), + ) +} + +export async function createAdminReservationCatalogRateCode( + request: AdminReservationCatalogCodeCreateRequest, +): Promise { + return sendJson('/api/admin/reservation/catalogs/rate-codes', 'POST', request) +} + +export async function updateAdminReservationCatalogRateCodeStatus( + catalogId: string, + request: AdminReservationCatalogStatusUpdateRequest, +): Promise { + return sendJson( + `/api/admin/reservation/catalogs/rate-codes/${encodeURIComponent(catalogId)}/status`, + 'PUT', + request, + ) +} + export async function createAdminHotel(request: AdminHotelCreateRequest): Promise { return sendJson('/api/admin/hotels', 'POST', request) } diff --git a/client/src/tests/reservationRouter.spec.ts b/client/src/tests/reservationRouter.spec.ts index 4f86b58..19eb50a 100644 --- a/client/src/tests/reservationRouter.spec.ts +++ b/client/src/tests/reservationRouter.spec.ts @@ -60,6 +60,7 @@ describe('reservation router', () => { expect(router.resolve('/system/roles').name).toBe('system-roles') expect(router.resolve('/system/menus').name).toBe('system-menus') expect(router.resolve('/system/hotels').name).toBe('system-hotels') + expect(router.resolve('/system/reservation-catalogs').name).toBe('system-reservation-catalogs') expect(router.resolve('/system/audits').name).toBe('system-audits') expect(router.resolve('/system/not-ready').name).toBe('not-found') }) @@ -81,6 +82,7 @@ describe('reservation router', () => { expect(router.resolve('/system/roles').meta.permission).toBe('SYSTEM_ROLE_MANAGE') expect(router.resolve('/system/menus').meta.permission).toBe('SYSTEM_MENU_MANAGE') expect(router.resolve('/system/hotels').meta.permission).toBe('HOTEL_MANAGE') + expect(router.resolve('/system/reservation-catalogs').meta.permission).toBe('RESERVATION_CATALOG_MANAGE') expect(router.resolve('/system/audits').meta.permission).toBe('SYSTEM_ADMIN_CONSOLE_ACCESS') }) @@ -152,6 +154,19 @@ describe('reservation router', () => { ) }) + it('redirects the System Admin entry to catalog management when it is the first allowed admin page', async () => { + const guard = createAuthGuard(() => + createAuthStoreForGuard({ + hasPermission: (permission: string) => + permission === 'SYSTEM_ADMIN_CONSOLE_ACCESS' || permission === 'RESERVATION_CATALOG_MANAGE', + }), + ) + + await expect(guard(createRoute('/system', { permission: 'SYSTEM_ADMIN_CONSOLE_ACCESS' }))).resolves.toBe( + '/system/reservation-catalogs', + ) + }) + it('redirects the root route to the default page when the backend menu points to root', async () => { const guard = createAuthGuard(() => createAuthStoreForGuard({ diff --git a/client/src/tests/systemAdminService.spec.ts b/client/src/tests/systemAdminService.spec.ts index a0381e4..0c42440 100644 --- a/client/src/tests/systemAdminService.spec.ts +++ b/client/src/tests/systemAdminService.spec.ts @@ -7,12 +7,18 @@ import { createAdminHotel, createAdminMenu, createAdminRole, + createAdminReservationCatalogAccount, + createAdminReservationCatalogRateCode, + createAdminReservationCatalogRoomType, createAdminUser, fetchAdminAudits, fetchAdminHotels, fetchAdminMenuTree, fetchAdminMenus, fetchAdminPermissions, + fetchAdminReservationCatalogAccounts, + fetchAdminReservationCatalogRateCodes, + fetchAdminReservationCatalogRoomTypes, fetchAdminRoleDetail, fetchAdminRoles, fetchAdminUserDetail, @@ -22,6 +28,9 @@ import { updateAdminHotelStatus, updateAdminMenu, updateAdminMenuTreeOrder, + updateAdminReservationCatalogAccountStatus, + updateAdminReservationCatalogRateCodeStatus, + updateAdminReservationCatalogRoomTypeStatus, updateAdminRole, updateAdminUser, } from '@/services/systemAdminService' @@ -300,4 +309,109 @@ describe('systemAdminService', () => { expect.objectContaining({ method: 'GET' }), ) }) + + it('fetches Reservation V4 catalog management lists with supported filters', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ items: [], page: {} })) + + await fetchAdminReservationCatalogAccounts({ + hotel_id: 'HOTEL-TEST', + keyword: 'qbd', + status: 'ACTIVE', + page_num: 2, + page_size: 10, + }) + await fetchAdminReservationCatalogRoomTypes({ hotel_id: 'HOTEL-TEST', status: 'DISABLED' }) + await fetchAdminReservationCatalogRateCodes({ keyword: 'group' }) + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/admin/reservation/catalogs/accounts?hotel_id=HOTEL-TEST&keyword=qbd&status=ACTIVE&page_num=2&page_size=10', + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/admin/reservation/catalogs/room-types?hotel_id=HOTEL-TEST&status=DISABLED', + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/admin/reservation/catalogs/rate-codes?keyword=group', + expect.objectContaining({ method: 'GET' }), + ) + }) + + it('sends Reservation V4 catalog create and status operations to admin endpoints', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse({ id: '1' })) + + await createAdminReservationCatalogAccount({ + hotel_id: 'HOTEL-TEST', + account_code: 'ACME', + account_name: 'Acme Travel', + market_code: 'LEISURE', + source_code: 'TRAVEL_AGENT', + }) + await updateAdminReservationCatalogAccountStatus('10001', { status: 'DISABLED' }) + await createAdminReservationCatalogRoomType({ + hotel_id: 'HOTEL-TEST', + code: 'FAM', + display_name: 'Family Room', + sort_order: 300, + }) + await updateAdminReservationCatalogRoomTypeStatus('20001', { status: 'ACTIVE' }) + await createAdminReservationCatalogRateCode({ + hotel_id: 'HOTEL-TEST', + code: 'CORP', + display_name: 'Corporate', + }) + await updateAdminReservationCatalogRateCodeStatus('30001', { status: 'DISABLED' }) + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/admin/reservation/catalogs/accounts', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + hotel_id: 'HOTEL-TEST', + account_code: 'ACME', + account_name: 'Acme Travel', + market_code: 'LEISURE', + source_code: 'TRAVEL_AGENT', + }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/admin/reservation/catalogs/accounts/10001/status', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ status: 'DISABLED' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/admin/reservation/catalogs/room-types', + expect.objectContaining({ method: 'POST' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 4, + '/api/admin/reservation/catalogs/room-types/20001/status', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ status: 'ACTIVE' }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 5, + '/api/admin/reservation/catalogs/rate-codes', + expect.objectContaining({ method: 'POST' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 6, + '/api/admin/reservation/catalogs/rate-codes/30001/status', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ status: 'DISABLED' }), + }), + ) + }) }) diff --git a/client/src/tests/systemReservationCatalogsView.spec.ts b/client/src/tests/systemReservationCatalogsView.spec.ts new file mode 100644 index 0000000..6028dfc --- /dev/null +++ b/client/src/tests/systemReservationCatalogsView.spec.ts @@ -0,0 +1,274 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import zhCN from '@/i18n/locales/zh-CN' +import { useAuthStore } from '@/stores/authStore' +import SystemReservationCatalogsView from '@/views/system/SystemReservationCatalogsView.vue' + +vi.mock('@/services/systemAdminService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createAdminReservationCatalogAccount: vi.fn(), + createAdminReservationCatalogRateCode: vi.fn(), + createAdminReservationCatalogRoomType: vi.fn(), + fetchAdminReservationCatalogAccounts: vi.fn(), + fetchAdminReservationCatalogRateCodes: vi.fn(), + fetchAdminReservationCatalogRoomTypes: vi.fn(), + updateAdminReservationCatalogAccountStatus: vi.fn(), + updateAdminReservationCatalogRateCodeStatus: vi.fn(), + updateAdminReservationCatalogRoomTypeStatus: vi.fn(), + } +}) + +const systemAdminService = await import('@/services/systemAdminService') + +describe('SystemReservationCatalogsView', () => { + beforeEach(() => { + vi.mocked(systemAdminService.createAdminReservationCatalogAccount).mockReset() + vi.mocked(systemAdminService.createAdminReservationCatalogRateCode).mockReset() + vi.mocked(systemAdminService.createAdminReservationCatalogRoomType).mockReset() + vi.mocked(systemAdminService.fetchAdminReservationCatalogAccounts).mockReset() + vi.mocked(systemAdminService.fetchAdminReservationCatalogRateCodes).mockReset() + vi.mocked(systemAdminService.fetchAdminReservationCatalogRoomTypes).mockReset() + vi.mocked(systemAdminService.updateAdminReservationCatalogAccountStatus).mockReset() + vi.mocked(systemAdminService.updateAdminReservationCatalogRateCodeStatus).mockReset() + vi.mocked(systemAdminService.updateAdminReservationCatalogRoomTypeStatus).mockReset() + vi.mocked(systemAdminService.fetchAdminReservationCatalogAccounts).mockResolvedValue({ + items: [ + { + id: '10001', + hotel_id: 'HOTEL-TEST', + account_code: 'QBD_TRAVEL', + account_name: 'Q.B.D. Travel', + market_code: 'LEISURE', + source_code: 'TRAVEL_AGENT', + status: 'ACTIVE', + catalog_source: 'SYSTEM_MANAGED', + external_account_id: null, + catalog_version: 'catalog-v1', + metadata_json: null, + version: 1, + created_at: '2026-07-19T02:00:00Z', + updated_at: '2026-07-19T02:00:00Z', + }, + ], + page: { + page_num: 1, + page_size: 20, + total: 1, + }, + }) + vi.mocked(systemAdminService.fetchAdminReservationCatalogRoomTypes).mockResolvedValue({ + items: [ + { + id: '20001', + hotel_id: 'HOTEL-TEST', + catalog_type: 'ROOM_TYPE', + code: 'TWN', + display_name: 'Twin', + status: 'ACTIVE', + catalog_source: 'SYSTEM_MANAGED', + external_id: null, + sort_order: 100, + catalog_version: 'catalog-v1', + metadata_json: null, + version: 1, + created_at: '2026-07-19T02:00:00Z', + updated_at: '2026-07-19T02:00:00Z', + }, + ], + page: { + page_num: 1, + page_size: 20, + total: 1, + }, + }) + vi.mocked(systemAdminService.fetchAdminReservationCatalogRateCodes).mockResolvedValue({ + items: [], + page: { + page_num: 1, + page_size: 20, + total: 0, + }, + }) + }) + + it('loads Account catalog for the selected hotel and creates with fixed Market and Source defaults', async () => { + vi.mocked(systemAdminService.createAdminReservationCatalogAccount).mockResolvedValue({ + id: '10002', + hotel_id: 'HOTEL-TEST', + account_code: 'ACME', + account_name: 'Acme Travel', + market_code: 'LEISURE', + source_code: 'TRAVEL_AGENT', + status: 'ACTIVE', + catalog_source: 'SYSTEM_MANAGED', + external_account_id: null, + catalog_version: 'catalog-v1', + metadata_json: null, + version: 1, + created_at: '2026-07-19T02:00:00Z', + updated_at: '2026-07-19T02:00:00Z', + }) + + const wrapper = mountCatalogView() + await flushPromises() + + expect(wrapper.text()).toContain('Reservation V4 目录管理') + expect(wrapper.text()).toContain('QBD_TRAVEL') + expect(systemAdminService.fetchAdminReservationCatalogAccounts).toHaveBeenCalledWith({ + hotel_id: 'HOTEL-TEST', + keyword: '', + status: '', + page_num: 1, + page_size: 20, + }) + + await wrapper.find('[data-testid="catalog-account-code"]').setValue('ACME') + await wrapper.find('[data-testid="catalog-account-name"]').setValue('Acme Travel') + await wrapper.find('[data-testid="catalog-create-form"]').trigger('submit') + await flushPromises() + + expect(systemAdminService.createAdminReservationCatalogAccount).toHaveBeenCalledWith({ + hotel_id: 'HOTEL-TEST', + account_code: 'ACME', + account_name: 'Acme Travel', + market_code: 'LEISURE', + source_code: 'TRAVEL_AGENT', + external_account_id: undefined, + catalog_version: undefined, + metadata_json: undefined, + }) + expect(wrapper.text()).toContain('新增后默认启用') + }) + + it('switches to Room Type catalog and toggles status by catalog id', async () => { + vi.mocked(systemAdminService.updateAdminReservationCatalogRoomTypeStatus).mockResolvedValue({ + id: '20001', + hotel_id: 'HOTEL-TEST', + catalog_type: 'ROOM_TYPE', + code: 'TWN', + display_name: 'Twin', + status: 'DISABLED', + catalog_source: 'SYSTEM_MANAGED', + external_id: null, + sort_order: 100, + catalog_version: 'catalog-v1', + metadata_json: null, + version: 2, + created_at: '2026-07-19T02:00:00Z', + updated_at: '2026-07-19T03:00:00Z', + }) + + const wrapper = mountCatalogView() + await flushPromises() + + await wrapper.find('[data-testid="catalog-tab-room-types"]').trigger('click') + await flushPromises() + + expect(systemAdminService.fetchAdminReservationCatalogRoomTypes).toHaveBeenCalledWith({ + hotel_id: 'HOTEL-TEST', + keyword: '', + status: '', + page_num: 1, + page_size: 20, + }) + expect(wrapper.text()).toContain('TWN') + + await wrapper.find('[data-testid="catalog-status-20001"]').trigger('click') + await flushPromises() + + expect(systemAdminService.updateAdminReservationCatalogRoomTypeStatus).toHaveBeenCalledWith('20001', { + status: 'DISABLED', + }) + }) + + it('switches to Rate Code catalog and creates through the dedicated endpoint', async () => { + vi.mocked(systemAdminService.createAdminReservationCatalogRateCode).mockResolvedValue({ + id: '30001', + hotel_id: 'HOTEL-TEST', + catalog_type: 'RATE_CODE', + code: 'CORP', + display_name: 'Corporate', + status: 'ACTIVE', + catalog_source: 'SYSTEM_MANAGED', + external_id: null, + sort_order: 100, + catalog_version: 'catalog-v1', + metadata_json: null, + version: 1, + created_at: '2026-07-19T02:00:00Z', + updated_at: '2026-07-19T02:00:00Z', + }) + + const wrapper = mountCatalogView() + await flushPromises() + + await wrapper.find('[data-testid="catalog-tab-rate-codes"]').trigger('click') + await flushPromises() + await wrapper.find('[data-testid="catalog-code"]').setValue('CORP') + await wrapper.find('[data-testid="catalog-display-name"]').setValue('Corporate') + await wrapper.find('[data-testid="catalog-create-form"]').trigger('submit') + await flushPromises() + + expect(systemAdminService.fetchAdminReservationCatalogRateCodes).toHaveBeenCalledWith({ + hotel_id: 'HOTEL-TEST', + keyword: '', + status: '', + page_num: 1, + page_size: 20, + }) + expect(systemAdminService.createAdminReservationCatalogRateCode).toHaveBeenCalledWith({ + hotel_id: 'HOTEL-TEST', + code: 'CORP', + display_name: 'Corporate', + external_id: undefined, + sort_order: 100, + catalog_version: undefined, + metadata_json: undefined, + }) + }) +}) + +function mountCatalogView() { + const pinia = createPinia() + setActivePinia(pinia) + const authStore = useAuthStore() + authStore.applyLoginResult({ + access_token: 'test-token', + token_type: 'Bearer', + expires_at: '2026-07-19T12:00:00Z', + user: { + id: 'u1', + username: 'admin', + display_name: '系统管理员', + super_admin: false, + }, + default_hotel_id: 'HOTEL-TEST', + hotels: [ + { + hotel_id: 'HOTEL-TEST', + hotel_name: '测试酒店', + time_zone: 'Asia/Bangkok', + default_hotel: true, + }, + ], + permissions: ['SYSTEM_ADMIN_CONSOLE_ACCESS', 'RESERVATION_CATALOG_MANAGE'], + menus: [], + }) + const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + messages: { + 'zh-CN': zhCN, + }, + }) + return mount(SystemReservationCatalogsView, { + global: { + plugins: [pinia, i18n], + }, + }) +} diff --git a/client/src/types/auth.ts b/client/src/types/auth.ts index b9fa027..d1e8fa1 100644 --- a/client/src/types/auth.ts +++ b/client/src/types/auth.ts @@ -9,6 +9,7 @@ export type AuthPermissionCode = | 'RESERVATION_AUDIT_READ' | 'RESERVATION_INVOICE_GENERATE' | 'RESERVATION_ROOMING_LIST_GENERATE' + | 'RESERVATION_CATALOG_MANAGE' | 'HOTEL_SWITCH' | 'SYSTEM_AUTH_READ' | 'SYSTEM_ADMIN_CONSOLE_ACCESS' diff --git a/client/src/types/systemAdmin.ts b/client/src/types/systemAdmin.ts index 3fc7cbd..5eaad48 100644 --- a/client/src/types/systemAdmin.ts +++ b/client/src/types/systemAdmin.ts @@ -31,6 +31,11 @@ export interface AdminHotelListFilters extends SystemAdminPageFilters { hotel_status?: string } +export interface AdminReservationCatalogListFilters extends SystemAdminPageFilters { + hotel_id?: string + status?: string +} + export interface AdminUserRoleResult { id: string role_code: string @@ -128,6 +133,40 @@ export interface AdminHotelResult { updated_at: string | null } +export interface AdminReservationCatalogAccountResult { + id: string + hotel_id: string + account_code: string + account_name: string + market_code: string + source_code: string + status: string + catalog_source: string + external_account_id: string | null + catalog_version: string | null + metadata_json: string | null + version: number | null + created_at: string | null + updated_at: string | null +} + +export interface AdminReservationCatalogCodeResult { + id: string + hotel_id: string + catalog_type: string + code: string + display_name: string + status: string + catalog_source: string + external_id: string | null + sort_order: number | null + catalog_version: string | null + metadata_json: string | null + version: number | null + created_at: string | null + updated_at: string | null +} + export interface AdminUserCreateRequest { username: string initial_password: string @@ -235,6 +274,31 @@ export interface AdminHotelStatusUpdateRequest { hotel_status: string } +export interface AdminReservationCatalogAccountCreateRequest { + hotel_id: string + account_code: string + account_name: string + market_code: string + source_code: string + external_account_id?: string + catalog_version?: string + metadata_json?: string +} + +export interface AdminReservationCatalogCodeCreateRequest { + hotel_id: string + code: string + display_name: string + external_id?: string + sort_order?: number + catalog_version?: string + metadata_json?: string +} + +export interface AdminReservationCatalogStatusUpdateRequest { + status: string +} + export interface AdminAuditLogListFilters extends SystemAdminPageFilters { target_type?: string target_id?: string diff --git a/client/src/views/system/SystemAdminLayoutView.vue b/client/src/views/system/SystemAdminLayoutView.vue index 7844d87..12f703e 100644 --- a/client/src/views/system/SystemAdminLayoutView.vue +++ b/client/src/views/system/SystemAdminLayoutView.vue @@ -70,6 +70,12 @@ const allTabs: Array<{ icon: 'pi pi-building', permission: 'HOTEL_MANAGE', }, + { + path: '/system/reservation-catalogs', + labelKey: 'nav.systemReservationCatalogs', + icon: 'pi pi-list-check', + permission: 'RESERVATION_CATALOG_MANAGE', + }, { path: '/system/audits', labelKey: 'nav.systemAudits', diff --git a/client/src/views/system/SystemReservationCatalogsView.vue b/client/src/views/system/SystemReservationCatalogsView.vue new file mode 100644 index 0000000..5e86d95 --- /dev/null +++ b/client/src/views/system/SystemReservationCatalogsView.vue @@ -0,0 +1,808 @@ + + + + + diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 3614b37..abe9408 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -45,7 +45,7 @@ | SuperAgent 查询上下文接口 1、2 | `docs/project/requirements/M002-ai-query-minimal-fields.md` | 阶段记录,用于理解接口 1、2 的最小字段实现;如与总契约冲突,以总契约为准。 | | 订单任务主流程 V3 | `docs/project/requirements/M002-order-task-workflow-v3.md` | 当前开发基线,基于 0711 P0 冻结基线和 0712 P0.1 Parent Group 修订,覆盖 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed。 | | 任务卡字段控件契约 V1 | `docs/project/requirements/M002-task-field-control-contract-v1.md` | 后端已返回 `fields[]` 控件元数据,规定人工复核控件复用和前后端边界;前端待接入。 | -| 订单任务多卡模型 V4 | `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前 V4 后端主入口;后端已开放工作台统一列表、V4 订单任务列表 / 详情、卡片确认、复核解阻、S10/S99 来源通知详情和 ack,前端页面接入仍按后续 checkpoint 跟进。 | +| 订单任务多卡模型 V4 | `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前 V4 主入口;后端已开放工作台统一列表、V4 订单任务列表 / 详情、卡片确认、复核解阻、S10/S99 来源通知详情和 ack;前端已完成 V4 页面第一版、目录 lookup 接入、订单详情 V4 时间线消费和系统设置目录管理 CP1。 | | Manual Invoice 手工开票生成 | `docs/project/requirements/M009-manual-invoice-generation-v1.md` | 当前有效;后端 CP2 已支持无订单 / 无任务手工填写字段、填 Excel 模板、转 PDF、OSS 输出和生成记录。 | | Rooming List Excel 生成 | `docs/project/requirements/M010-rooming-list-excel-generation-v1.md` | 当前有效;后端 CP1 已支持前端上传来源名单并填写目标字段,同步生成 `.xlsx` 直接下载;前端 V1 已新增 `/reservation/rooming-lists/new`,按 Blob 下载处理,不落库、不上传 OSS。 | | 订单任务主流程 V2 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 已实现阶段记录,保留用于理解当前代码中的 S000/S999、订单任务流转和 OPERA 模拟骨架。 | diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 6f4e05e..c10de32 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -67,6 +67,9 @@ | `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 不再返回;如果提交的状态和当前状态一致,后端幂等返回当前记录,不新增管理审计。 | + +前端已在系统设置下新增 `/system/reservation-catalogs` 消费上述目录管理接口。页面入口要求 `RESERVATION_CATALOG_MANAGE`,列表过滤直接传 `hotel_id`、`keyword`、`status`、`page_num`、`page_size`;Account 新增第一版固定提交 `market_code=LEISURE`、`source_code=TRAVEL_AGENT`;状态重复提交按成功提示处理,不额外弹失败。 + | `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 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 | 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 64bafe8..61d407a 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 @@ -499,7 +499,7 @@ 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 | CP1 已完成 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口、`RESERVATION_CATALOG_MANAGE` 权限和管理审计;前端页面、Market / Source 独立管理后置 | +| 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,必要时新增机器目录接口或导出包 | @@ -507,7 +507,7 @@ CP11 已作为后端第一步落地,因为它不依赖真实 PMS,也能让 ## 15. 仍需确认的问题 -1. Account 的第一版真实维护入口后端已放在系统管理后台接口;前端菜单和页面布局仍需后续确认。 +1. Account 的第一版真实维护入口已放在系统设置 `/system/reservation-catalogs`;前端第一版固定使用 `market_code=LEISURE`、`source_code=TRAVEL_AGENT`,Market / Source 独立管理仍需后续确认。 2. Account code 是否继续使用本系统定义的稳定 code,例如 `QBD_TRAVEL`,还是必须对齐 PMS profile code。 3. Market / Source 是否只允许随 Account 派生,还是未来允许用户在 Basic Information 中单独改选。 4. Room Type 第一版后端已支持系统管理维护;后续是否仍要接 PMS / OHIP 同步替换为主来源待确认。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 43bfb33..fe2a16b 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -85,7 +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 或外部同步错误详情 | +| `/api/admin/reservation/catalogs/**` | `FRONTEND_ADMIN` | 已实现目录管理后台 CP1;前端入口为 `/system/reservation-catalogs`;强制登录、`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 调试和系统接口 diff --git a/docs/superpowers/plans/2026-07-19-m002-v4-catalog-admin-frontend-cp1.md b/docs/superpowers/plans/2026-07-19-m002-v4-catalog-admin-frontend-cp1.md new file mode 100644 index 0000000..3aed988 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-m002-v4-catalog-admin-frontend-cp1.md @@ -0,0 +1,126 @@ +# M002 V4 Catalog Admin Frontend CP1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the Reservation V4 catalog management frontend page under System Settings for Account, Room Type, and Rate Code catalogs. + +**Architecture:** Extend the existing system admin module with typed admin catalog service functions, a guarded `/system/reservation-catalogs` route, and a single focused Vue page that switches between the three catalog types. The page follows existing system admin list/form/pagination patterns and never bypasses backend permission, hotel, or catalog validation. + +**Tech Stack:** Vue 3.5, TypeScript, Vue Router, Pinia auth store, Vue I18n, Vitest, existing `httpClient` and system admin CSS. + +## Global Constraints + +- API calls must stay in `client/src/services/systemAdminService.ts`. +- Types must stay in `client/src/types/systemAdmin.ts`. +- Route and tab entry require `RESERVATION_CATALOG_MANAGE`. +- Account create uses default `market_code=LEISURE` and `source_code=TRAVEL_AGENT`. +- List filters are `hotel_id`, `keyword`, `status`, `page_num`, `page_size`. +- No backend changes, no PMS / OPERA / OHIP integration, no V4 task card behavior changes. +- Do not submit secrets, demo keys, build artifacts, IDE files, or unrelated working tree changes. + +--- + +### Task 1: Service And Route Contract + +**Files:** +- Modify: `client/src/types/auth.ts` +- Modify: `client/src/types/systemAdmin.ts` +- Modify: `client/src/services/systemAdminService.ts` +- Modify: `client/src/router/index.ts` +- Test: `client/src/tests/systemAdminService.spec.ts` +- Test: `client/src/tests/reservationRouter.spec.ts` + +**Interfaces:** +- Produces: `fetchAdminReservationCatalogAccounts`, `createAdminReservationCatalogAccount`, `updateAdminReservationCatalogAccountStatus`. +- Produces: `fetchAdminReservationCatalogRoomTypes`, `createAdminReservationCatalogRoomType`, `updateAdminReservationCatalogRoomTypeStatus`. +- Produces: `fetchAdminReservationCatalogRateCodes`, `createAdminReservationCatalogRateCode`, `updateAdminReservationCatalogRateCodeStatus`. + +- [x] **Step 1: Write failing service tests** + +```ts +await fetchAdminReservationCatalogAccounts({ hotel_id: 'HOTEL-TEST', keyword: 'qbd', status: 'ACTIVE', page_num: 2, page_size: 10 }) +await createAdminReservationCatalogAccount({ hotel_id: 'HOTEL-TEST', account_code: 'ACME', account_name: 'Acme', market_code: 'LEISURE', source_code: 'TRAVEL_AGENT' }) +await updateAdminReservationCatalogAccountStatus('10001', { status: 'DISABLED' }) +``` + +- [x] **Step 2: Write failing router tests** + +```ts +expect(router.resolve('/system/reservation-catalogs').name).toBe('system-reservation-catalogs') +expect(router.resolve('/system/reservation-catalogs').meta.permission).toBe('RESERVATION_CATALOG_MANAGE') +``` + +- [x] **Step 3: Implement types, services, and route** + +Add the catalog request/response interfaces and route metadata, using only backend paths documented in `M002-v4-real-catalog-lookup-api-design.md`. + +- [x] **Step 4: Verify task tests** + +Run: `CI=true pnpm --dir client test -- systemAdminService.spec.ts reservationRouter.spec.ts` + +--- + +### Task 2: Catalog Management Page + +**Files:** +- Create: `client/src/views/system/SystemReservationCatalogsView.vue` +- Modify: `client/src/views/system/SystemAdminLayoutView.vue` +- Modify: `client/src/i18n/locales/zh-CN.ts` +- Modify: `client/src/i18n/locales/en-US.ts` +- Modify: `client/src/i18n/locales/th-TH.ts` +- Test: `client/src/tests/systemReservationCatalogsView.spec.ts` + +**Interfaces:** +- Consumes service functions from Task 1. +- Produces a page that lists, creates, enables, and disables Account, Room Type, and Rate Code records. + +- [x] **Step 1: Write failing page tests** + +```ts +expect(wrapper.text()).toContain('Reservation V4 目录管理') +expect(fetchAdminReservationCatalogAccounts).toHaveBeenCalledWith(expect.objectContaining({ hotel_id: 'HOTEL-TEST' })) +await wrapper.find('form').trigger('submit.prevent') +expect(createAdminReservationCatalogAccount).toHaveBeenCalledWith(expect.objectContaining({ market_code: 'LEISURE', source_code: 'TRAVEL_AGENT' })) +``` + +- [x] **Step 2: Implement page UI** + +Use existing system admin sections: filters, create form, catalog table, pagination, and row status action buttons. + +- [x] **Step 3: Add i18n keys** + +Add `nav.systemReservationCatalogs` and `systemAdmin.catalogs.*` to zh-CN, en-US, and th-TH. + +- [x] **Step 4: Verify page tests** + +Run: `CI=true pnpm --dir client test -- systemReservationCatalogsView.spec.ts` + +--- + +### Task 3: Verification, Review, And Commit + +**Files:** +- Review all changed frontend files. +- Commit only files related to this frontend CP1. + +- [ ] **Step 1: Run checks** + +Run: + +```bash +CI=true pnpm --dir client lint +CI=true pnpm --dir client typecheck +CI=true pnpm --dir client test +CI=true pnpm --dir client build +``` + +- [ ] **Step 2: Code review** + +Review for endpoint paths, permission gates, hotel filter behavior, idempotent status actions, stale/disabled catalog explanation, and accidental staging of unrelated files. + +- [ ] **Step 3: Commit** + +```bash +git add +git commit -m "实现V4目录管理后台前端" +```