修复手工开票下载与错误提示
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -297,6 +297,9 @@ export default {
|
||||
generating: 'กำลังสร้าง',
|
||||
openPdf: 'เปิด PDF',
|
||||
downloadPdf: 'ดาวน์โหลด PDF',
|
||||
downloadingPdf: 'กำลังดาวน์โหลด',
|
||||
downloadFallback: 'เบราว์เซอร์ดาวน์โหลด PDF โดยตรงไม่ได้ จึงเปิดหน้า PDF ให้แทน',
|
||||
technicalDetails: 'รายละเอียดทางเทคนิค',
|
||||
generationId: 'รหัสการสร้าง',
|
||||
generationStatus: 'สถานะ',
|
||||
pdfUrl: 'ลิงก์ PDF',
|
||||
|
||||
@@ -297,6 +297,9 @@ export default {
|
||||
generating: '生成中',
|
||||
openPdf: '打开 PDF',
|
||||
downloadPdf: '下载 PDF',
|
||||
downloadingPdf: '下载中',
|
||||
downloadFallback: '浏览器未能直接下载,已尝试打开 PDF 页面。',
|
||||
technicalDetails: '技术详情',
|
||||
generationId: '生成记录 ID',
|
||||
generationStatus: '生成状态',
|
||||
pdfUrl: 'PDF 链接',
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user