From 0715e8334f14c8982ff5d2bc250e708b5f046256 Mon Sep 17 00:00:00 2001 From: andy Date: Sat, 18 Jul 2026 12:58:01 +0700 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=88=BF=E8=A1=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 4 +- client/src/i18n/locales/en-US.ts | 55 ++ client/src/i18n/locales/th-TH.ts | 55 ++ client/src/i18n/locales/zh-CN.ts | 55 ++ client/src/layouts/ReservationAppShell.vue | 2 + client/src/router/index.ts | 9 + client/src/services/roomingListService.ts | 165 ++++ client/src/tests/reservationAppShell.spec.ts | 24 + client/src/tests/reservationRouter.spec.ts | 4 + client/src/tests/roomingListService.spec.ts | 165 ++++ client/src/tests/roomingListView.spec.ts | 252 +++++ client/src/types/auth.ts | 1 + client/src/types/roomingList.ts | 39 + .../ReservationRoomingListGenerationView.vue | 862 ++++++++++++++++++ docs/project/frontend-backend/README.md | 4 +- .../backend-to-frontend-notes.md | 3 +- 16 files changed, 1694 insertions(+), 5 deletions(-) create mode 100644 client/src/services/roomingListService.ts create mode 100644 client/src/tests/roomingListService.spec.ts create mode 100644 client/src/tests/roomingListView.spec.ts create mode 100644 client/src/types/roomingList.ts create mode 100644 client/src/views/reservation/ReservationRoomingListGenerationView.vue diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 2aade8d..9416816 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -33,14 +33,14 @@ - 现有历史文档暂不按 AI-NSES 目录大搬迁,先通过索引和采用说明建立映射关系。 - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 -- M010 Rooming List Excel 生成后端 CP1 已实现:前端上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 +- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 ## 5. Next Steps - Review 本次 AI-NSES 文档入口改动。 - 确认通过后,可按后续具体需求继续进入业务开发。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 -- M010 后续可进入前端 checkpoint:新增 Rooming List 页面,上传来源名单、填写每房人数和目标列字段,并按 Blob 下载 `.xlsx`;如需预览、历史记录或 OSS 下载再单独开后续后端 checkpoint。 +- M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 ## 6. 文档同步提醒 diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index d8e8d49..6481f65 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -27,6 +27,7 @@ export default { orders: 'Order list', taskQueue: 'Task queue', manualInvoice: 'Manual invoice', + roomingList: 'Rooming list', sourceMessages: 'Source messages', systemStatus: 'System status', orderDetail: 'Order detail', @@ -347,6 +348,60 @@ export default { chargePositive: 'Quantity, rate, and nights must be greater than 0.', }, }, + roomingList: { + eyebrow: 'Reservation tool', + title: 'Generate Rooming List Excel', + subtitle: 'Upload the travel group list, enter rooming defaults, and download the generated hotel import workbook.', + currentHotel: 'Current hotel', + sourceSection: 'Source list and required fields', + targetSection: 'Target Excel defaults', + sourceFile: 'Source Excel file', + sourceFileHint: 'Supports .xls / .xlsx. The frontend does not read or log guest list content.', + noFile: 'Choose a .xls / .xlsx file', + peoplePerRoom: 'People per room', + arrival: 'Arrival', + departure: 'Departure', + roomType: 'Room type', + roomTypePlaceholder: 'e.g. DLX / TWN', + titleColumn: 'Title', + rateCode: 'Rate Code', + adults: 'Adults', + children: 'Children', + paymentType: 'Payment Type', + vip: 'VIP', + nationality: 'Nationality', + email: 'Email', + idType: 'ID Type', + idNumber: 'ID Number', + generate: 'Generate and download Excel', + generating: 'Generating', + validationTitle: 'Please fix the form', + resultSuccess: 'Generation result', + resultDownloaded: 'Excel has been generated and the browser download was triggered.', + downloadedFile: 'Downloaded file', + fieldErrors: { + fileRequired: 'Choose a source Excel file.', + fileType: 'Only .xls / .xlsx files are supported.', + positiveInteger: 'People per room must be greater than 0.', + nonNegativeInteger: 'Must be 0 or a positive integer.', + arrivalRequired: 'Enter arrival date.', + departureRequired: 'Enter departure date.', + dateFormat: 'Date format must be yyyy-MM-dd.', + departureAfterArrival: 'Departure must be later than arrival.', + roomTypeRequired: 'Enter room type.', + invalidEmail: 'Invalid email.', + }, + errors: { + FRONTEND_VALIDATION: 'Form validation failed. Check required fields.', + ROOMING_LIST_VALIDATION_FAILED: 'Rooming List field validation failed. Please check the form.', + ROOMING_LIST_SOURCE_FILE_INVALID: 'The source list is invalid. Check the Excel headers and file format.', + FRONTEND_PERMISSION_DENIED: 'Your account does not have Rooming List generation permission.', + HOTEL_ACCESS_DENIED: 'Your account cannot access the selected hotel.', + AUTH_TOKEN_REQUIRED: 'Please sign in before generating a Rooming List.', + AUTH_SESSION_INVALID: 'Your session has expired. Please sign in again.', + UNKNOWN: 'Rooming List generation failed. Please retry later.', + }, + }, order: { summary: 'Order summary', taskQueue: 'Order task queue', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 4321fa5..0af02aa 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -27,6 +27,7 @@ export default { orders: 'รายการออเดอร์', taskQueue: 'คิวงาน', manualInvoice: 'ออกใบแจ้งหนี้เอง', + roomingList: 'สร้าง Rooming List', sourceMessages: 'แหล่งที่มาข้อความ', systemStatus: 'สถานะระบบ', orderDetail: 'รายละเอียดออเดอร์', @@ -347,6 +348,60 @@ export default { chargePositive: 'Quantity, Rate และ Nights ต้องมากกว่า 0', }, }, + roomingList: { + eyebrow: 'เครื่องมือ Reservation', + title: 'สร้าง Rooming List Excel', + subtitle: 'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้องและค่าเริ่มต้น แล้วดาวน์โหลดไฟล์ Excel ที่สร้างแล้ว', + currentHotel: 'โรงแรมปัจจุบัน', + sourceSection: 'ไฟล์รายชื่อและข้อมูลจำเป็น', + targetSection: 'ค่าเริ่มต้นของ Excel ปลายทาง', + sourceFile: 'ไฟล์ Excel ต้นทาง', + sourceFileHint: 'รองรับ .xls / .xlsx และหน้าเว็บจะไม่อ่านหรือบันทึกข้อมูลรายชื่อแขก', + noFile: 'เลือกไฟล์ .xls / .xlsx', + peoplePerRoom: 'จำนวนคนต่อห้อง', + arrival: 'Arrival', + departure: 'Departure', + roomType: 'Room Type', + roomTypePlaceholder: 'เช่น DLX / TWN', + titleColumn: 'Title', + rateCode: 'Rate Code', + adults: 'Adults', + children: 'Children', + paymentType: 'Payment Type', + vip: 'VIP', + nationality: 'Nationality', + email: 'Email', + idType: 'ID Type', + idNumber: 'ID Number', + generate: 'สร้างและดาวน์โหลด Excel', + generating: 'กำลังสร้าง', + validationTitle: 'กรุณาแก้ไขแบบฟอร์ม', + resultSuccess: 'ผลการสร้าง', + resultDownloaded: 'สร้าง Excel แล้ว และเริ่มดาวน์โหลดในเบราว์เซอร์แล้ว', + downloadedFile: 'ไฟล์ที่ดาวน์โหลด', + fieldErrors: { + fileRequired: 'กรุณาเลือกไฟล์ Excel ต้นทาง', + fileType: 'รองรับเฉพาะไฟล์ .xls / .xlsx', + positiveInteger: 'จำนวนคนต่อห้องต้องมากกว่า 0', + nonNegativeInteger: 'ต้องเป็น 0 หรือจำนวนเต็มบวก', + arrivalRequired: 'กรุณากรอก Arrival', + departureRequired: 'กรุณากรอก Departure', + dateFormat: 'รูปแบบวันที่ต้องเป็น yyyy-MM-dd', + departureAfterArrival: 'Departure ต้องอยู่หลัง Arrival', + roomTypeRequired: 'กรุณากรอก Room Type', + invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง', + }, + errors: { + FRONTEND_VALIDATION: 'ตรวจสอบแบบฟอร์มไม่ผ่าน กรุณาตรวจฟิลด์ที่จำเป็น', + ROOMING_LIST_VALIDATION_FAILED: 'ตรวจสอบข้อมูล Rooming List ไม่ผ่าน กรุณาตรวจแบบฟอร์ม', + ROOMING_LIST_SOURCE_FILE_INVALID: 'ไฟล์รายชื่อต้นทางไม่ถูกต้อง กรุณาตรวจหัวตารางและรูปแบบไฟล์', + FRONTEND_PERMISSION_DENIED: 'บัญชีนี้ไม่มีสิทธิ์สร้าง Rooming List', + HOTEL_ACCESS_DENIED: 'บัญชีนี้ไม่มีสิทธิ์เข้าถึงโรงแรมที่เลือก', + AUTH_TOKEN_REQUIRED: 'กรุณาเข้าสู่ระบบก่อนสร้าง Rooming List', + AUTH_SESSION_INVALID: 'เซสชันหมดอายุ กรุณาเข้าสู่ระบบใหม่', + UNKNOWN: 'สร้าง Rooming List ไม่สำเร็จ กรุณาลองใหม่ภายหลัง', + }, + }, order: { summary: 'สรุปออเดอร์', taskQueue: 'คิวงานของออเดอร์นี้', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 53b9158..db6352c 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -27,6 +27,7 @@ export default { orders: '订单列表', taskQueue: '任务队列', manualInvoice: '手工开票', + roomingList: '房表生成', sourceMessages: '消息来源', systemStatus: '系统状态', orderDetail: '订单详情', @@ -347,6 +348,60 @@ export default { chargePositive: '费用明细的 Quantity、Rate、Nights 必须大于 0。', }, }, + roomingList: { + eyebrow: '预订工具', + title: '生成 Rooming List Excel', + subtitle: '上传旅行团名单 Excel,填写每房人数和目标列默认值,生成酒店可导入的 Rooming List。', + currentHotel: '当前酒店', + sourceSection: '来源名单与必填信息', + targetSection: '目标 Excel 默认值', + sourceFile: '来源 Excel 文件', + sourceFileHint: '支持 .xls / .xlsx;前端不会读取或记录名单内容。', + noFile: '选择 .xls / .xlsx 文件', + peoplePerRoom: '每间房人数', + arrival: '入住日期', + departure: '离店日期', + roomType: '房型', + roomTypePlaceholder: '例如 DLX / TWN', + titleColumn: '称谓(Title)', + rateCode: '价格代码(Rate Code)', + adults: '成人数(Adults)', + children: '儿童数(Children)', + paymentType: '付款方式(Payment Type)', + vip: 'VIP', + nationality: '国籍(Nationality)', + email: '邮箱(Email)', + idType: '证件类型(ID Type)', + idNumber: '证件号码(ID Number)', + generate: '生成并下载 Excel', + generating: '生成中', + validationTitle: '请先修正表单', + resultSuccess: '生成结果', + resultDownloaded: 'Excel 已生成,并已触发浏览器下载。', + downloadedFile: '下载文件', + fieldErrors: { + fileRequired: '请选择来源 Excel 文件。', + fileType: '仅支持 .xls / .xlsx 文件。', + positiveInteger: '每间房人数必须大于 0。', + nonNegativeInteger: '必须是 0 或正整数。', + arrivalRequired: '请填写入住日期。', + departureRequired: '请填写离店日期。', + dateFormat: '日期格式必须为 yyyy-MM-dd。', + departureAfterArrival: '离店日期必须晚于入住日期。', + roomTypeRequired: '请填写房型。', + invalidEmail: 'Email 格式不正确。', + }, + errors: { + FRONTEND_VALIDATION: '表单校验未通过,请检查必填字段。', + ROOMING_LIST_VALIDATION_FAILED: 'Rooming List 字段校验失败,请检查表单内容。', + ROOMING_LIST_SOURCE_FILE_INVALID: '来源名单文件不合法,请检查 Excel 表头和文件格式。', + FRONTEND_PERMISSION_DENIED: '当前账号没有生成 Rooming List 的权限。', + HOTEL_ACCESS_DENIED: '当前账号不能访问所选酒店。', + AUTH_TOKEN_REQUIRED: '请先登录后再生成 Rooming List。', + AUTH_SESSION_INVALID: '登录已失效,请重新登录后再生成。', + UNKNOWN: 'Rooming List 生成失败,请稍后重试。', + }, + }, order: { summary: '订单摘要', taskQueue: '同订单任务队列', diff --git a/client/src/layouts/ReservationAppShell.vue b/client/src/layouts/ReservationAppShell.vue index ee43583..fd1c3c9 100644 --- a/client/src/layouts/ReservationAppShell.vue +++ b/client/src/layouts/ReservationAppShell.vue @@ -175,6 +175,8 @@ const menuLabelKeys: Record = { RESERVATION_TASKS: 'nav.taskQueue', RESERVATION_MANUAL_INVOICE: 'nav.manualInvoice', RESERVATION_INVOICES: 'nav.manualInvoice', + RESERVATION_ROOMING_LIST: 'nav.roomingList', + RESERVATION_ROOMING_LISTS: 'nav.roomingList', DEBUG_EML_SUPERAGENT: 'nav.debugEml', SYSTEM_SETTINGS: 'nav.systemSettings', } diff --git a/client/src/router/index.ts b/client/src/router/index.ts index c0a4f64..c2d50dc 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -71,6 +71,15 @@ export const router = createRouter({ permission: 'RESERVATION_INVOICE_GENERATE', }, }, + { + path: '/reservation/rooming-lists/new', + name: 'reservation-rooming-list', + component: () => import('@/views/reservation/ReservationRoomingListGenerationView.vue'), + meta: { + titleKey: 'nav.roomingList', + permission: 'RESERVATION_ROOMING_LIST_GENERATE', + }, + }, { path: '/reservation/orders/:orderId', name: 'reservation-order-detail', diff --git a/client/src/services/roomingListService.ts b/client/src/services/roomingListService.ts new file mode 100644 index 0000000..6f158fc --- /dev/null +++ b/client/src/services/roomingListService.ts @@ -0,0 +1,165 @@ +import { getStoredAccessToken } from '@/services/authSession' +import { isAuthSessionInvalidPayload, notifyUnauthorized } from '@/services/httpClient' +import type { + RoomingListErrorResponse, + RoomingListGenerationDownload, + RoomingListGenerationErrorCode, + RoomingListGenerationInput, +} from '@/types/roomingList' + +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '' +const roomingListGenerationEndpoint = '/api/reservation/rooming-lists/generations' +const roomingListExcelContentType = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +const defaultRoomingListFileName = 'rooming-list.xlsx' + +export class RoomingListGenerationError extends Error { + readonly status: number + readonly errorCode: RoomingListGenerationErrorCode + readonly details: string[] + + constructor( + message: string, + status: number, + errorCode: RoomingListGenerationErrorCode, + details: string[], + ) { + super(message) + this.name = 'RoomingListGenerationError' + this.status = status + this.errorCode = errorCode + this.details = details + } +} + +export async function generateReservationRoomingListExcel( + input: RoomingListGenerationInput, +): Promise { + const response = await fetch(`${apiBaseUrl}${roomingListGenerationEndpoint}`, { + method: 'POST', + headers: buildRoomingListHeaders(), + body: buildRoomingListForm(input), + }) + + if (!response.ok) { + const payload = await readErrorPayload(response) + if (isAuthSessionInvalidPayload(payload) || response.status === 401) { + notifyUnauthorized() + } + throw normalizeGenerationError(response.status, payload) + } + + const blob = await response.blob() + return { + blob, + fileName: fileNameFromContentDisposition(response.headers.get('content-disposition')), + } +} + +function buildRoomingListHeaders(): Record { + const headers: Record = { + Accept: roomingListExcelContentType, + } + const token = getStoredAccessToken() + if (token) { + headers.Authorization = `Bearer ${token}` + } + return headers +} + +function buildRoomingListForm(input: RoomingListGenerationInput): FormData { + const form = new FormData() + form.append('file', input.file) + appendOptionalText(form, 'hotel_id', input.hotelId) + form.append('people_per_room', String(input.peoplePerRoom)) + form.append('arrival', input.arrival) + form.append('departure', input.departure) + form.append('room_type', input.roomType) + appendOptionalText(form, 'title', input.title) + appendOptionalText(form, 'rate_code', input.rateCode) + appendOptionalNumber(form, 'adults', input.adults) + appendOptionalNumber(form, 'children', input.children) + appendOptionalText(form, 'payment_type', input.paymentType) + appendOptionalText(form, 'vip', input.vip) + appendOptionalText(form, 'nationality', input.nationality) + appendOptionalText(form, 'email', input.email) + appendOptionalText(form, 'id_type', input.idType) + appendOptionalText(form, 'id_number', input.idNumber) + return form +} + +function appendOptionalText(form: FormData, key: string, value: string | null | undefined): void { + const normalized = value?.trim() + if (normalized) { + form.append(key, normalized) + } +} + +function appendOptionalNumber(form: FormData, key: string, value: number | null | undefined): void { + if (typeof value === 'number' && Number.isFinite(value)) { + form.append(key, String(value)) + } +} + +async function readErrorPayload(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + return { + message: await response.text(), + } + } + return response.json() +} + +function normalizeGenerationError(status: number, payload: unknown): RoomingListGenerationError { + const errorPayload = isRoomingListErrorResponse(payload) ? payload : {} + return new RoomingListGenerationError( + errorPayload.message || `Request failed with status ${status}`, + status, + errorPayload.error_code || 'UNKNOWN', + normalizeDetails(errorPayload.details), + ) +} + +function isRoomingListErrorResponse(payload: unknown): payload is RoomingListErrorResponse { + return typeof payload === 'object' && payload !== null && !Array.isArray(payload) +} + +function normalizeDetails(details: unknown): string[] { + if (!Array.isArray(details)) { + return [] + } + return details.filter((item): item is string => typeof item === 'string') +} + +export function fileNameFromContentDisposition(contentDisposition: string | null): string { + if (!contentDisposition) { + return defaultRoomingListFileName + } + const encodedFileName = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(contentDisposition)?.[1] + if (encodedFileName) { + return safeFileName(decodeFileName(encodedFileName)) + } + const quotedFileName = /filename="([^"]+)"/i.exec(contentDisposition)?.[1] + if (quotedFileName) { + return safeFileName(quotedFileName) + } + const plainFileName = /filename=([^;]+)/i.exec(contentDisposition)?.[1] + if (plainFileName) { + return safeFileName(plainFileName) + } + return defaultRoomingListFileName +} + +function decodeFileName(value: string): string { + try { + return decodeURIComponent(value.trim().replace(/^"|"$/g, '')) + } catch { + return value.trim().replace(/^"|"$/g, '') + } +} + +function safeFileName(value: string): string { + const normalized = value.trim().replace(/[/\\]/g, '_') + return normalized || defaultRoomingListFileName +} diff --git a/client/src/tests/reservationAppShell.spec.ts b/client/src/tests/reservationAppShell.spec.ts index e60e0ec..2666036 100644 --- a/client/src/tests/reservationAppShell.spec.ts +++ b/client/src/tests/reservationAppShell.spec.ts @@ -91,6 +91,18 @@ function createManualInvoiceMenu(): AuthMenuResult { } } +function createRoomingListMenu(): AuthMenuResult { + return { + menu_code: 'RESERVATION_ROOMING_LIST', + menu_name: 'Rooming List', + route_path: '/reservation/rooming-lists/new', + component_key: 'ReservationRoomingList', + icon_key: 'pi pi-file-excel', + permission_code: 'RESERVATION_ROOMING_LIST_GENERATE', + sort_order: 26, + } +} + function createSystemMenu(): AuthMenuResult { return { menu_code: 'SYSTEM_SETTINGS', @@ -137,6 +149,11 @@ async function mountShell(menus = [createReservationOrdersMenu(), createReservat component: { template: '
' }, meta: { titleKey: 'nav.manualInvoice' }, }, + { + path: '/reservation/rooming-lists/new', + component: { template: '
' }, + meta: { titleKey: 'nav.roomingList' }, + }, { path: '/system', component: { template: '
' }, @@ -199,6 +216,13 @@ describe('ReservationAppShell', () => { expect(wrapper.text()).toContain('手工开票') }) + it('shows Rooming List only when backend menus include it', async () => { + const wrapper = await mountShell([createReservationOrdersMenu(), createRoomingListMenu()]) + + expect(wrapper.find('a[href="/reservation/rooming-lists/new"]').exists()).toBe(true) + expect(wrapper.text()).toContain('房表生成') + }) + it('shows System Settings only when backend menus include it', async () => { const wrapper = await mountShell([createReservationOrdersMenu(), createSystemMenu()]) diff --git a/client/src/tests/reservationRouter.spec.ts b/client/src/tests/reservationRouter.spec.ts index 85b8d68..4f86b58 100644 --- a/client/src/tests/reservationRouter.spec.ts +++ b/client/src/tests/reservationRouter.spec.ts @@ -43,6 +43,7 @@ describe('reservation router', () => { expect(router.resolve('/reservation/orders').name).toBe('reservation-order-list') expect(router.resolve('/reservation/tasks').name).toBe('reservation-task-list') expect(router.resolve('/reservation/invoices/new').name).toBe('reservation-manual-invoice') + expect(router.resolve('/reservation/rooming-lists/new').name).toBe('reservation-rooming-list') expect(router.resolve('/reservation/orders/20001').name).toBe('reservation-order-detail') expect(router.resolve('/reservation/tasks/10001').name).toBe('reservation-task-detail') expect(router.resolve('/reservation/source-messages/30001/conversation').name).toBe( @@ -69,6 +70,9 @@ describe('reservation router', () => { expect(router.resolve('/reservation/tasks').meta.permission).toBe('RESERVATION_TASK_READ') expect(router.resolve('/reservation/tasks/10001').meta.permission).toBe('RESERVATION_TASK_READ') expect(router.resolve('/reservation/invoices/new').meta.permission).toBe('RESERVATION_INVOICE_GENERATE') + expect(router.resolve('/reservation/rooming-lists/new').meta.permission).toBe( + 'RESERVATION_ROOMING_LIST_GENERATE', + ) expect(router.resolve('/reservation/source-messages/30001/conversation').meta.permission).toBe( 'SOURCE_MESSAGE_ORIGINAL_READ', ) diff --git a/client/src/tests/roomingListService.spec.ts b/client/src/tests/roomingListService.spec.ts new file mode 100644 index 0000000..b8f618f --- /dev/null +++ b/client/src/tests/roomingListService.spec.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { clearStoredAccessToken, setStoredAccessToken } from '@/services/authSession' +import { + generateReservationRoomingListExcel, + RoomingListGenerationError, +} from '@/services/roomingListService' +import { setUnauthorizedHandler } from '@/services/httpClient' +import type { RoomingListGenerationInput } from '@/types/roomingList' + +function createInput(): RoomingListGenerationInput { + return { + file: new File(['passport names'], 'source.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + hotelId: 'HOTEL-TEST', + peoplePerRoom: 2, + arrival: '2026-07-20', + departure: '2026-07-22', + roomType: 'DLX', + title: 'MR', + rateCode: 'BAR', + adults: 2, + children: 0, + paymentType: 'BT', + vip: 'VIP1', + nationality: 'TH', + email: 'guest@example.test', + idType: 'Passport', + idNumber: 'A1234567', + } +} + +function createExcelResponse(headers: Record = {}): Response { + return new Response(new Blob(['xlsx-binary']), { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ...headers, + }, + }) +} + +function createJsonResponse(payload: unknown, status: number): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { + 'Content-Type': 'application/json', + }, + }) +} + +describe('roomingListService', () => { + beforeEach(() => { + vi.restoreAllMocks() + clearStoredAccessToken() + setUnauthorizedHandler(null) + }) + + it('uploads source Excel as FormData and returns the generated workbook blob', async () => { + const input = createInput() + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + createExcelResponse({ + 'Content-Disposition': 'attachment; filename="rooming-list-HOTEL-TEST-20260718.xlsx"', + }), + ) + setStoredAccessToken('session-token') + + const result = await generateReservationRoomingListExcel(input) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('/api/reservation/rooming-lists/generations') + expect(init).toMatchObject({ + method: 'POST', + headers: { + Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + Authorization: 'Bearer session-token', + }, + }) + expect(init?.headers).not.toHaveProperty('Content-Type') + expect(init?.body).toBeInstanceOf(FormData) + const formData = init?.body as FormData + expect(formData.get('file')).toBe(input.file) + expect(formData.get('hotel_id')).toBe('HOTEL-TEST') + expect(formData.get('people_per_room')).toBe('2') + expect(formData.get('arrival')).toBe('2026-07-20') + expect(formData.get('departure')).toBe('2026-07-22') + expect(formData.get('room_type')).toBe('DLX') + expect(formData.get('title')).toBe('MR') + expect(formData.get('rate_code')).toBe('BAR') + expect(formData.get('adults')).toBe('2') + expect(formData.get('children')).toBe('0') + expect(formData.get('payment_type')).toBe('BT') + expect(formData.get('vip')).toBe('VIP1') + expect(formData.get('nationality')).toBe('TH') + expect(formData.get('email')).toBe('guest@example.test') + expect(formData.get('id_type')).toBe('Passport') + expect(formData.get('id_number')).toBe('A1234567') + expect(result.fileName).toBe('rooming-list-HOTEL-TEST-20260718.xlsx') + expect(result.blob.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + }) + + it('uses the default file name when Content-Disposition does not provide one', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(createExcelResponse()) + + const result = await generateReservationRoomingListExcel({ + ...createInput(), + hotelId: undefined, + title: '', + rateCode: '', + adults: null, + children: null, + paymentType: '', + vip: '', + nationality: '', + email: '', + idType: '', + idNumber: '', + }) + + expect(result.fileName).toBe('rooming-list.xlsx') + }) + + it('throws a typed error with backend details for validation failures', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + createJsonResponse( + { + error_code: 'ROOMING_LIST_VALIDATION_FAILED', + message: 'Rooming List 字段校验失败。', + details: ['people_per_room: 必填字段缺失。', 'departure: 必须晚于 arrival。'], + }, + 400, + ), + ) + + await expect(generateReservationRoomingListExcel(createInput())).rejects.toMatchObject({ + status: 400, + errorCode: 'ROOMING_LIST_VALIDATION_FAILED', + message: 'Rooming List 字段校验失败。', + details: ['people_per_room: 必填字段缺失。', 'departure: 必须晚于 arrival。'], + } satisfies Partial) + }) + + it('runs the shared unauthorized handler for auth session failures', async () => { + const unauthorizedHandler = vi.fn() + setUnauthorizedHandler(unauthorizedHandler) + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + createJsonResponse( + { + error_code: 'AUTH_SESSION_INVALID', + message: '登录已失效,请重新登录。', + details: [], + }, + 401, + ), + ) + + await expect(generateReservationRoomingListExcel(createInput())).rejects.toMatchObject({ + status: 401, + errorCode: 'AUTH_SESSION_INVALID', + } satisfies Partial) + expect(unauthorizedHandler).toHaveBeenCalledOnce() + }) +}) diff --git a/client/src/tests/roomingListView.spec.ts b/client/src/tests/roomingListView.spec.ts new file mode 100644 index 0000000..c945252 --- /dev/null +++ b/client/src/tests/roomingListView.spec.ts @@ -0,0 +1,252 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import enUS from '@/i18n/locales/en-US' +import thTH from '@/i18n/locales/th-TH' +import zhCN from '@/i18n/locales/zh-CN' +import { + generateReservationRoomingListExcel, + RoomingListGenerationError, +} from '@/services/roomingListService' +import { useAuthStore } from '@/stores/authStore' +import ReservationRoomingListGenerationView from '@/views/reservation/ReservationRoomingListGenerationView.vue' + +vi.mock('@/services/roomingListService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateReservationRoomingListExcel: vi.fn(), + } +}) + +const nativeCreateObjectURL = URL.createObjectURL +const nativeRevokeObjectURL = URL.revokeObjectURL + +function mountView(options: { permissions?: string[] } = {}) { + const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + fallbackLocale: 'zh-CN', + messages: { + 'zh-CN': zhCN, + 'en-US': enUS, + 'th-TH': thTH, + }, + }) + const pinia = createPinia() + setActivePinia(pinia) + useAuthStore().applyLoginResult({ + access_token: 'session-token', + token_type: 'Bearer', + expires_at: '2026-07-18T00:00:00Z', + user: { + id: '1900000000000000001', + username: 'admin', + display_name: '系统管理员', + super_admin: true, + }, + default_hotel_id: 'HOTEL-TEST', + hotels: [ + { + hotel_id: 'HOTEL-TEST', + hotel_name: '测试酒店', + time_zone: 'Asia/Bangkok', + default_hotel: true, + }, + ], + permissions: options.permissions ?? ['RESERVATION_ROOMING_LIST_GENERATE'], + menus: [], + }) + + return mount(ReservationRoomingListGenerationView, { + global: { + plugins: [i18n, pinia], + }, + }) +} + +async function chooseFile(wrapper: ReturnType, file: File) { + const input = wrapper.find('[data-testid="rooming-list-file"]') + Object.defineProperty(input.element, 'files', { + configurable: true, + value: [file], + }) + await input.trigger('change') +} + +async function fillRequiredFields(wrapper: ReturnType) { + await chooseFile( + wrapper, + new File(['passport names'], 'source.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ) + await wrapper.find('[data-testid="rooming-list-people-per-room"]').setValue('2') + await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-07-20') + await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-22') + await wrapper.find('[data-testid="rooming-list-room-type"]').setValue('DLX') +} + +describe('ReservationRoomingListGenerationView', () => { + beforeEach(() => { + vi.mocked(generateReservationRoomingListExcel).mockReset() + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:rooming-list'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + if (nativeCreateObjectURL) { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: nativeCreateObjectURL, + }) + } else { + Reflect.deleteProperty(URL, 'createObjectURL') + } + if (nativeRevokeObjectURL) { + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: nativeRevokeObjectURL, + }) + } else { + Reflect.deleteProperty(URL, 'revokeObjectURL') + } + }) + + it('blocks submit until required upload and target fields are provided', async () => { + const wrapper = mountView() + + await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') + await flushPromises() + + expect(generateReservationRoomingListExcel).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('请选择来源 Excel 文件。') + expect(wrapper.text()).toContain('每间房人数必须大于 0。') + expect(wrapper.text()).toContain('请填写入住日期。') + expect(wrapper.text()).toContain('请填写离店日期。') + expect(wrapper.text()).toContain('请填写房型。') + }) + + it('blocks departure dates that are not later than arrival', async () => { + const wrapper = mountView() + await fillRequiredFields(wrapper) + await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-20') + + await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') + await flushPromises() + + expect(generateReservationRoomingListExcel).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('离店日期必须晚于入住日期。') + }) + + it('submits required and optional fields, then downloads the returned workbook', async () => { + vi.mocked(generateReservationRoomingListExcel).mockResolvedValue({ + blob: new Blob(['xlsx-binary'], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + fileName: 'rooming-list-HOTEL-TEST.xlsx', + }) + const wrapper = mountView() + await fillRequiredFields(wrapper) + await wrapper.find('[data-testid="rooming-list-title"]').setValue('MR') + await wrapper.find('[data-testid="rooming-list-rate-code"]').setValue('BAR') + await wrapper.find('[data-testid="rooming-list-adults"]').setValue('2') + await wrapper.find('[data-testid="rooming-list-children"]').setValue('0') + await wrapper.find('[data-testid="rooming-list-payment-type"]').setValue('BT') + await wrapper.find('[data-testid="rooming-list-vip"]').setValue('VIP1') + await wrapper.find('[data-testid="rooming-list-nationality"]').setValue('TH') + await wrapper.find('[data-testid="rooming-list-email"]').setValue('guest@example.test') + await wrapper.find('[data-testid="rooming-list-id-type"]').setValue('Passport') + await wrapper.find('[data-testid="rooming-list-id-number"]').setValue('A1234567') + + await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') + await flushPromises() + + expect(generateReservationRoomingListExcel).toHaveBeenCalledWith({ + file: expect.any(File), + hotelId: 'HOTEL-TEST', + peoplePerRoom: 2, + arrival: '2026-07-20', + departure: '2026-07-22', + roomType: 'DLX', + title: 'MR', + rateCode: 'BAR', + adults: 2, + children: 0, + paymentType: 'BT', + vip: 'VIP1', + nationality: 'TH', + email: 'guest@example.test', + idType: 'Passport', + idNumber: 'A1234567', + }) + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce() + expect(wrapper.text()).toContain('rooming-list-HOTEL-TEST.xlsx') + }) + + it('does not submit the same source file again while generation is pending', async () => { + let resolveGeneration!: (value: { blob: Blob; fileName: string }) => void + vi.mocked(generateReservationRoomingListExcel).mockImplementation( + () => + new Promise((resolve) => { + resolveGeneration = resolve + }), + ) + const wrapper = mountView() + await fillRequiredFields(wrapper) + + await wrapper.find('form').trigger('submit') + await wrapper.find('form').trigger('submit') + + expect(generateReservationRoomingListExcel).toHaveBeenCalledTimes(1) + resolveGeneration({ + blob: new Blob(['xlsx-binary'], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + fileName: 'rooming-list-HOTEL-TEST.xlsx', + }) + await flushPromises() + }) + + it('shows backend validation details without logging uploaded content', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.mocked(generateReservationRoomingListExcel).mockRejectedValue( + new RoomingListGenerationError('Rooming List 字段校验失败。', 400, 'ROOMING_LIST_VALIDATION_FAILED', [ + 'file: 未找到表头:护照全名', + ]), + ) + const wrapper = mountView() + await fillRequiredFields(wrapper) + + await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Rooming List 字段校验失败,请检查表单内容。') + expect(wrapper.text()).toContain('file: 未找到表头:护照全名') + expect(consoleErrorSpy).not.toHaveBeenCalled() + }) + + it('shows a permission message for 403 errors', async () => { + vi.mocked(generateReservationRoomingListExcel).mockRejectedValue( + new RoomingListGenerationError('当前用户没有访问该业务能力的权限。', 403, 'FRONTEND_PERMISSION_DENIED', []), + ) + const wrapper = mountView() + await fillRequiredFields(wrapper) + + await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('当前账号没有生成 Rooming List 的权限。') + }) +}) diff --git a/client/src/types/auth.ts b/client/src/types/auth.ts index ed32d94..b9fa027 100644 --- a/client/src/types/auth.ts +++ b/client/src/types/auth.ts @@ -8,6 +8,7 @@ export type AuthPermissionCode = | 'RESERVATION_OPERA_SIM_EXECUTE' | 'RESERVATION_AUDIT_READ' | 'RESERVATION_INVOICE_GENERATE' + | 'RESERVATION_ROOMING_LIST_GENERATE' | 'HOTEL_SWITCH' | 'SYSTEM_AUTH_READ' | 'SYSTEM_ADMIN_CONSOLE_ACCESS' diff --git a/client/src/types/roomingList.ts b/client/src/types/roomingList.ts new file mode 100644 index 0000000..2faa954 --- /dev/null +++ b/client/src/types/roomingList.ts @@ -0,0 +1,39 @@ +export type RoomingListGenerationErrorCode = + | 'ROOMING_LIST_VALIDATION_FAILED' + | 'ROOMING_LIST_SOURCE_FILE_INVALID' + | 'FRONTEND_PERMISSION_DENIED' + | 'HOTEL_ACCESS_DENIED' + | 'AUTH_TOKEN_REQUIRED' + | 'AUTH_SESSION_INVALID' + | 'UNKNOWN' + | (string & {}) + +export interface RoomingListGenerationInput { + file: File + hotelId?: string | null + peoplePerRoom: number + arrival: string + departure: string + roomType: string + title?: string | null + rateCode?: string | null + adults?: number | null + children?: number | null + paymentType?: string | null + vip?: string | null + nationality?: string | null + email?: string | null + idType?: string | null + idNumber?: string | null +} + +export interface RoomingListGenerationDownload { + blob: Blob + fileName: string +} + +export interface RoomingListErrorResponse { + error_code?: RoomingListGenerationErrorCode + message?: string + details?: unknown +} diff --git a/client/src/views/reservation/ReservationRoomingListGenerationView.vue b/client/src/views/reservation/ReservationRoomingListGenerationView.vue new file mode 100644 index 0000000..bfbed29 --- /dev/null +++ b/client/src/views/reservation/ReservationRoomingListGenerationView.vue @@ -0,0 +1,862 @@ + + + + + diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 77780a0..84d2295 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -46,7 +46,7 @@ | 订单任务主流程 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[]` 控件元数据,规定人工复核控件复用和前后端边界;前端待接入。 | | 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` 直接下载,不落库、不上传 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 模拟骨架。 | | 后端 checkpoint | `docs/project/requirements/M002-backend-checkpoint-plan.md` | 阶段记录,用于理解后端拆分和验收。 | | 前端可用接口与待补接口 | `docs/project/frontend-backend/frontend-to-backend-api-requests.md` | 前后端协作清单,已区分可用、后置和历史候选路径,不替代后端权威契约。 | @@ -62,7 +62,7 @@ - 真实 OPERA / OHIP 接入后置;当前仅有 OPERA 模拟骨架。 - 任务卡字段控件契约 V1 后端第一版已完成,任务详情 `fields[]` 已返回 `control_type/edit_scope/write_target/options_source/raw_readonly/control_hint`;前端后续按契约接入,不要硬编码 PMS 房型、Rate Code 或未冻结枚举。 - Manual Invoice 第一阶段按 M009 推进:后端已提供 `POST /api/reservation/invoices/manual-generations`,前端已新增 `/reservation/invoices/new` 手工开票页面,并已按 `invoice.html` 原型的三段式业务结构对齐;页面可以不依赖订单或任务,用户手工填写 / 选择字段后由后端业务接口填充 Excel 模板并生成 PDF;前端不得直接调用 M008 的调试上传转换接口来完成业务开票。侧边栏入口仍以登录后端返回的 menus 为准,建议后续在菜单管理中配置 `RESERVATION_MANUAL_INVOICE` / `/reservation/invoices/new` / `RESERVATION_INVOICE_GENERATE`。 -- Rooming List Excel 后端 CP1 已按 M010 落地:接口为 `POST /api/reservation/rooming-lists/generations`,前端上传来源名单、填写每房人数和目标列字段,后端同步返回 `.xlsx` 下载;该能力不依赖订单或任务,第一版不落库、不上传 OSS,权限码为 `RESERVATION_ROOMING_LIST_GENERATE`。 +- Rooming List Excel 后端 CP1 和前端 V1 已按 M010 落地:接口为 `POST /api/reservation/rooming-lists/generations`,前端页面为 `/reservation/rooming-lists/new`,上传来源名单、填写每房人数和目标列字段,后端同步返回 `.xlsx` 下载;该能力不依赖订单或任务,第一版不落库、不上传 OSS,权限码为 `RESERVATION_ROOMING_LIST_GENERATE`。 ## 6. 前端开发注意事项 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 1d4cea8..6a70879 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -426,7 +426,8 @@ RESERVATION_ROOMING_LIST_GENERATE 前端注意: -- 第一版页面建议作为 Reservation 运营工具入口,例如 `/reservation/rooming-lists/new`。 +- 前端 V1 已新增 `/reservation/rooming-lists/new` 页面,按 `RESERVATION_ROOMING_LIST_GENERATE` 路由权限保护,并使用登录态 Bearer token 调用正式业务接口。 +- 侧边栏入口不由前端硬编码公开,仍依赖后端登录态 `menus[]`。如需菜单中展示,建议菜单管理配置 `menu_code=RESERVATION_ROOMING_LIST`、`route_path=/reservation/rooming-lists/new`、`permission_code=RESERVATION_ROOMING_LIST_GENERATE`。 - 该页面上传来源名单 Excel,后端默认识别 `护照全名` 列。 - 姓名支持 `LI/CHUNHONG` 和 `LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`。 - 前端必须让用户输入 `people_per_room`,后端按名单顺序分组,并用 `ceil(total_people / people_per_room)` 生成房间行。