修复房表生成日期错误提示国际化
This commit is contained in:
@@ -25,10 +25,10 @@ vi.mock('@/services/roomingListService', async (importOriginal) => {
|
|||||||
const nativeCreateObjectURL = URL.createObjectURL
|
const nativeCreateObjectURL = URL.createObjectURL
|
||||||
const nativeRevokeObjectURL = URL.revokeObjectURL
|
const nativeRevokeObjectURL = URL.revokeObjectURL
|
||||||
|
|
||||||
function mountView(options: { permissions?: string[] } = {}) {
|
function mountView(options: { permissions?: string[]; locale?: 'zh-CN' | 'en-US' | 'th-TH' } = {}) {
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'zh-CN',
|
locale: options.locale ?? 'zh-CN',
|
||||||
fallbackLocale: 'zh-CN',
|
fallbackLocale: 'zh-CN',
|
||||||
messages: {
|
messages: {
|
||||||
'zh-CN': zhCN,
|
'zh-CN': zhCN,
|
||||||
@@ -301,6 +301,55 @@ describe('ReservationRoomingListGenerationView', () => {
|
|||||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
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 () => {
|
it('shows a permission message for 403 errors', async () => {
|
||||||
vi.mocked(generateReservationRoomingListExcel).mockRejectedValue(
|
vi.mocked(generateReservationRoomingListExcel).mockRejectedValue(
|
||||||
new RoomingListGenerationError('当前用户没有访问该业务能力的权限。', 403, 'FRONTEND_PERMISSION_DENIED', []),
|
new RoomingListGenerationError('当前用户没有访问该业务能力的权限。', 403, 'FRONTEND_PERMISSION_DENIED', []),
|
||||||
|
|||||||
@@ -320,6 +320,13 @@ type RoomingListFieldKey =
|
|||||||
| 'roomType'
|
| 'roomType'
|
||||||
| 'paymentType'
|
| 'paymentType'
|
||||||
| 'nationality'
|
| 'nationality'
|
||||||
|
type BackendDetailKind = 'arrivalRequired' | 'departureRequired' | 'departureAfterArrival'
|
||||||
|
|
||||||
|
interface BackendDetailMessage {
|
||||||
|
message: string
|
||||||
|
fieldKey?: RoomingListFieldKey
|
||||||
|
kind?: BackendDetailKind
|
||||||
|
}
|
||||||
|
|
||||||
const allowedPaymentTypes = new Set(['BTQR', 'CA'])
|
const allowedPaymentTypes = new Set(['BTQR', 'CA'])
|
||||||
const allowedNationalities = new Set(['KR', 'CHN'])
|
const allowedNationalities = new Set(['KR', 'CHN'])
|
||||||
@@ -340,7 +347,8 @@ const form = reactive({
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const validationAttempted = ref(false)
|
const validationAttempted = ref(false)
|
||||||
const messages = ref<string[]>([])
|
const messages = ref<string[]>([])
|
||||||
const detailMessages = ref<string[]>([])
|
const staticDetailMessages = ref<string[]>([])
|
||||||
|
const backendDetailMessages = ref<BackendDetailMessage[]>([])
|
||||||
const generatedFileName = ref('')
|
const generatedFileName = ref('')
|
||||||
const alertTone = ref<AlertTone>('error')
|
const alertTone = ref<AlertTone>('error')
|
||||||
|
|
||||||
@@ -349,7 +357,16 @@ const sourceFileLabel = computed(() => form.file?.name || t('roomingList.noFile'
|
|||||||
const alertTitle = computed(() =>
|
const alertTitle = computed(() =>
|
||||||
alertTone.value === 'success' ? t('roomingList.resultSuccess') : t('roomingList.validationTitle'),
|
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 {
|
function chooseSourceFile(event: Event): void {
|
||||||
const input = event.target
|
const input = event.target
|
||||||
@@ -369,7 +386,7 @@ async function submitGeneration(): Promise<void> {
|
|||||||
const errors = collectFieldErrors()
|
const errors = collectFieldErrors()
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
messages.value = [t('roomingList.errors.FRONTEND_VALIDATION')]
|
messages.value = [t('roomingList.errors.FRONTEND_VALIDATION')]
|
||||||
detailMessages.value = Object.values(errors)
|
staticDetailMessages.value = Object.values(errors)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!form.file) {
|
if (!form.file) {
|
||||||
@@ -408,11 +425,12 @@ function handleGenerationError(error: unknown): void {
|
|||||||
if (error instanceof RoomingListGenerationError) {
|
if (error instanceof RoomingListGenerationError) {
|
||||||
const messageKey = `roomingList.errors.${error.errorCode}`
|
const messageKey = `roomingList.errors.${error.errorCode}`
|
||||||
messages.value = [te(messageKey) ? t(messageKey) : error.message || t('roomingList.errors.UNKNOWN')]
|
messages.value = [te(messageKey) ? t(messageKey) : error.message || t('roomingList.errors.UNKNOWN')]
|
||||||
detailMessages.value = error.details
|
backendDetailMessages.value = mapBackendDetailMessages(error.details)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
messages.value = [t('roomingList.errors.UNKNOWN')]
|
messages.value = [t('roomingList.errors.UNKNOWN')]
|
||||||
detailMessages.value = []
|
staticDetailMessages.value = []
|
||||||
|
backendDetailMessages.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, fileName: string): void {
|
function downloadBlob(blob: Blob, fileName: string): void {
|
||||||
@@ -429,11 +447,121 @@ function downloadBlob(blob: Blob, fileName: string): void {
|
|||||||
|
|
||||||
function clearResult(): void {
|
function clearResult(): void {
|
||||||
messages.value = []
|
messages.value = []
|
||||||
detailMessages.value = []
|
staticDetailMessages.value = []
|
||||||
|
backendDetailMessages.value = []
|
||||||
generatedFileName.value = ''
|
generatedFileName.value = ''
|
||||||
alertTone.value = 'error'
|
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 {
|
function collectFieldErrors(): FieldErrors {
|
||||||
const errors: FieldErrors = {}
|
const errors: FieldErrors = {}
|
||||||
if (!form.file) {
|
if (!form.file) {
|
||||||
|
|||||||
@@ -213,6 +213,23 @@ class ReservationRoomingListGenerationControllerTest {
|
|||||||
.andExpect(jsonPath("$.details[0]").value("room_type: 必填字段缺失。"));
|
.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
|
@Test
|
||||||
void shouldRejectRoomingListGenerationWhenTravelDateFormatInvalid() throws Exception {
|
void shouldRejectRoomingListGenerationWhenTravelDateFormatInvalid() throws Exception {
|
||||||
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
|
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
|
||||||
|
|||||||
Reference in New Issue
Block a user