修复房表生成日期错误提示国际化

This commit is contained in:
andy
2026-07-24 13:03:46 +07:00
parent eaea8787d9
commit 3c9430ac5f
3 changed files with 202 additions and 8 deletions

View File

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

View File

@@ -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<string[]>([])
const detailMessages = ref<string[]>([])
const staticDetailMessages = ref<string[]>([])
const backendDetailMessages = ref<BackendDetailMessage[]>([])
const generatedFileName = ref('')
const alertTone = ref<AlertTone>('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<FieldErrors>(() => (validationAttempted.value ? collectFieldErrors() : {}))
const detailMessages = computed<string[]>(() => [
...staticDetailMessages.value,
...backendDetailMessages.value
.filter((detail) => isBackendDetailActive(detail))
.map((detail) => detail.message),
])
const fieldErrors = computed<FieldErrors>(() => ({
...collectBackendFieldErrors(),
...(validationAttempted.value ? collectFieldErrors() : {}),
}))
function chooseSourceFile(event: Event): void {
const input = event.target
@@ -369,7 +386,7 @@ async function submitGeneration(): Promise<void> {
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) {

View File

@@ -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");