修复房表生成日期错误提示国际化
This commit is contained in:
@@ -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', []),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user