实现房表生成第二种名单样式

This commit is contained in:
andy
2026-07-24 11:55:29 +07:00
parent 14eda89b0b
commit eaea8787d9
22 changed files with 645 additions and 64 deletions

View File

@@ -402,7 +402,7 @@ export default {
eyebrow: 'Reservation tool',
title: 'Generate Rooming List Excel',
subtitle:
'Upload the travel group list, enter people per room, room type, payment type and nationality. Arrival and departure are derived from the source travel dates.',
'Upload the travel group list, enter people per room, room type, payment type and nationality. Lists with travel dates derive stay dates automatically; English-name lists require manual dates.',
currentHotel: 'Current hotel',
sourceSection: 'Source list and required fields',
targetSection: 'Target Excel defaults',
@@ -412,6 +412,7 @@ export default {
peoplePerRoom: 'People per room',
arrival: 'Arrival',
departure: 'Departure',
datePlaceholder: 'yyyy-MM-dd',
roomType: 'Room type',
roomTypePlaceholder: 'e.g. DLX / TWN',
titleColumn: 'Title',

View File

@@ -402,7 +402,7 @@ export default {
eyebrow: 'เครื่องมือ Reservation',
title: 'สร้าง Rooming List Excel',
subtitle:
'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality โดย Arrival/Departure จะมาจาก Travel Date ในไฟล์ต้นทาง',
'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality ไฟล์ที่มี Travel Date จะสร้างวันที่ให้อัตโนมัติ ส่วนไฟล์ English name ต้องกรอกวันที่เอง',
currentHotel: 'โรงแรมปัจจุบัน',
sourceSection: 'ไฟล์รายชื่อและข้อมูลจำเป็น',
targetSection: 'ค่าเริ่มต้นของ Excel ปลายทาง',
@@ -412,6 +412,7 @@ export default {
peoplePerRoom: 'จำนวนคนต่อห้อง',
arrival: 'Arrival',
departure: 'Departure',
datePlaceholder: 'yyyy-MM-dd',
roomType: 'Room Type',
roomTypePlaceholder: 'เช่น DLX / TWN',
titleColumn: 'Title',

View File

@@ -401,7 +401,7 @@ export default {
roomingList: {
eyebrow: '预订工具',
title: '生成 Rooming List Excel',
subtitle: '上传旅行团名单 Excel填写每房人数、房型、付款方式和国籍入住/离店日期由来源表的旅游日期生成。',
subtitle: '上传旅行团名单 Excel填写每房人数、房型、付款方式和国籍有旅游日期的名单自动生成入住/离店日期,英文姓/英文名名单需手填日期。',
currentHotel: '当前酒店',
sourceSection: '来源名单与必填信息',
targetSection: '目标 Excel 默认值',
@@ -411,6 +411,7 @@ export default {
peoplePerRoom: '每间房人数',
arrival: '入住日期',
departure: '离店日期',
datePlaceholder: 'yyyy-MM-dd',
roomType: '房型',
roomTypePlaceholder: '例如 DLX / TWN',
titleColumn: '称谓Title',

View File

@@ -72,6 +72,8 @@ function buildRoomingListForm(input: RoomingListGenerationInput): FormData {
form.append('file', input.file)
appendOptionalText(form, 'hotel_id', input.hotelId)
form.append('people_per_room', String(input.peoplePerRoom))
appendOptionalText(form, 'arrival', input.arrival)
appendOptionalText(form, 'departure', input.departure)
form.append('room_type', input.roomType)
appendOptionalText(form, 'payment_type', input.paymentType)
appendOptionalText(form, 'nationality', input.nationality)

View File

@@ -15,6 +15,8 @@ function createInput(): RoomingListGenerationInput {
}),
hotelId: 'HOTEL-TEST',
peoplePerRoom: 2,
arrival: '2026-05-10',
departure: '2026-05-16',
roomType: 'DLX',
paymentType: 'CA',
nationality: 'CHN',
@@ -74,11 +76,11 @@ describe('roomingListService', () => {
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-05-10')
expect(formData.get('departure')).toBe('2026-05-16')
expect(formData.get('room_type')).toBe('DLX')
expect(formData.get('payment_type')).toBe('CA')
expect(formData.get('nationality')).toBe('CHN')
expect(formData.get('arrival')).toBeNull()
expect(formData.get('departure')).toBeNull()
expect(formData.get('title')).toBeNull()
expect(formData.get('rate_code')).toBeNull()
expect(formData.get('adults')).toBeNull()

View File

@@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -62,7 +63,7 @@ function mountView(options: { permissions?: string[] } = {}) {
return mount(ReservationRoomingListGenerationView, {
global: {
plugins: [i18n, pinia],
plugins: [i18n, pinia, PrimeVue],
},
})
}
@@ -91,6 +92,20 @@ async function fillRequiredFields(wrapper: ReturnType<typeof mountView>) {
describe('ReservationRoomingListGenerationView', () => {
beforeEach(() => {
vi.mocked(generateReservationRoomingListExcel).mockReset()
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:rooming-list'),
@@ -129,17 +144,19 @@ describe('ReservationRoomingListGenerationView', () => {
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请先修正表单')
expect(wrapper.text()).toContain('表单校验未通过,请检查必填字段。')
expect(wrapper.text()).toContain('请选择来源 Excel 文件。')
expect(wrapper.text()).toContain('每间房人数必须大于 0。')
expect(wrapper.text()).toContain('请填写房型。')
expect(wrapper.text()).toContain('请选择国籍。')
})
it('does not render manually entered dates or removed target defaults', () => {
it('renders optional manual stay dates but keeps removed target defaults hidden', () => {
const wrapper = mountView()
expect(wrapper.find('[data-testid="rooming-list-arrival"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="rooming-list-departure"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="rooming-list-arrival"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="rooming-list-departure"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="rooming-list-title"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="rooming-list-rate-code"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="rooming-list-adults"]').exists()).toBe(false)
@@ -167,6 +184,8 @@ describe('ReservationRoomingListGenerationView', () => {
file: expect.any(File),
hotelId: 'HOTEL-TEST',
peoplePerRoom: 2,
arrival: '',
departure: '',
roomType: 'DLX',
paymentType: 'BTQR',
nationality: 'CHN',
@@ -197,6 +216,49 @@ describe('ReservationRoomingListGenerationView', () => {
)
})
it('submits optional manual stay dates when they are provided', 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-arrival"]').setValue('2026-05-10')
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-05-16')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).toHaveBeenCalledWith(
expect.objectContaining({
arrival: '2026-05-10',
departure: '2026-05-16',
}),
)
})
it('blocks submit when optional manual stay dates are incomplete or out of order', async () => {
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-05-16')
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-05-10')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('离店日期必须晚于入住日期。')
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请填写离店日期。')
})
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(

View File

@@ -12,6 +12,8 @@ export interface RoomingListGenerationInput {
file: File
hotelId?: string | null
peoplePerRoom: number
arrival?: string | null
departure?: string | null
roomType: string
paymentType?: string | null
nationality: string

View File

@@ -116,6 +116,56 @@
{{ fieldError('roomType') }}
</span>
</label>
<div class="field">
<label
for="rooming-list-arrival-input"
class="field-label"
>
{{ t('roomingList.arrival') }}
</label>
<LocalizedDatePicker
v-model="form.arrival"
input-id="rooming-list-arrival-input"
test-id="rooming-list-arrival"
:placeholder="t('roomingList.datePlaceholder')"
:input-class="validationClass('arrival')"
:invalid="Boolean(fieldError('arrival'))"
:described-by="fieldError('arrival') ? fieldErrorId('arrival') : undefined"
/>
<span
v-if="fieldError('arrival')"
:id="fieldErrorId('arrival')"
class="field-error"
>
{{ fieldError('arrival') }}
</span>
</div>
<div class="field">
<label
for="rooming-list-departure-input"
class="field-label"
>
{{ t('roomingList.departure') }}
</label>
<LocalizedDatePicker
v-model="form.departure"
input-id="rooming-list-departure-input"
test-id="rooming-list-departure"
:placeholder="t('roomingList.datePlaceholder')"
:input-class="validationClass('departure')"
:invalid="Boolean(fieldError('departure'))"
:described-by="fieldError('departure') ? fieldErrorId('departure') : undefined"
/>
<span
v-if="fieldError('departure')"
:id="fieldErrorId('departure')"
class="field-error"
>
{{ fieldError('departure') }}
</span>
</div>
</div>
</section>
@@ -252,6 +302,7 @@
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LocalizedDatePicker from '@/components/common/LocalizedDatePicker.vue'
import {
generateReservationRoomingListExcel,
RoomingListGenerationError,
@@ -264,6 +315,8 @@ type AlertTone = 'success' | 'error'
type RoomingListFieldKey =
| 'file'
| 'peoplePerRoom'
| 'arrival'
| 'departure'
| 'roomType'
| 'paymentType'
| 'nationality'
@@ -277,6 +330,8 @@ const authStore = useAuthStore()
const form = reactive({
file: null as File | null,
peoplePerRoom: '',
arrival: '',
departure: '',
roomType: '',
paymentType: 'BTQR',
nationality: '',
@@ -340,6 +395,8 @@ function buildGenerationInput(file: File): RoomingListGenerationInput {
file,
hotelId: authStore.selectedHotelId,
peoplePerRoom: Number(form.peoplePerRoom),
arrival: form.arrival,
departure: form.departure,
roomType: form.roomType,
paymentType: form.paymentType,
nationality: form.nationality,
@@ -387,6 +444,21 @@ function collectFieldErrors(): FieldErrors {
if (!isPositiveInteger(form.peoplePerRoom)) {
errors.peoplePerRoom = t('roomingList.fieldErrors.positiveInteger')
}
const arrival = form.arrival.trim()
const departure = form.departure.trim()
if (arrival && !isDateInputValue(arrival)) {
errors.arrival = t('roomingList.fieldErrors.dateFormat')
}
if (departure && !isDateInputValue(departure)) {
errors.departure = t('roomingList.fieldErrors.dateFormat')
}
if (arrival && !departure) {
errors.departure = t('roomingList.fieldErrors.departureRequired')
} else if (!arrival && departure) {
errors.arrival = t('roomingList.fieldErrors.arrivalRequired')
} else if (arrival && departure && isDateInputValue(arrival) && isDateInputValue(departure) && departure <= arrival) {
errors.departure = t('roomingList.fieldErrors.departureAfterArrival')
}
if (!form.roomType.trim()) {
errors.roomType = t('roomingList.fieldErrors.roomTypeRequired')
}
@@ -438,6 +510,14 @@ function isPositiveInteger(value: unknown): boolean {
return /^[1-9]\d*$/.test(normalizeInputText(value))
}
function isDateInputValue(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false
}
const date = new Date(`${value}T00:00:00Z`)
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
}
function normalizeInputText(value: unknown): string {
if (typeof value === 'string') {
return value.trim()