实现房表生成第二种名单样式
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user