实现 M009 手工开票前端页面
This commit is contained in:
115
client/src/tests/manualInvoiceService.spec.ts
Normal file
115
client/src/tests/manualInvoiceService.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
233
client/src/tests/manualInvoiceView.spec.ts
Normal file
233
client/src/tests/manualInvoiceView.spec.ts
Normal file
@@ -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<typeof import('@/services/reservationService')>()
|
||||
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<typeof mountView>) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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: '<div />' },
|
||||
meta: { titleKey: 'debugEml.title' },
|
||||
},
|
||||
{
|
||||
path: '/reservation/invoices/new',
|
||||
component: { template: '<div />' },
|
||||
meta: { titleKey: 'nav.manualInvoice' },
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
component: { template: '<div />' },
|
||||
@@ -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()])
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user