From 3c9430ac5f9d6d4e6f122a7384e7b436266a3b7d Mon Sep 17 00:00:00 2001 From: andy Date: Fri, 24 Jul 2026 13:03:46 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=88=BF=E8=A1=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=97=A5=E6=9C=9F=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/tests/roomingListView.spec.ts | 53 ++++++- .../ReservationRoomingListGenerationView.vue | 140 +++++++++++++++++- ...onRoomingListGenerationControllerTest.java | 17 +++ 3 files changed, 202 insertions(+), 8 deletions(-) diff --git a/client/src/tests/roomingListView.spec.ts b/client/src/tests/roomingListView.spec.ts index b868b19..131c2b1 100644 --- a/client/src/tests/roomingListView.spec.ts +++ b/client/src/tests/roomingListView.spec.ts @@ -25,10 +25,10 @@ vi.mock('@/services/roomingListService', async (importOriginal) => { const nativeCreateObjectURL = URL.createObjectURL const nativeRevokeObjectURL = URL.revokeObjectURL -function mountView(options: { permissions?: string[] } = {}) { +function mountView(options: { permissions?: string[]; locale?: 'zh-CN' | 'en-US' | 'th-TH' } = {}) { const i18n = createI18n({ legacy: false, - locale: 'zh-CN', + locale: options.locale ?? 'zh-CN', fallbackLocale: 'zh-CN', messages: { 'zh-CN': zhCN, @@ -301,6 +301,55 @@ describe('ReservationRoomingListGenerationView', () => { expect(consoleErrorSpy).not.toHaveBeenCalled() }) + it('maps backend manual stay date validation details to the active locale', async () => { + vi.mocked(generateReservationRoomingListExcel).mockRejectedValue( + new RoomingListGenerationError('Rooming List 字段校验失败。', 400, 'ROOMING_LIST_VALIDATION_FAILED', [ + 'arrival: 第二种来源名单样式必须填写入住日期(前端提交为空)。', + 'departure: 第二种来源名单样式必须填写离店日期', + ]), + ) + const wrapper = mountView({ locale: 'en-US' }) + await fillRequiredFields(wrapper) + + await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Please fix the form') + expect(wrapper.text()).toContain('Rooming List field validation failed. Please check the form.') + expect(wrapper.text()).toContain('Enter arrival date.') + expect(wrapper.text()).toContain('Enter departure date.') + expect(wrapper.text()).not.toContain('第二种来源名单样式必须填写') + expect(wrapper.find('[data-testid="rooming-list-arrival"]').classes()).toContain('is-invalid') + expect(wrapper.find('[data-testid="rooming-list-departure"]').classes()).toContain('is-invalid') + }) + + it('clears mapped backend stay date field errors when the user edits those fields', async () => { + vi.mocked(generateReservationRoomingListExcel).mockRejectedValue( + new RoomingListGenerationError('Rooming List 字段校验失败。', 400, 'ROOMING_LIST_VALIDATION_FAILED', [ + 'arrival: 第二种来源名单样式必须填写入住日期。', + 'departure: 第二种来源名单样式必须填写离店日期。', + ]), + ) + const wrapper = mountView({ locale: 'en-US' }) + await fillRequiredFields(wrapper) + + await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') + await flushPromises() + await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-05-10') + await flushPromises() + + expect(wrapper.text()).not.toContain('Enter arrival date.') + expect(wrapper.text()).toContain('Enter departure date.') + expect(wrapper.find('[data-testid="rooming-list-arrival"]').classes()).not.toContain('is-invalid') + expect(wrapper.find('[data-testid="rooming-list-departure"]').classes()).toContain('is-invalid') + + await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-05-16') + await flushPromises() + + expect(wrapper.text()).not.toContain('Enter departure date.') + expect(wrapper.find('[data-testid="rooming-list-departure"]').classes()).not.toContain('is-invalid') + }) + it('shows a permission message for 403 errors', async () => { vi.mocked(generateReservationRoomingListExcel).mockRejectedValue( new RoomingListGenerationError('当前用户没有访问该业务能力的权限。', 403, 'FRONTEND_PERMISSION_DENIED', []), diff --git a/client/src/views/reservation/ReservationRoomingListGenerationView.vue b/client/src/views/reservation/ReservationRoomingListGenerationView.vue index 3e7afc0..d38b9f1 100644 --- a/client/src/views/reservation/ReservationRoomingListGenerationView.vue +++ b/client/src/views/reservation/ReservationRoomingListGenerationView.vue @@ -320,6 +320,13 @@ type RoomingListFieldKey = | 'roomType' | 'paymentType' | 'nationality' +type BackendDetailKind = 'arrivalRequired' | 'departureRequired' | 'departureAfterArrival' + +interface BackendDetailMessage { + message: string + fieldKey?: RoomingListFieldKey + kind?: BackendDetailKind +} const allowedPaymentTypes = new Set(['BTQR', 'CA']) const allowedNationalities = new Set(['KR', 'CHN']) @@ -340,7 +347,8 @@ const form = reactive({ const submitting = ref(false) const validationAttempted = ref(false) const messages = ref([]) -const detailMessages = ref([]) +const staticDetailMessages = ref([]) +const backendDetailMessages = ref([]) const generatedFileName = ref('') const alertTone = ref('error') @@ -349,7 +357,16 @@ const sourceFileLabel = computed(() => form.file?.name || t('roomingList.noFile' const alertTitle = computed(() => alertTone.value === 'success' ? t('roomingList.resultSuccess') : t('roomingList.validationTitle'), ) -const fieldErrors = computed(() => (validationAttempted.value ? collectFieldErrors() : {})) +const detailMessages = computed(() => [ + ...staticDetailMessages.value, + ...backendDetailMessages.value + .filter((detail) => isBackendDetailActive(detail)) + .map((detail) => detail.message), +]) +const fieldErrors = computed(() => ({ + ...collectBackendFieldErrors(), + ...(validationAttempted.value ? collectFieldErrors() : {}), +})) function chooseSourceFile(event: Event): void { const input = event.target @@ -369,7 +386,7 @@ async function submitGeneration(): Promise { const errors = collectFieldErrors() if (Object.keys(errors).length > 0) { messages.value = [t('roomingList.errors.FRONTEND_VALIDATION')] - detailMessages.value = Object.values(errors) + staticDetailMessages.value = Object.values(errors) return } if (!form.file) { @@ -408,11 +425,12 @@ function handleGenerationError(error: unknown): void { if (error instanceof RoomingListGenerationError) { const messageKey = `roomingList.errors.${error.errorCode}` messages.value = [te(messageKey) ? t(messageKey) : error.message || t('roomingList.errors.UNKNOWN')] - detailMessages.value = error.details + backendDetailMessages.value = mapBackendDetailMessages(error.details) return } messages.value = [t('roomingList.errors.UNKNOWN')] - detailMessages.value = [] + staticDetailMessages.value = [] + backendDetailMessages.value = [] } function downloadBlob(blob: Blob, fileName: string): void { @@ -429,11 +447,121 @@ function downloadBlob(blob: Blob, fileName: string): void { function clearResult(): void { messages.value = [] - detailMessages.value = [] + staticDetailMessages.value = [] + backendDetailMessages.value = [] generatedFileName.value = '' alertTone.value = 'error' } +function mapBackendDetailMessages(details: string[]): BackendDetailMessage[] { + return details.map((detail) => mapBackendDetailMessage(detail)) +} + +function mapBackendDetailMessage(detail: string): BackendDetailMessage { + const parsed = parseBackendDetail(detail) + if (parsed?.fieldKey === 'arrival' && isArrivalRequiredBackendDetail(parsed.message)) { + return { + message: t('roomingList.fieldErrors.arrivalRequired'), + fieldKey: 'arrival', + kind: 'arrivalRequired', + } + } + if (parsed?.fieldKey === 'departure') { + if (isDepartureRequiredBackendDetail(parsed.message)) { + return { + message: t('roomingList.fieldErrors.departureRequired'), + fieldKey: 'departure', + kind: 'departureRequired', + } + } + if (isDepartureAfterArrivalBackendDetail(parsed.message)) { + return { + message: t('roomingList.fieldErrors.departureAfterArrival'), + fieldKey: 'departure', + kind: 'departureAfterArrival', + } + } + } + return { + message: detail, + } +} + +function parseBackendDetail(detail: string): { fieldKey: RoomingListFieldKey; message: string } | null { + const separatorIndex = detail.indexOf(':') + if (separatorIndex <= 0) { + return null + } + const fieldKey = detail.slice(0, separatorIndex).trim() + if (!isRoomingListFieldKey(fieldKey)) { + return null + } + return { + fieldKey, + message: detail.slice(separatorIndex + 1).trim(), + } +} + +function isRoomingListFieldKey(value: string): value is RoomingListFieldKey { + return ( + value === 'file' || + value === 'peoplePerRoom' || + value === 'arrival' || + value === 'departure' || + value === 'roomType' || + value === 'paymentType' || + value === 'nationality' + ) +} + +function isArrivalRequiredBackendDetail(message: string): boolean { + return message.includes('入住日期') && isRequiredBackendDetail(message) +} + +function isDepartureRequiredBackendDetail(message: string): boolean { + return message.includes('离店日期') && isRequiredBackendDetail(message) +} + +function isRequiredBackendDetail(message: string): boolean { + return message.includes('必须填写') || message.includes('必填') || message.includes('不能为空') +} + +function isDepartureAfterArrivalBackendDetail(message: string): boolean { + return message.includes('晚于') && (message.includes('入住日期') || message.toLowerCase().includes('arrival')) +} + +function collectBackendFieldErrors(): FieldErrors { + const errors: FieldErrors = {} + backendDetailMessages.value.forEach((detail) => { + if (detail.fieldKey && isBackendDetailActive(detail)) { + errors[detail.fieldKey] = detail.message + } + }) + return errors +} + +function isBackendDetailActive(detail: BackendDetailMessage): boolean { + switch (detail.kind) { + case 'arrivalRequired': + return !form.arrival.trim() + case 'departureRequired': + return !form.departure.trim() + case 'departureAfterArrival': { + const arrival = form.arrival.trim() + const departure = form.departure.trim() + return Boolean( + arrival && + departure && + isDateInputValue(arrival) && + isDateInputValue(departure) && + departure <= arrival, + ) + } + default: + return true + } +} + function collectFieldErrors(): FieldErrors { const errors: FieldErrors = {} if (!form.file) { diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerTest.java index 344971b..35672a5 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerTest.java @@ -213,6 +213,23 @@ class ReservationRoomingListGenerationControllerTest { .andExpect(jsonPath("$.details[0]").value("room_type: 必填字段缺失。")); } + @Test + void shouldRejectEnglishNameSourceWhenManualStayDatesMissing() throws Exception { + String token = loginToken(mockMvc, "rooming-admin", "Admin@123456"); + + performAuthorized(mockMvc, token, multipart(ENDPOINT) + .file(sourceFileWithEnglishNames()) + .param("hotel_id", "HOTEL-TEST") + .param("people_per_room", "2") + .param("room_type", "UG1") + .param("payment_type", "BTQR") + .param("nationality", "CHN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED")) + .andExpect(jsonPath("$.details[0]").value("arrival: 第二种来源名单样式必须填写入住日期。")) + .andExpect(jsonPath("$.details[1]").value("departure: 第二种来源名单样式必须填写离店日期。")); + } + @Test void shouldRejectRoomingListGenerationWhenTravelDateFormatInvalid() throws Exception { String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");