diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index bef6e89..e8b99ec 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -297,6 +297,9 @@ export default { generating: 'Generating', openPdf: 'Open PDF', downloadPdf: 'Download PDF', + downloadingPdf: 'Downloading', + downloadFallback: 'The browser could not download the PDF directly, so the PDF page was opened instead.', + technicalDetails: 'Technical details', generationId: 'Generation ID', generationStatus: 'Status', pdfUrl: 'PDF URL', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index bd2864d..0dd4761 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -297,6 +297,9 @@ export default { generating: 'กำลังสร้าง', openPdf: 'เปิด PDF', downloadPdf: 'ดาวน์โหลด PDF', + downloadingPdf: 'กำลังดาวน์โหลด', + downloadFallback: 'เบราว์เซอร์ดาวน์โหลด PDF โดยตรงไม่ได้ จึงเปิดหน้า PDF ให้แทน', + technicalDetails: 'รายละเอียดทางเทคนิค', generationId: 'รหัสการสร้าง', generationStatus: 'สถานะ', pdfUrl: 'ลิงก์ PDF', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index f1cd018..5368f00 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -297,6 +297,9 @@ export default { generating: '生成中', openPdf: '打开 PDF', downloadPdf: '下载 PDF', + downloadingPdf: '下载中', + downloadFallback: '浏览器未能直接下载,已尝试打开 PDF 页面。', + technicalDetails: '技术详情', generationId: '生成记录 ID', generationStatus: '生成状态', pdfUrl: 'PDF 链接', diff --git a/client/src/tests/manualInvoiceView.spec.ts b/client/src/tests/manualInvoiceView.spec.ts index b13ddcd..fa1ced9 100644 --- a/client/src/tests/manualInvoiceView.spec.ts +++ b/client/src/tests/manualInvoiceView.spec.ts @@ -1,11 +1,13 @@ import { flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' import zhCN from '@/i18n/locales/zh-CN' import { ApiError } from '@/services/httpClient' import { useAuthStore } from '@/stores/authStore' +import type { AuthHotelResult } from '@/types/auth' import type { ManualInvoiceGenerationResult } from '@/types/manualInvoice' import ReservationManualInvoiceView from '@/views/reservation/ReservationManualInvoiceView.vue' @@ -41,7 +43,10 @@ function createResult(): ManualInvoiceGenerationResult { } } -function mountView(options: { timeZone?: string } = {}) { +const nativeCreateObjectURL = URL.createObjectURL +const nativeRevokeObjectURL = URL.revokeObjectURL + +function mountView(options: { timeZone?: string; extraHotels?: AuthHotelResult[] } = {}) { const i18n = createI18n({ legacy: false, locale: 'zh-CN', @@ -69,6 +74,7 @@ function mountView(options: { timeZone?: string } = {}) { time_zone: options.timeZone ?? 'Asia/Bangkok', default_hotel: true, }, + ...(options.extraHotels ?? []), ], permissions: ['RESERVATION_INVOICE_GENERATE'], menus: [], @@ -101,10 +107,37 @@ describe('ReservationManualInvoiceView', () => { beforeEach(() => { vi.mocked(service.generateManualReservationInvoice).mockReset() sessionStorage.clear() + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:manual-invoice-pdf'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) }) afterEach(() => { vi.useRealTimers() + vi.unstubAllGlobals() + 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('defaults document dates in the selected hotel timezone', () => { @@ -122,6 +155,60 @@ describe('ReservationManualInvoiceView', () => { expect((wrapper.find('[data-testid="document-due-date"]').element as HTMLInputElement).value).toBe('2026-07-25') }) + it('refreshes untouched document dates when switching hotel timezone', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-17T12:30:00Z')) + const wrapper = mountView({ + timeZone: 'Asia/Bangkok', + extraHotels: [ + { + hotel_id: 'HOTEL-KIRITIMATI', + hotel_name: '换日线酒店', + time_zone: 'Pacific/Kiritimati', + default_hotel: false, + }, + ], + }) + const authStore = useAuthStore() + + expect((wrapper.find('[data-testid="document-invoice-date"]').element as HTMLInputElement).value).toBe( + '2026-07-17', + ) + + authStore.setSelectedHotelId('HOTEL-KIRITIMATI') + await nextTick() + + expect((wrapper.find('[data-testid="document-invoice-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('does not overwrite manually edited document dates when switching hotel timezone', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-17T12:30:00Z')) + const wrapper = mountView({ + timeZone: 'Asia/Bangkok', + extraHotels: [ + { + hotel_id: 'HOTEL-KIRITIMATI', + hotel_name: '换日线酒店', + time_zone: 'Pacific/Kiritimati', + default_hotel: false, + }, + ], + }) + const authStore = useAuthStore() + + await wrapper.find('[data-testid="document-invoice-date"]').setValue('2026-08-01') + authStore.setSelectedHotelId('HOTEL-KIRITIMATI') + await nextTick() + + expect((wrapper.find('[data-testid="document-invoice-date"]').element as HTMLInputElement).value).toBe( + '2026-08-01', + ) + }) + it('links company and attention seed data while allowing manual overrides before submit', async () => { vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) const wrapper = mountView() @@ -166,6 +253,32 @@ describe('ReservationManualInvoiceView', () => { expect(wrapper.find('a[href="https://oss.example/invoices/manual-91001.pdf"]').exists()).toBe(true) }) + it('downloads the generated PDF through a browser blob URL', async () => { + vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['pdf'], { type: 'application/pdf' })), + }) + vi.stubGlobal('fetch', fetchMock) + 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() + await wrapper.find('[data-testid="manual-invoice-download-pdf"]').trigger('click') + await flushPromises() + + expect(fetchMock).toHaveBeenCalledWith('https://oss.example/invoices/manual-91001.pdf', { + credentials: 'omit', + }) + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:manual-invoice-pdf') + }) + it('submits complete numeric input values instead of parseFloat prefixes', async () => { vi.mocked(service.generateManualReservationInvoice).mockResolvedValue(createResult()) const wrapper = mountView() @@ -218,6 +331,31 @@ describe('ReservationManualInvoiceView', () => { expect(wrapper.text()).toContain('PDF 生成超时') }) + it('keeps backend raw details in a folded technical detail block', async () => { + vi.mocked(service.generateManualReservationInvoice).mockRejectedValue( + new ApiError('Request failed with status 422', 422, { + error_code: 'RESERVATION_INVOICE_VALIDATION_FAILED', + message: 'invoice_payload.recipient.email is invalid', + details: ['invoice_payload.recipient.email: must be a valid email'], + }), + ) + 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.find('[data-testid="manual-invoice-user-messages"]').text()).toContain('Invoice 字段校验失败') + expect(wrapper.find('[data-testid="manual-invoice-user-messages"]').text()).not.toContain('invoice_payload') + expect(wrapper.find('[data-testid="manual-invoice-technical-details"]').text()).toContain( + 'invoice_payload.recipient.email is invalid', + ) + expect(wrapper.find('[data-testid="manual-invoice-technical-details"]').text()).toContain( + 'invoice_payload.recipient.email: must be a valid email', + ) + }) + it('blocks negative extra bed rate before calling the backend', async () => { const wrapper = mountView() diff --git a/client/src/views/reservation/ReservationManualInvoiceView.vue b/client/src/views/reservation/ReservationManualInvoiceView.vue index 9579242..76b6888 100644 --- a/client/src/views/reservation/ReservationManualInvoiceView.vue +++ b/client/src/views/reservation/ReservationManualInvoiceView.vue @@ -28,6 +28,7 @@ data-testid="document-invoice-date" type="date" required + @input="markDocumentDatesEdited" > @@ -320,7 +323,7 @@ :class="`invoice-alert--${alertTone}`" > {{ alertTitle }} -