实现 M009 手工开票前端页面

This commit is contained in:
andy
2026-07-17 11:57:26 +07:00
parent 11478c6913
commit 72fee44a68
16 changed files with 2032 additions and 3 deletions

View 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')
})
})

View 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')
})
})

View File

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

View File

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