新增房表生成前端页面
This commit is contained in:
@@ -91,6 +91,18 @@ function createManualInvoiceMenu(): AuthMenuResult {
|
||||
}
|
||||
}
|
||||
|
||||
function createRoomingListMenu(): AuthMenuResult {
|
||||
return {
|
||||
menu_code: 'RESERVATION_ROOMING_LIST',
|
||||
menu_name: 'Rooming List',
|
||||
route_path: '/reservation/rooming-lists/new',
|
||||
component_key: 'ReservationRoomingList',
|
||||
icon_key: 'pi pi-file-excel',
|
||||
permission_code: 'RESERVATION_ROOMING_LIST_GENERATE',
|
||||
sort_order: 26,
|
||||
}
|
||||
}
|
||||
|
||||
function createSystemMenu(): AuthMenuResult {
|
||||
return {
|
||||
menu_code: 'SYSTEM_SETTINGS',
|
||||
@@ -137,6 +149,11 @@ async function mountShell(menus = [createReservationOrdersMenu(), createReservat
|
||||
component: { template: '<div />' },
|
||||
meta: { titleKey: 'nav.manualInvoice' },
|
||||
},
|
||||
{
|
||||
path: '/reservation/rooming-lists/new',
|
||||
component: { template: '<div />' },
|
||||
meta: { titleKey: 'nav.roomingList' },
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
component: { template: '<div />' },
|
||||
@@ -199,6 +216,13 @@ describe('ReservationAppShell', () => {
|
||||
expect(wrapper.text()).toContain('手工开票')
|
||||
})
|
||||
|
||||
it('shows Rooming List only when backend menus include it', async () => {
|
||||
const wrapper = await mountShell([createReservationOrdersMenu(), createRoomingListMenu()])
|
||||
|
||||
expect(wrapper.find('a[href="/reservation/rooming-lists/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()])
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('reservation router', () => {
|
||||
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/rooming-lists/new').name).toBe('reservation-rooming-list')
|
||||
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(
|
||||
@@ -69,6 +70,9 @@ describe('reservation router', () => {
|
||||
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/rooming-lists/new').meta.permission).toBe(
|
||||
'RESERVATION_ROOMING_LIST_GENERATE',
|
||||
)
|
||||
expect(router.resolve('/reservation/source-messages/30001/conversation').meta.permission).toBe(
|
||||
'SOURCE_MESSAGE_ORIGINAL_READ',
|
||||
)
|
||||
|
||||
165
client/src/tests/roomingListService.spec.ts
Normal file
165
client/src/tests/roomingListService.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearStoredAccessToken, setStoredAccessToken } from '@/services/authSession'
|
||||
import {
|
||||
generateReservationRoomingListExcel,
|
||||
RoomingListGenerationError,
|
||||
} from '@/services/roomingListService'
|
||||
import { setUnauthorizedHandler } from '@/services/httpClient'
|
||||
import type { RoomingListGenerationInput } from '@/types/roomingList'
|
||||
|
||||
function createInput(): RoomingListGenerationInput {
|
||||
return {
|
||||
file: new File(['passport names'], 'source.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
hotelId: 'HOTEL-TEST',
|
||||
peoplePerRoom: 2,
|
||||
arrival: '2026-07-20',
|
||||
departure: '2026-07-22',
|
||||
roomType: 'DLX',
|
||||
title: 'MR',
|
||||
rateCode: 'BAR',
|
||||
adults: 2,
|
||||
children: 0,
|
||||
paymentType: 'BT',
|
||||
vip: 'VIP1',
|
||||
nationality: 'TH',
|
||||
email: 'guest@example.test',
|
||||
idType: 'Passport',
|
||||
idNumber: 'A1234567',
|
||||
}
|
||||
}
|
||||
|
||||
function createExcelResponse(headers: Record<string, string> = {}): Response {
|
||||
return new Response(new Blob(['xlsx-binary']), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
...headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createJsonResponse(payload: unknown, status: number): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('roomingListService', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
clearStoredAccessToken()
|
||||
setUnauthorizedHandler(null)
|
||||
})
|
||||
|
||||
it('uploads source Excel as FormData and returns the generated workbook blob', async () => {
|
||||
const input = createInput()
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
createExcelResponse({
|
||||
'Content-Disposition': 'attachment; filename="rooming-list-HOTEL-TEST-20260718.xlsx"',
|
||||
}),
|
||||
)
|
||||
setStoredAccessToken('session-token')
|
||||
|
||||
const result = await generateReservationRoomingListExcel(input)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/api/reservation/rooming-lists/generations')
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
Authorization: 'Bearer session-token',
|
||||
},
|
||||
})
|
||||
expect(init?.headers).not.toHaveProperty('Content-Type')
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
const formData = init?.body as FormData
|
||||
expect(formData.get('file')).toBe(input.file)
|
||||
expect(formData.get('hotel_id')).toBe('HOTEL-TEST')
|
||||
expect(formData.get('people_per_room')).toBe('2')
|
||||
expect(formData.get('arrival')).toBe('2026-07-20')
|
||||
expect(formData.get('departure')).toBe('2026-07-22')
|
||||
expect(formData.get('room_type')).toBe('DLX')
|
||||
expect(formData.get('title')).toBe('MR')
|
||||
expect(formData.get('rate_code')).toBe('BAR')
|
||||
expect(formData.get('adults')).toBe('2')
|
||||
expect(formData.get('children')).toBe('0')
|
||||
expect(formData.get('payment_type')).toBe('BT')
|
||||
expect(formData.get('vip')).toBe('VIP1')
|
||||
expect(formData.get('nationality')).toBe('TH')
|
||||
expect(formData.get('email')).toBe('guest@example.test')
|
||||
expect(formData.get('id_type')).toBe('Passport')
|
||||
expect(formData.get('id_number')).toBe('A1234567')
|
||||
expect(result.fileName).toBe('rooming-list-HOTEL-TEST-20260718.xlsx')
|
||||
expect(result.blob.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
})
|
||||
|
||||
it('uses the default file name when Content-Disposition does not provide one', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(createExcelResponse())
|
||||
|
||||
const result = await generateReservationRoomingListExcel({
|
||||
...createInput(),
|
||||
hotelId: undefined,
|
||||
title: '',
|
||||
rateCode: '',
|
||||
adults: null,
|
||||
children: null,
|
||||
paymentType: '',
|
||||
vip: '',
|
||||
nationality: '',
|
||||
email: '',
|
||||
idType: '',
|
||||
idNumber: '',
|
||||
})
|
||||
|
||||
expect(result.fileName).toBe('rooming-list.xlsx')
|
||||
})
|
||||
|
||||
it('throws a typed error with backend details for validation failures', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
createJsonResponse(
|
||||
{
|
||||
error_code: 'ROOMING_LIST_VALIDATION_FAILED',
|
||||
message: 'Rooming List 字段校验失败。',
|
||||
details: ['people_per_room: 必填字段缺失。', 'departure: 必须晚于 arrival。'],
|
||||
},
|
||||
400,
|
||||
),
|
||||
)
|
||||
|
||||
await expect(generateReservationRoomingListExcel(createInput())).rejects.toMatchObject({
|
||||
status: 400,
|
||||
errorCode: 'ROOMING_LIST_VALIDATION_FAILED',
|
||||
message: 'Rooming List 字段校验失败。',
|
||||
details: ['people_per_room: 必填字段缺失。', 'departure: 必须晚于 arrival。'],
|
||||
} satisfies Partial<RoomingListGenerationError>)
|
||||
})
|
||||
|
||||
it('runs the shared unauthorized handler for auth session failures', async () => {
|
||||
const unauthorizedHandler = vi.fn()
|
||||
setUnauthorizedHandler(unauthorizedHandler)
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
createJsonResponse(
|
||||
{
|
||||
error_code: 'AUTH_SESSION_INVALID',
|
||||
message: '登录已失效,请重新登录。',
|
||||
details: [],
|
||||
},
|
||||
401,
|
||||
),
|
||||
)
|
||||
|
||||
await expect(generateReservationRoomingListExcel(createInput())).rejects.toMatchObject({
|
||||
status: 401,
|
||||
errorCode: 'AUTH_SESSION_INVALID',
|
||||
} satisfies Partial<RoomingListGenerationError>)
|
||||
expect(unauthorizedHandler).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
252
client/src/tests/roomingListView.spec.ts
Normal file
252
client/src/tests/roomingListView.spec.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import enUS from '@/i18n/locales/en-US'
|
||||
import thTH from '@/i18n/locales/th-TH'
|
||||
import zhCN from '@/i18n/locales/zh-CN'
|
||||
import {
|
||||
generateReservationRoomingListExcel,
|
||||
RoomingListGenerationError,
|
||||
} from '@/services/roomingListService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import ReservationRoomingListGenerationView from '@/views/reservation/ReservationRoomingListGenerationView.vue'
|
||||
|
||||
vi.mock('@/services/roomingListService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/services/roomingListService')>()
|
||||
return {
|
||||
...actual,
|
||||
generateReservationRoomingListExcel: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const nativeCreateObjectURL = URL.createObjectURL
|
||||
const nativeRevokeObjectURL = URL.revokeObjectURL
|
||||
|
||||
function mountView(options: { permissions?: string[] } = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
fallbackLocale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS,
|
||||
'th-TH': thTH,
|
||||
},
|
||||
})
|
||||
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: 'Asia/Bangkok',
|
||||
default_hotel: true,
|
||||
},
|
||||
],
|
||||
permissions: options.permissions ?? ['RESERVATION_ROOMING_LIST_GENERATE'],
|
||||
menus: [],
|
||||
})
|
||||
|
||||
return mount(ReservationRoomingListGenerationView, {
|
||||
global: {
|
||||
plugins: [i18n, pinia],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseFile(wrapper: ReturnType<typeof mountView>, file: File) {
|
||||
const input = wrapper.find('[data-testid="rooming-list-file"]')
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
configurable: true,
|
||||
value: [file],
|
||||
})
|
||||
await input.trigger('change')
|
||||
}
|
||||
|
||||
async function fillRequiredFields(wrapper: ReturnType<typeof mountView>) {
|
||||
await chooseFile(
|
||||
wrapper,
|
||||
new File(['passport names'], 'source.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
)
|
||||
await wrapper.find('[data-testid="rooming-list-people-per-room"]').setValue('2')
|
||||
await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-07-20')
|
||||
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-22')
|
||||
await wrapper.find('[data-testid="rooming-list-room-type"]').setValue('DLX')
|
||||
}
|
||||
|
||||
describe('ReservationRoomingListGenerationView', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(generateReservationRoomingListExcel).mockReset()
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:rooming-list'),
|
||||
})
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
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('blocks submit until required upload and target fields are provided', async () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('请选择来源 Excel 文件。')
|
||||
expect(wrapper.text()).toContain('每间房人数必须大于 0。')
|
||||
expect(wrapper.text()).toContain('请填写入住日期。')
|
||||
expect(wrapper.text()).toContain('请填写离店日期。')
|
||||
expect(wrapper.text()).toContain('请填写房型。')
|
||||
})
|
||||
|
||||
it('blocks departure dates that are not later than arrival', async () => {
|
||||
const wrapper = mountView()
|
||||
await fillRequiredFields(wrapper)
|
||||
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-20')
|
||||
|
||||
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('离店日期必须晚于入住日期。')
|
||||
})
|
||||
|
||||
it('submits required and optional fields, then downloads the returned workbook', async () => {
|
||||
vi.mocked(generateReservationRoomingListExcel).mockResolvedValue({
|
||||
blob: new Blob(['xlsx-binary'], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
fileName: 'rooming-list-HOTEL-TEST.xlsx',
|
||||
})
|
||||
const wrapper = mountView()
|
||||
await fillRequiredFields(wrapper)
|
||||
await wrapper.find('[data-testid="rooming-list-title"]').setValue('MR')
|
||||
await wrapper.find('[data-testid="rooming-list-rate-code"]').setValue('BAR')
|
||||
await wrapper.find('[data-testid="rooming-list-adults"]').setValue('2')
|
||||
await wrapper.find('[data-testid="rooming-list-children"]').setValue('0')
|
||||
await wrapper.find('[data-testid="rooming-list-payment-type"]').setValue('BT')
|
||||
await wrapper.find('[data-testid="rooming-list-vip"]').setValue('VIP1')
|
||||
await wrapper.find('[data-testid="rooming-list-nationality"]').setValue('TH')
|
||||
await wrapper.find('[data-testid="rooming-list-email"]').setValue('guest@example.test')
|
||||
await wrapper.find('[data-testid="rooming-list-id-type"]').setValue('Passport')
|
||||
await wrapper.find('[data-testid="rooming-list-id-number"]').setValue('A1234567')
|
||||
|
||||
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(generateReservationRoomingListExcel).toHaveBeenCalledWith({
|
||||
file: expect.any(File),
|
||||
hotelId: 'HOTEL-TEST',
|
||||
peoplePerRoom: 2,
|
||||
arrival: '2026-07-20',
|
||||
departure: '2026-07-22',
|
||||
roomType: 'DLX',
|
||||
title: 'MR',
|
||||
rateCode: 'BAR',
|
||||
adults: 2,
|
||||
children: 0,
|
||||
paymentType: 'BT',
|
||||
vip: 'VIP1',
|
||||
nationality: 'TH',
|
||||
email: 'guest@example.test',
|
||||
idType: 'Passport',
|
||||
idNumber: 'A1234567',
|
||||
})
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce()
|
||||
expect(wrapper.text()).toContain('rooming-list-HOTEL-TEST.xlsx')
|
||||
})
|
||||
|
||||
it('does not submit the same source file again while generation is pending', async () => {
|
||||
let resolveGeneration!: (value: { blob: Blob; fileName: string }) => void
|
||||
vi.mocked(generateReservationRoomingListExcel).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveGeneration = resolve
|
||||
}),
|
||||
)
|
||||
const wrapper = mountView()
|
||||
await fillRequiredFields(wrapper)
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(generateReservationRoomingListExcel).toHaveBeenCalledTimes(1)
|
||||
resolveGeneration({
|
||||
blob: new Blob(['xlsx-binary'], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
fileName: 'rooming-list-HOTEL-TEST.xlsx',
|
||||
})
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('shows backend validation details without logging uploaded content', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
vi.mocked(generateReservationRoomingListExcel).mockRejectedValue(
|
||||
new RoomingListGenerationError('Rooming List 字段校验失败。', 400, 'ROOMING_LIST_VALIDATION_FAILED', [
|
||||
'file: 未找到表头:护照全名',
|
||||
]),
|
||||
)
|
||||
const wrapper = mountView()
|
||||
await fillRequiredFields(wrapper)
|
||||
|
||||
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Rooming List 字段校验失败,请检查表单内容。')
|
||||
expect(wrapper.text()).toContain('file: 未找到表头:护照全名')
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a permission message for 403 errors', async () => {
|
||||
vi.mocked(generateReservationRoomingListExcel).mockRejectedValue(
|
||||
new RoomingListGenerationError('当前用户没有访问该业务能力的权限。', 403, 'FRONTEND_PERMISSION_DENIED', []),
|
||||
)
|
||||
const wrapper = mountView()
|
||||
await fillRequiredFields(wrapper)
|
||||
|
||||
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('当前账号没有生成 Rooming List 的权限。')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user