From 72fee44a6873cd53917fef0e0b848fc9a7652beb Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 17 Jul 2026 11:57:26 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20M009=20=E6=89=8B=E5=B7=A5?= =?UTF-8?q?=E5=BC=80=E7=A5=A8=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 --- client/src/i18n/locales/en-US.ts | 79 ++ client/src/i18n/locales/th-TH.ts | 79 ++ client/src/i18n/locales/zh-CN.ts | 79 ++ client/src/layouts/ReservationAppShell.vue | 2 + client/src/router/index.ts | 9 + client/src/services/reservationService.ts | 10 + client/src/tests/manualInvoiceService.spec.ts | 115 ++ client/src/tests/manualInvoiceView.spec.ts | 233 ++++ client/src/tests/reservationAppShell.spec.ts | 24 + client/src/tests/reservationRouter.spec.ts | 2 + client/src/types/auth.ts | 1 + client/src/types/manualInvoice.ts | 72 + .../ReservationManualInvoiceView.vue | 1233 +++++++++++++++++ docs/project/frontend-backend/README.md | 2 +- .../backend-to-frontend-notes.md | 3 + .../frontend-to-backend-api-requests.md | 92 +- 16 files changed, 2032 insertions(+), 3 deletions(-) create mode 100644 client/src/tests/manualInvoiceService.spec.ts create mode 100644 client/src/tests/manualInvoiceView.spec.ts create mode 100644 client/src/types/manualInvoice.ts create mode 100644 client/src/views/reservation/ReservationManualInvoiceView.vue diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 1567fc3..bef6e89 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -26,6 +26,7 @@ export default { nav: { orders: 'Order list', taskQueue: 'Task queue', + manualInvoice: 'Manual invoice', sourceMessages: 'Source messages', systemStatus: 'System status', orderDetail: 'Order detail', @@ -250,6 +251,84 @@ export default { createdAt: 'Created at', }, }, + manualInvoice: { + title: 'Manual invoice', + subtitle: 'Generate a Proforma Invoice PDF by entering fields manually without an order or task.', + sourceBadge: 'Manual generation', + independentHint: 'This V1 page is not linked to an order or task. The backend records generation and audit data.', + documentSection: 'Document', + recipientSection: 'Recipient', + bookingSection: 'Booking', + chargesSection: 'Charges', + previewSection: 'Amount preview', + resultSection: 'Generation result', + invoiceDate: 'Invoice date', + bookingDate: 'Booking date', + dueDate: 'Due date', + companySeed: 'Select company', + contactSeed: 'Select attention', + selectCompany: 'Select company', + selectContact: 'Select attention', + company: 'Company', + attention: 'Attention', + address: 'Address', + telephone: 'Tel', + email: 'Email', + groupName: 'Group name', + arrivalDate: 'Arrival date', + departureDate: 'Departure date', + roomRateNote: 'Room rate note', + extraBedRate: 'Extra bed rate', + description: 'Description', + roomType: 'Room type', + quantity: 'Quantity', + rate: 'Rate', + nights: 'Nights', + amount: 'Amount', + subtotal: 'Sub-total', + vat: 'VAT 7%', + total: 'Total', + addCharge: 'Add charge', + removeCharge: 'Remove', + maxChargesReached: 'V1 supports up to 10 charge lines.', + previewDisclaimer: 'The frontend only previews amounts. Backend totals are authoritative.', + manualOverrideHint: 'Selecting company and attention fills contact details; every text field remains editable.', + generate: 'Generate PDF', + generating: 'Generating', + openPdf: 'Open PDF', + downloadPdf: 'Download PDF', + generationId: 'Generation ID', + generationStatus: 'Status', + pdfUrl: 'PDF URL', + hotelId: 'Hotel ID', + templateCode: 'Template', + createdAt: 'Created at', + noPdfUrl: 'The backend did not return a PDF URL.', + backendTotals: 'Backend totals', + validationTitle: 'Please fix the form', + statuses: { + SUCCEEDED: 'Generated', + GENERATED: 'Generated', + FAILED: 'Failed', + PROCESSING: 'Generating', + }, + errors: { + FRONTEND_VALIDATION: 'Form validation failed. Check required fields and charge lines.', + RESERVATION_INVOICE_VALIDATION_FAILED: 'Invoice field validation failed. Please check the form.', + RESERVATION_INVOICE_CONTEXT_MISMATCH: 'The order or task context does not match.', + DOCUMENT_CONVERSION_FAILED: 'PDF generation failed. Please retry or contact an administrator.', + DOCUMENT_CONVERSION_TIMEOUT: 'PDF generation timed out. Please retry later.', + AUTH_401: 'Your session has expired. Please sign in again.', + AUTH_403: 'Your account does not have manual invoice permission.', + UNKNOWN: 'Generation failed. Please retry later.', + requiredFields: 'Complete dates, recipient, booking details, and at least one valid charge line.', + invalidEmail: 'Email format is invalid.', + dueDateBeforeBookingDate: 'Due date cannot be earlier than booking date.', + departureBeforeArrival: 'Departure date must be later than arrival date.', + extraBedNegative: 'Extra bed rate cannot be less than 0.', + chargePositive: 'Quantity, rate, and nights must be greater than 0.', + }, + }, 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 c0934ae..bd2864d 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -26,6 +26,7 @@ export default { nav: { orders: 'รายการออเดอร์', taskQueue: 'คิวงาน', + manualInvoice: 'ออกใบแจ้งหนี้เอง', sourceMessages: 'แหล่งที่มาข้อความ', systemStatus: 'สถานะระบบ', orderDetail: 'รายละเอียดออเดอร์', @@ -250,6 +251,84 @@ export default { createdAt: 'เวลาสร้าง', }, }, + manualInvoice: { + title: 'ออกใบแจ้งหนี้เอง', + subtitle: 'กรอกข้อมูลเองเพื่อสร้าง Proforma Invoice PDF โดยไม่ต้องอ้างอิงออเดอร์หรืองาน', + sourceBadge: 'สร้างด้วยตนเอง', + independentHint: 'หน้า V1 นี้ไม่ผูกกับออเดอร์หรืองาน หลังบ้านจะบันทึกประวัติการสร้างและ audit', + documentSection: 'ข้อมูลเอกสาร', + recipientSection: 'ผู้รับ', + bookingSection: 'ข้อมูลการจอง', + chargesSection: 'รายการค่าใช้จ่าย', + previewSection: 'ตัวอย่างยอดเงิน', + resultSection: 'ผลการสร้าง', + invoiceDate: 'วันที่ Invoice', + bookingDate: 'วันที่ Booking', + dueDate: 'Due Date', + companySeed: 'เลือก Company', + contactSeed: 'เลือก Attention', + selectCompany: 'เลือก Company', + selectContact: 'เลือก Attention', + company: 'Company', + attention: 'Attention', + address: 'Address', + telephone: 'Tel', + email: 'Email', + groupName: 'Group Name', + arrivalDate: 'Arrival Date', + departureDate: 'Departure Date', + roomRateNote: 'Room Rate Note', + extraBedRate: 'Extra Bed Rate', + description: 'Description', + roomType: 'Room Type', + quantity: 'Quantity', + rate: 'Rate', + nights: 'Nights', + amount: 'Amount', + subtotal: 'Sub-Total', + vat: 'VAT 7%', + total: 'Total', + addCharge: 'เพิ่มรายการ', + removeCharge: 'ลบ', + maxChargesReached: 'V1 รองรับได้สูงสุด 10 รายการค่าใช้จ่าย', + previewDisclaimer: 'ยอดนี้เป็นเพียงตัวอย่าง ยอดสุดท้ายให้ยึด totals จากหลังบ้าน', + manualOverrideHint: 'เลือก Company / Attention แล้วระบบจะกรอกข้อมูลให้ แต่ยังแก้ไขข้อความได้ทั้งหมด', + generate: 'สร้าง PDF', + generating: 'กำลังสร้าง', + openPdf: 'เปิด PDF', + downloadPdf: 'ดาวน์โหลด PDF', + generationId: 'รหัสการสร้าง', + generationStatus: 'สถานะ', + pdfUrl: 'ลิงก์ PDF', + hotelId: 'รหัสโรงแรม', + templateCode: 'เทมเพลต', + createdAt: 'เวลาสร้าง', + noPdfUrl: 'หลังบ้านไม่ได้ส่งลิงก์ PDF กลับมา', + backendTotals: 'ยอดจากหลังบ้าน', + validationTitle: 'กรุณาแก้ไขแบบฟอร์ม', + statuses: { + SUCCEEDED: 'สร้างแล้ว', + GENERATED: 'สร้างแล้ว', + FAILED: 'สร้างไม่สำเร็จ', + PROCESSING: 'กำลังสร้าง', + }, + errors: { + FRONTEND_VALIDATION: 'ตรวจสอบแบบฟอร์มไม่ผ่าน กรุณาตรวจฟิลด์ที่จำเป็นและรายการค่าใช้จ่าย', + RESERVATION_INVOICE_VALIDATION_FAILED: 'ตรวจสอบข้อมูล Invoice ไม่ผ่าน กรุณาตรวจแบบฟอร์ม', + RESERVATION_INVOICE_CONTEXT_MISMATCH: 'ออเดอร์หรืองานไม่ตรงกัน', + DOCUMENT_CONVERSION_FAILED: 'สร้าง PDF ไม่สำเร็จ กรุณาลองอีกครั้งหรือติดต่อผู้ดูแล', + DOCUMENT_CONVERSION_TIMEOUT: 'สร้าง PDF หมดเวลา กรุณาลองใหม่ภายหลัง', + AUTH_401: 'เซสชันหมดอายุ กรุณาเข้าสู่ระบบใหม่', + AUTH_403: 'บัญชีนี้ไม่มีสิทธิ์ออกใบแจ้งหนี้เอง', + UNKNOWN: 'สร้างไม่สำเร็จ กรุณาลองอีกครั้ง', + requiredFields: 'กรุณากรอกวันที่ ผู้รับ ข้อมูลการจอง และอย่างน้อยหนึ่งรายการค่าใช้จ่ายที่ถูกต้อง', + invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง', + dueDateBeforeBookingDate: 'Due Date ต้องไม่เร็วกว่าวันที่ Booking', + departureBeforeArrival: 'Departure Date ต้องหลัง Arrival Date', + extraBedNegative: 'Extra Bed Rate ต้องไม่ต่ำกว่า 0', + chargePositive: 'Quantity, Rate และ Nights ต้องมากกว่า 0', + }, + }, order: { summary: 'สรุปออเดอร์', taskQueue: 'คิวงานของออเดอร์นี้', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 7b5dd80..f1cd018 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -26,6 +26,7 @@ export default { nav: { orders: '订单列表', taskQueue: '任务队列', + manualInvoice: '手工开票', sourceMessages: '消息来源', systemStatus: '系统状态', orderDetail: '订单详情', @@ -250,6 +251,84 @@ export default { createdAt: '创建时间', }, }, + manualInvoice: { + title: '手工开票', + subtitle: '无订单、无任务时,手工填写 Proforma Invoice 字段并生成 PDF。', + sourceBadge: '手工生成', + independentHint: '本页第一版不关联订单或任务,生成记录和审计由后端统一落库。', + documentSection: '单据信息', + recipientSection: '收件方', + bookingSection: '预订信息', + chargesSection: '费用明细', + previewSection: '金额预览', + resultSection: '生成结果', + invoiceDate: '开票日期(Invoice Date)', + bookingDate: '预订日期(Booking Date)', + dueDate: '付款截止日期(Due Date)', + companySeed: '选择公司(Company)', + contactSeed: '选择收件人(Attention)', + selectCompany: '请选择公司', + selectContact: '请选择收件人', + company: '公司(Company)', + attention: '收件人(Attention)', + address: '地址(Address)', + telephone: 'Tel', + email: 'Email', + groupName: '团队名称(Group Name)', + arrivalDate: '到店日期(Arrival Date)', + departureDate: '离店日期(Departure Date)', + roomRateNote: '房价备注(Room Rate Note)', + extraBedRate: '加床价格(Extra Bed Rate)', + description: '费用描述(Description)', + roomType: '房型(Room Type)', + quantity: '数量(Quantity)', + rate: '单价(Rate)', + nights: '晚数(Nights)', + amount: 'Amount', + subtotal: 'Sub-Total', + vat: 'VAT 7%', + total: 'Total', + addCharge: '新增费用', + removeCharge: '删除', + maxChargesReached: '第一版最多支持 10 条费用明细。', + previewDisclaimer: '前端仅做金额预览,最终金额以后端返回 totals 为准。', + manualOverrideHint: '选择 Company / Attention 后会自动带出资料,带出的文本仍可手工覆盖。', + generate: '生成 PDF', + generating: '生成中', + openPdf: '打开 PDF', + downloadPdf: '下载 PDF', + generationId: '生成记录 ID', + generationStatus: '生成状态', + pdfUrl: 'PDF 链接', + hotelId: '酒店 ID', + templateCode: '模板', + createdAt: '创建时间', + noPdfUrl: '后端未返回 PDF 链接。', + backendTotals: '后端返回金额', + validationTitle: '请先修正表单', + statuses: { + SUCCEEDED: '已生成', + GENERATED: '已生成', + FAILED: '生成失败', + PROCESSING: '生成中', + }, + errors: { + FRONTEND_VALIDATION: '表单校验未通过,请检查必填字段和费用明细。', + RESERVATION_INVOICE_VALIDATION_FAILED: 'Invoice 字段校验失败,请检查表单内容。', + RESERVATION_INVOICE_CONTEXT_MISMATCH: '订单或任务上下文不匹配。', + DOCUMENT_CONVERSION_FAILED: 'PDF 生成失败,请稍后重试或联系管理员。', + DOCUMENT_CONVERSION_TIMEOUT: 'PDF 生成超时,请稍后重试。', + AUTH_401: '登录已失效,请重新登录后再生成。', + AUTH_403: '当前账号没有手工开票权限。', + UNKNOWN: '生成失败,请稍后重试。', + requiredFields: '请补充日期、收件方、预订信息和至少一条有效费用明细。', + invalidEmail: 'Email 格式不正确。', + dueDateBeforeBookingDate: 'Due Date 不能早于 Booking 日期。', + departureBeforeArrival: 'Departure Date 必须晚于 Arrival Date。', + extraBedNegative: '加床价格不能小于 0。', + chargePositive: '费用明细的 Quantity、Rate、Nights 必须大于 0。', + }, + }, order: { summary: '订单摘要', taskQueue: '同订单任务队列', diff --git a/client/src/layouts/ReservationAppShell.vue b/client/src/layouts/ReservationAppShell.vue index cdaa643..ee43583 100644 --- a/client/src/layouts/ReservationAppShell.vue +++ b/client/src/layouts/ReservationAppShell.vue @@ -173,6 +173,8 @@ const localeOptions: Array<{ value: SupportedLocale; label: string }> = [ const menuLabelKeys: Record = { RESERVATION_ORDERS: 'nav.orders', RESERVATION_TASKS: 'nav.taskQueue', + RESERVATION_MANUAL_INVOICE: 'nav.manualInvoice', + RESERVATION_INVOICES: 'nav.manualInvoice', DEBUG_EML_SUPERAGENT: 'nav.debugEml', SYSTEM_SETTINGS: 'nav.systemSettings', } diff --git a/client/src/router/index.ts b/client/src/router/index.ts index c78cb55..c0a4f64 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -62,6 +62,15 @@ export const router = createRouter({ permission: 'RESERVATION_TASK_READ', }, }, + { + path: '/reservation/invoices/new', + name: 'reservation-manual-invoice', + component: () => import('@/views/reservation/ReservationManualInvoiceView.vue'), + meta: { + titleKey: 'nav.manualInvoice', + permission: 'RESERVATION_INVOICE_GENERATE', + }, + }, { path: '/reservation/orders/:orderId', name: 'reservation-order-detail', diff --git a/client/src/services/reservationService.ts b/client/src/services/reservationService.ts index 43d233a..99d30bd 100644 --- a/client/src/services/reservationService.ts +++ b/client/src/services/reservationService.ts @@ -4,6 +4,10 @@ import { } from '@/fixtures/reservation' import { getReservationHotelId } from '@/config/reservationConfig' import { getJson, sendJson } from '@/services/httpClient' +import type { + ManualInvoiceGenerationRequest, + ManualInvoiceGenerationResult, +} from '@/types/manualInvoice' import type { ReservationManualReviewConversionRequest, ReservationManualReviewConversionResult, @@ -150,6 +154,12 @@ export async function fetchReservationTaskAudits(taskId: string): Promise(`/api/reservation/tasks/${taskId}/audits`) } +export async function generateManualReservationInvoice( + request: ManualInvoiceGenerationRequest, +): Promise { + return sendJson('/api/reservation/invoices/manual-generations', 'POST', request) +} + function withQuery(path: string, params: object): string { const searchParams = new URLSearchParams() Object.entries(params).forEach(([key, value]) => { diff --git a/client/src/tests/manualInvoiceService.spec.ts b/client/src/tests/manualInvoiceService.spec.ts new file mode 100644 index 0000000..d80e526 --- /dev/null +++ b/client/src/tests/manualInvoiceService.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { clearStoredAccessToken, setStoredAccessToken } from '@/services/authSession' +import { generateManualReservationInvoice } from '@/services/reservationService' +import type { ManualInvoiceGenerationRequest, ManualInvoiceGenerationResult } from '@/types/manualInvoice' + +const jsonHeaders = { + headers: { + get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null), + }, +} + +function mockJsonResponse(payload: unknown): Response { + return { + ok: true, + status: 200, + json: async () => payload, + text: async () => JSON.stringify(payload), + ...jsonHeaders, + } as Response +} + +function createManualInvoiceRequest(): ManualInvoiceGenerationRequest { + return { + hotel_id: 'HOTEL-TEST', + source_type: 'MANUAL', + task_id: null, + order_id: null, + template_code: 'PROFORMA_INVOICE_V1', + invoice_payload: { + document: { + invoice_date: '2026-07-17', + booking_date: '2026-07-17', + due_date: '2026-07-24', + }, + recipient: { + company_code: 'QBD', + contact_id: 'QBD_JITDANUN_PANAPHUCHONG', + company: 'Q.B.D. TRAVEL GROUP CO., LTD', + attention: 'Jitdanun Panaphuchong', + address: '2/90 Rajpattana,Rajpattana,Sapansoong, Bangkok, TH, 10240', + telephone: '089-032 0176', + email: 'op.qbdtravel@gmail.com', + }, + booking: { + group_name: 'LLT260509FA213', + arrival_date: '2026-07-20', + departure_date: '2026-07-22', + room_rate_note: 'Inclusive breakfast', + extra_bed_rate: 900, + }, + charges: [ + { + description: '2N TWN +1 DBL', + room_type: 'TWN/DBL', + quantity: 3, + rate: 2500, + nights: 2, + }, + ], + }, + } +} + +function createManualInvoiceResult(): ManualInvoiceGenerationResult { + return { + invoice_generation_id: '91001', + generation_status: 'GENERATED', + source_type: 'MANUAL', + hotel_id: 'HOTEL-TEST', + order_id: null, + task_id: null, + template_code: 'PROFORMA_INVOICE_V1', + pdf_url: 'https://oss.example/invoices/manual-91001.pdf', + pdf_object_key: 'reservation/invoices/manual-91001.pdf', + generated_excel_object_key: 'reservation/invoices/manual-91001.xlsx', + totals: { + subtotal: 14018.69, + vat: 981.31, + total: 15000, + currency: 'THB', + }, + created_at: '2026-07-17T08:00:00Z', + } +} + +describe('manual invoice reservation service', () => { + beforeEach(() => { + vi.restoreAllMocks() + clearStoredAccessToken() + }) + + it('posts manual invoice payload to the M009 business endpoint with Bearer auth', async () => { + const request = createManualInvoiceRequest() + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockJsonResponse(createManualInvoiceResult())) + setStoredAccessToken('session-token') + + const result = await generateManualReservationInvoice(request) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/reservation/invoices/manual-generations', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Accept: 'application/json', + Authorization: 'Bearer session-token', + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }), + ) + expect(fetchMock.mock.calls[0]?.[0]).not.toContain('/api/system/document-conversions/excel-to-pdf') + expect(result.pdf_url).toBe('https://oss.example/invoices/manual-91001.pdf') + }) +}) diff --git a/client/src/tests/manualInvoiceView.spec.ts b/client/src/tests/manualInvoiceView.spec.ts new file mode 100644 index 0000000..b13ddcd --- /dev/null +++ b/client/src/tests/manualInvoiceView.spec.ts @@ -0,0 +1,233 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import zhCN from '@/i18n/locales/zh-CN' +import { ApiError } from '@/services/httpClient' +import { useAuthStore } from '@/stores/authStore' +import type { ManualInvoiceGenerationResult } from '@/types/manualInvoice' +import ReservationManualInvoiceView from '@/views/reservation/ReservationManualInvoiceView.vue' + +vi.mock('@/services/reservationService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateManualReservationInvoice: vi.fn(), + } +}) + +const service = await import('@/services/reservationService') + +function createResult(): ManualInvoiceGenerationResult { + return { + invoice_generation_id: '91001', + generation_status: 'GENERATED', + source_type: 'MANUAL', + hotel_id: 'HOTEL-TEST', + order_id: null, + task_id: null, + template_code: 'PROFORMA_INVOICE_V1', + pdf_url: 'https://oss.example/invoices/manual-91001.pdf', + pdf_object_key: 'reservation/invoices/manual-91001.pdf', + generated_excel_object_key: 'reservation/invoices/manual-91001.xlsx', + totals: { + subtotal: 14018.69, + vat: 981.31, + total: 15000, + currency: 'THB', + }, + created_at: '2026-07-17T08:00:00Z', + } +} + +function mountView(options: { timeZone?: string } = {}) { + const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + messages: { + 'zh-CN': zhCN, + }, + }) + 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: options.timeZone ?? 'Asia/Bangkok', + default_hotel: true, + }, + ], + permissions: ['RESERVATION_INVOICE_GENERATE'], + menus: [], + }) + + return mount(ReservationManualInvoiceView, { + global: { + plugins: [i18n, pinia], + }, + }) +} + +async function fillMinimumInvoiceForm(wrapper: ReturnType) { + await wrapper.find('[data-testid="document-invoice-date"]').setValue('2026-07-17') + await wrapper.find('[data-testid="document-booking-date"]').setValue('2026-07-17') + await wrapper.find('[data-testid="document-due-date"]').setValue('2026-07-24') + await wrapper.find('[data-testid="booking-group-name"]').setValue('LLT260509FA213') + await wrapper.find('[data-testid="booking-arrival-date"]').setValue('2026-07-20') + await wrapper.find('[data-testid="booking-departure-date"]').setValue('2026-07-22') + await wrapper.find('[data-testid="booking-room-rate-note"]').setValue('Inclusive breakfast') + await wrapper.find('[data-testid="booking-extra-bed-rate"]').setValue('900') + await wrapper.find('[data-testid="charge-description-0"]').setValue('2N TWN +1 DBL') + await wrapper.find('[data-testid="charge-room-type-0"]').setValue('TWN/DBL') + await wrapper.find('[data-testid="charge-quantity-0"]').setValue('3') + await wrapper.find('[data-testid="charge-rate-0"]').setValue('2500') + await wrapper.find('[data-testid="charge-nights-0"]').setValue('2') +} + +describe('ReservationManualInvoiceView', () => { + beforeEach(() => { + vi.mocked(service.generateManualReservationInvoice).mockReset() + sessionStorage.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('defaults document dates in the selected hotel timezone', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-17T12:30:00Z')) + + const wrapper = mountView({ timeZone: 'Pacific/Kiritimati' }) + + expect((wrapper.find('[data-testid="document-invoice-date"]').element as HTMLInputElement).value).toBe( + '2026-07-18', + ) + expect((wrapper.find('[data-testid="document-booking-date"]').element as HTMLInputElement).value).toBe( + '2026-07-18', + ) + expect((wrapper.find('[data-testid="document-due-date"]').element as HTMLInputElement).value).toBe('2026-07-25') + }) + + it('links company and attention seed data while allowing manual overrides before submit', async () => { + vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) + const wrapper = mountView() + + await wrapper.find('[data-testid="recipient-company-code"]').setValue('HANATOUR') + await wrapper.find('[data-testid="recipient-contact-id"]').setValue('HANATOUR_BOLAM_JO') + expect((wrapper.find('[data-testid="recipient-telephone"]').element as HTMLInputElement).value).toBe( + '82 010 4182 4615', + ) + + await wrapper.find('[data-testid="recipient-email"]').setValue('custom@example.test') + await fillMinimumInvoiceForm(wrapper) + await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click') + await flushPromises() + + expect(service.generateManualReservationInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + hotel_id: 'HOTEL-TEST', + source_type: 'MANUAL', + task_id: null, + order_id: null, + template_code: 'PROFORMA_INVOICE_V1', + invoice_payload: expect.objectContaining({ + recipient: expect.objectContaining({ + company_code: 'HANATOUR', + contact_id: 'HANATOUR_BOLAM_JO', + attention: 'Bolam Jo', + email: 'custom@example.test', + }), + charges: [ + expect.objectContaining({ + description: '2N TWN +1 DBL', + quantity: 3, + rate: 2500, + nights: 2, + }), + ], + }), + }), + ) + expect(wrapper.text()).toContain('91001') + expect(wrapper.find('a[href="https://oss.example/invoices/manual-91001.pdf"]').exists()).toBe(true) + }) + + it('submits complete numeric input values instead of parseFloat prefixes', async () => { + vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) + const wrapper = mountView() + + await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD') + await fillMinimumInvoiceForm(wrapper) + await wrapper.find('[data-testid="charge-rate-0"]').setValue('1e3') + await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click') + await flushPromises() + + expect(service.generateManualReservationInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + invoice_payload: expect.objectContaining({ + charges: [ + expect.objectContaining({ + rate: 1000, + }), + ], + }), + }), + ) + }) + + it('limits manual charge lines to ten rows', async () => { + const wrapper = mountView() + const addButton = () => wrapper.find('[data-testid="manual-invoice-add-charge"]') + + for (let index = 1; index < 10; index += 1) { + await addButton().trigger('click') + } + + expect(wrapper.findAll('[data-testid^="charge-row-"]')).toHaveLength(10) + expect(addButton().attributes('disabled')).toBeDefined() + }) + + it('shows a friendly backend error for document conversion timeout', async () => { + vi.mocked(service.generateManualReservationInvoice).mockRejectedValue( + new ApiError('Request failed with status 500', 500, { + error_code: 'DOCUMENT_CONVERSION_TIMEOUT', + message: 'Document conversion timeout.', + }), + ) + const wrapper = mountView() + + await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD') + await fillMinimumInvoiceForm(wrapper) + await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('PDF 生成超时') + }) + + it('blocks negative extra bed rate before calling the backend', async () => { + const wrapper = mountView() + + await wrapper.find('[data-testid="recipient-company-code"]').setValue('QBD') + await fillMinimumInvoiceForm(wrapper) + await wrapper.find('[data-testid="booking-extra-bed-rate"]').setValue('-1') + await wrapper.find('[data-testid="manual-invoice-submit"]').trigger('click') + await flushPromises() + + expect(service.generateManualReservationInvoice).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('加床价格不能小于 0') + }) +}) diff --git a/client/src/tests/reservationAppShell.spec.ts b/client/src/tests/reservationAppShell.spec.ts index 8826441..e60e0ec 100644 --- a/client/src/tests/reservationAppShell.spec.ts +++ b/client/src/tests/reservationAppShell.spec.ts @@ -79,6 +79,18 @@ function createDebugMenu(): AuthMenuResult { } } +function createManualInvoiceMenu(): AuthMenuResult { + return { + menu_code: 'RESERVATION_MANUAL_INVOICE', + menu_name: '手工开票', + route_path: '/reservation/invoices/new', + component_key: 'ReservationManualInvoice', + icon_key: 'pi pi-file-pdf', + permission_code: 'RESERVATION_INVOICE_GENERATE', + sort_order: 25, + } +} + function createSystemMenu(): AuthMenuResult { return { menu_code: 'SYSTEM_SETTINGS', @@ -120,6 +132,11 @@ async function mountShell(menus = [createReservationOrdersMenu(), createReservat component: { template: '
' }, meta: { titleKey: 'debugEml.title' }, }, + { + path: '/reservation/invoices/new', + component: { template: '
' }, + meta: { titleKey: 'nav.manualInvoice' }, + }, { path: '/system', component: { template: '
' }, @@ -175,6 +192,13 @@ describe('ReservationAppShell', () => { expect(wrapper.text()).toContain('Debug EML') }) + it('shows Manual Invoice only when backend menus include it', async () => { + const wrapper = await mountShell([createReservationOrdersMenu(), createManualInvoiceMenu()]) + + expect(wrapper.find('a[href="/reservation/invoices/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 d8d5201..85b8d68 100644 --- a/client/src/tests/reservationRouter.spec.ts +++ b/client/src/tests/reservationRouter.spec.ts @@ -42,6 +42,7 @@ describe('reservation router', () => { expect(router.resolve('/login').name).toBe('login') 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/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( @@ -67,6 +68,7 @@ describe('reservation router', () => { expect(router.resolve('/reservation/orders/20001').meta.permission).toBe('RESERVATION_ORDER_READ') 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/source-messages/30001/conversation').meta.permission).toBe( 'SOURCE_MESSAGE_ORIGINAL_READ', ) diff --git a/client/src/types/auth.ts b/client/src/types/auth.ts index 7cfbfcb..ed32d94 100644 --- a/client/src/types/auth.ts +++ b/client/src/types/auth.ts @@ -7,6 +7,7 @@ export type AuthPermissionCode = | 'RESERVATION_TASK_CONFIRM' | 'RESERVATION_OPERA_SIM_EXECUTE' | 'RESERVATION_AUDIT_READ' + | 'RESERVATION_INVOICE_GENERATE' | 'HOTEL_SWITCH' | 'SYSTEM_AUTH_READ' | 'SYSTEM_ADMIN_CONSOLE_ACCESS' diff --git a/client/src/types/manualInvoice.ts b/client/src/types/manualInvoice.ts new file mode 100644 index 0000000..028ca3d --- /dev/null +++ b/client/src/types/manualInvoice.ts @@ -0,0 +1,72 @@ +export type ManualInvoiceSourceType = 'MANUAL' +export type ManualInvoiceTemplateCode = 'PROFORMA_INVOICE_V1' + +export interface ManualInvoiceDocumentPayload { + invoice_date: string + booking_date: string + due_date: string +} + +export interface ManualInvoiceRecipientPayload { + company_code: string | null + contact_id: string | null + company: string + attention: string + address: string + telephone: string + email: string +} + +export interface ManualInvoiceBookingPayload { + group_name: string + arrival_date: string + departure_date: string + room_rate_note: string | null + extra_bed_rate: number | null +} + +export interface ManualInvoiceChargePayload { + description: string + room_type: string + quantity: number + rate: number + nights: number +} + +export interface ManualInvoicePayload { + document: ManualInvoiceDocumentPayload + recipient: ManualInvoiceRecipientPayload + booking: ManualInvoiceBookingPayload + charges: ManualInvoiceChargePayload[] +} + +export interface ManualInvoiceGenerationRequest { + hotel_id?: string + source_type: ManualInvoiceSourceType + task_id: null + order_id: null + template_code: ManualInvoiceTemplateCode + invoice_payload: ManualInvoicePayload +} + +export interface ManualInvoiceTotalsResult { + subtotal: number + vat: number + total: number + currency: string +} + +export interface ManualInvoiceGenerationResult { + invoice_generation_id: string + generation_status: string + source_type: ManualInvoiceSourceType + hotel_id: string + order_id: string | null + task_id: string | null + template_code: ManualInvoiceTemplateCode + pdf_url: string | null + pdf_object_key: string | null + generated_excel_object_key: string | null + totals: ManualInvoiceTotalsResult + created_at: string +} diff --git a/client/src/views/reservation/ReservationManualInvoiceView.vue b/client/src/views/reservation/ReservationManualInvoiceView.vue new file mode 100644 index 0000000..9579242 --- /dev/null +++ b/client/src/views/reservation/ReservationManualInvoiceView.vue @@ -0,0 +1,1233 @@ +