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

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

@@ -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',

View File

@@ -297,6 +297,9 @@ export default {
generating: 'กำลังสร้าง',
openPdf: 'เปิด PDF',
downloadPdf: 'ดาวน์โหลด PDF',
downloadingPdf: 'กำลังดาวน์โหลด',
downloadFallback: 'เบราว์เซอร์ดาวน์โหลด PDF โดยตรงไม่ได้ จึงเปิดหน้า PDF ให้แทน',
technicalDetails: 'รายละเอียดทางเทคนิค',
generationId: 'รหัสการสร้าง',
generationStatus: 'สถานะ',
pdfUrl: 'ลิงก์ PDF',

View File

@@ -297,6 +297,9 @@ export default {
generating: '生成中',
openPdf: '打开 PDF',
downloadPdf: '下载 PDF',
downloadingPdf: '下载中',
downloadFallback: '浏览器未能直接下载,已尝试打开 PDF 页面。',
technicalDetails: '技术详情',
generationId: '生成记录 ID',
generationStatus: '生成状态',
pdfUrl: 'PDF 链接',

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

View File

@@ -28,6 +28,7 @@
data-testid="document-invoice-date"
type="date"
required
@input="markDocumentDatesEdited"
>
</label>
<label class="field">
@@ -37,6 +38,7 @@
data-testid="document-booking-date"
type="date"
required
@input="markDocumentDatesEdited"
>
</label>
<label class="field">
@@ -46,6 +48,7 @@
data-testid="document-due-date"
type="date"
required
@input="markDocumentDatesEdited"
>
</label>
</div>
@@ -320,7 +323,7 @@
:class="`invoice-alert--${alertTone}`"
>
<strong>{{ alertTitle }}</strong>
<ul>
<ul data-testid="manual-invoice-user-messages">
<li
v-for="message in messages"
:key="message"
@@ -328,6 +331,21 @@
{{ message }}
</li>
</ul>
<details
v-if="technicalDetails.length > 0"
class="technical-details"
data-testid="manual-invoice-technical-details"
>
<summary>{{ t('manualInvoice.technicalDetails') }}</summary>
<ul>
<li
v-for="detail in technicalDetails"
:key="detail"
>
{{ detail }}
</li>
</ul>
</details>
</section>
<section
@@ -372,16 +390,25 @@
>
{{ t('manualInvoice.openPdf') }}
</a>
<a
<button
type="button"
class="secondary-link"
:href="generationResult.pdf_url"
download
data-testid="manual-invoice-download-pdf"
:disabled="downloadingPdf"
@click="downloadPdf"
>
{{ t('manualInvoice.downloadPdf') }}
</a>
{{ downloadingPdf ? t('manualInvoice.downloadingPdf') : t('manualInvoice.downloadPdf') }}
</button>
</div>
<p
v-else
v-if="downloadMessage"
class="muted-text"
data-testid="manual-invoice-download-message"
>
{{ downloadMessage }}
</p>
<p
v-if="!generationResult.pdf_url"
class="muted-text"
>
{{ t('manualInvoice.noPdfUrl') }}
@@ -450,6 +477,11 @@ interface ChargeForm {
nights: string
}
interface GenerationErrorDescription {
message: string
technicalDetails: string[]
}
const recipientCompanies: RecipientCompanySeed[] = [
{
company_code: 'LIAN_TAI',
@@ -565,9 +597,13 @@ const form = reactive({
})
const submitting = ref(false)
const downloadingPdf = ref(false)
const messages = ref<string[]>([])
const technicalDetails = ref<string[]>([])
const downloadMessage = ref('')
const alertTone = ref<'error' | 'success'>('error')
const generationResult = ref<ManualInvoiceGenerationResult | null>(null)
const documentDatesEdited = ref(false)
const recipientContacts = computed(() => {
return recipientCompanies.find((company) => company.company_code === form.recipient.company_code)?.contacts ?? []
@@ -614,6 +650,16 @@ watch(
},
)
watch(
() => authStore.selectedHotel?.time_zone,
(timeZone, previousTimeZone) => {
if (!timeZone || timeZone === previousTimeZone || documentDatesEdited.value) {
return
}
applyDefaultDocumentDates(timeZone)
},
)
function createCharge(): ChargeForm {
const id = nextChargeId
nextChargeId += 1
@@ -652,6 +698,8 @@ function lineAmount(charge: ChargeForm): number {
}
async function submitInvoice(): Promise<void> {
technicalDetails.value = []
downloadMessage.value = ''
messages.value = validateForm()
alertTone.value = 'error'
if (messages.value.length > 0) {
@@ -664,12 +712,40 @@ async function submitInvoice(): Promise<void> {
generationResult.value = await generateManualReservationInvoice(buildRequest())
messages.value = []
} catch (error) {
messages.value = describeGenerationError(error)
const errorDescription = describeGenerationError(error)
messages.value = [errorDescription.message]
technicalDetails.value = errorDescription.technicalDetails
} finally {
submitting.value = false
}
}
async function downloadPdf(): Promise<void> {
const result = generationResult.value
const pdfUrl = result?.pdf_url
if (!pdfUrl || downloadingPdf.value) {
return
}
downloadingPdf.value = true
downloadMessage.value = ''
try {
const response = await fetch(pdfUrl, { credentials: 'omit' })
if (!response.ok) {
throw new Error(`PDF download failed with status ${response.status}`)
}
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
triggerBrowserDownload(objectUrl, createPdfFileName(result.invoice_generation_id))
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
} catch {
downloadMessage.value = t('manualInvoice.downloadFallback')
window.open(pdfUrl, '_blank', 'noopener,noreferrer')
} finally {
downloadingPdf.value = false
}
}
function buildRequest(): ManualInvoiceGenerationRequest {
return {
hotel_id: authStore.selectedHotelId ?? undefined,
@@ -759,15 +835,21 @@ function validateForm(): string[] {
return [...new Set(errors)]
}
function describeGenerationError(error: unknown): string[] {
function describeGenerationError(error: unknown): GenerationErrorDescription {
if (error instanceof ApiError) {
const authKey = error.status === 401 ? 'AUTH_401' : error.status === 403 ? 'AUTH_403' : null
const errorCode = authKey ?? errorCodeFromDetails(error.details)
const messageKey = `manualInvoice.errors.${errorCode}`
const message = te(messageKey) ? t(messageKey) : t('manualInvoice.errors.UNKNOWN')
return [message, ...detailMessages(error.details)]
return {
message,
technicalDetails: detailMessages(error.details),
}
}
return {
message: t('manualInvoice.errors.UNKNOWN'),
technicalDetails: [],
}
return [t('manualInvoice.errors.UNKNOWN')]
}
function errorCodeFromDetails(details: unknown): string {
@@ -781,14 +863,15 @@ function detailMessages(details: unknown): string[] {
if (!isRecord(details)) {
return []
}
const messages: string[] = []
if (typeof details.message === 'string') {
messages.push(details.message)
}
const rawDetails = details.details
if (Array.isArray(rawDetails)) {
return rawDetails.filter((item): item is string => typeof item === 'string')
messages.push(...rawDetails.filter((item): item is string => typeof item === 'string'))
}
if (typeof details.message === 'string') {
return [details.message]
}
return []
return [...new Set(messages)]
}
function applyContactSeed(contactId: string): void {
@@ -802,6 +885,33 @@ function applyContactSeed(contactId: string): void {
form.recipient.email = contact.email
}
function markDocumentDatesEdited(): void {
documentDatesEdited.value = true
}
function applyDefaultDocumentDates(timeZone: string): void {
const dates = createDefaultDocumentDates(timeZone)
form.document.invoice_date = dates.invoiceDate
form.document.booking_date = dates.bookingDate
form.document.due_date = dates.dueDate
}
function triggerBrowserDownload(objectUrl: string, fileName: string): void {
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = fileName
anchor.rel = 'noreferrer'
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
function createPdfFileName(invoiceGenerationId: string): string {
const safeId = invoiceGenerationId.replace(/[^a-zA-Z0-9_.-]/g, '-')
return `proforma-invoice-${safeId}.pdf`
}
function numberValue(value: string | number): number {
return parseNumericInput(value) ?? 0
}
@@ -1111,7 +1221,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
.primary-button:disabled,
.secondary-button:disabled,
.icon-button:disabled {
.icon-button:disabled,
.secondary-link:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@@ -1175,6 +1286,24 @@ function isRecord(value: unknown): value is Record<string, unknown> {
padding-left: 18px;
}
.technical-details {
border-top: 1px solid rgb(190 18 60 / 16%);
margin-top: 12px;
padding-top: 12px;
color: var(--th-color-slate-500);
font-size: 12px;
}
.technical-details summary {
cursor: pointer;
color: #9f1239;
font-weight: 800;
}
.technical-details li {
word-break: break-word;
}
.result-panel h3 {
margin: 18px 0 12px;
color: var(--th-color-navy-950);
@@ -1196,7 +1325,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
.secondary-link {
border: 1px solid var(--th-color-border);
background: var(--th-color-white);
color: var(--th-color-blue-600);
cursor: pointer;
font: inherit;
padding: 10px 12px;
}