修复手工开票下载与错误提示

This commit is contained in:
andy
2026-07-17 12:49:58 +07:00
parent 72fee44a68
commit f4d86248d4
7 changed files with 301 additions and 18 deletions

View File

@@ -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()