diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index 04cf933e..1025fde5 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -9,7 +9,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { isIso6346ContainerNumber } from '../iso6346' -const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>() +const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean; submitBlocked?: boolean }>() const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>() const formRef = ref() const relationWarning = ref('') @@ -195,7 +195,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len -
查看附件引用保存
+
查看附件引用保存
diff --git a/src/modules/trucking/pages/TruckAppointmentCreateView.behavior.test.ts b/src/modules/trucking/pages/TruckAppointmentCreateView.behavior.test.ts new file mode 100644 index 00000000..360a0bd6 --- /dev/null +++ b/src/modules/trucking/pages/TruckAppointmentCreateView.behavior.test.ts @@ -0,0 +1,118 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import TruckAppointmentCreateView from './TruckAppointmentCreateView.vue' +import { createTruckAppointment, listTruckAppointments } from '../api/platformTrucking' +import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types' + +const routing = vi.hoisted(() => ({ query: {} as Record, replace: vi.fn(), push: vi.fn() })) +vi.mock('vue-router', () => ({ useRoute: () => ({ query: routing.query }), useRouter: () => ({ replace: routing.replace, push: routing.push }) })) +vi.mock('../api/platformTrucking', () => ({ createTruckAppointment: vi.fn(), listTruckAppointments: vi.fn() })) + +const existing = (status: TruckAppointmentResource['status']): TruckAppointmentResource => ({ + id: 'appointment-1', appointmentNo: 'TA-260901-0003', status, containerBookingId: 'container-1', attachments: [], +}) +const page = (records: TruckAppointmentResource[]) => ({ records, page: 1, size: 20, total: records.length, pages: 1 }) +const formStub = { + name: 'TruckAppointmentForm', + props: ['modelValue', 'submitting', 'submitBlocked'], + emits: ['update:modelValue', 'submit', 'attachments'], + template: '
', +} +type ViewModel = { + form: SaveTruckAppointmentRequest + submit: () => Promise + lookupExistingAppointment: (id: string, autoNavigate?: boolean) => Promise + existingAppointment: TruckAppointmentResource | null + existingLookupError: { message: string } | null + checkedContainerBookingId: string +} + +function mountView() { + return mount(TruckAppointmentCreateView, { global: { plugins: [ElementPlus], stubs: { TruckAppointmentForm: formStub, TruckAttachmentDialog: true } } }) +} + +describe('TruckAppointmentCreateView existing appointment reuse', () => { + beforeEach(() => { + vi.clearAllMocks() + for (const key of Object.keys(routing.query)) delete routing.query[key] + routing.replace.mockResolvedValue(undefined) + routing.push.mockResolvedValue(undefined) + vi.mocked(listTruckAppointments).mockResolvedValue(page([])) + vi.mocked(createTruckAppointment).mockResolvedValue(existing('DRAFT')) + }) + + it('automatically reuses a DRAFT appointment for a query-prefilled container', async () => { + Object.assign(routing.query, { oceanBookingId: 'booking-1', containerBookingId: 'container-1' }) + vi.mocked(listTruckAppointments).mockResolvedValue(page([existing('DRAFT')])) + const wrapper = mountView() + await flushPromises() + expect(listTruckAppointments).toHaveBeenCalledWith({ containerBookingId: 'container-1', page: 1, size: 20 }) + expect(routing.replace).toHaveBeenCalledWith({ name: 'trucking-detail', params: { id: 'appointment-1' }, query: { edit: '1' } }) + expect(createTruckAppointment).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it.each(['COMPLETED', 'CANCELLED', 'EXCEPTION'] as const)('allows a new appointment after terminal status %s', async (status) => { + Object.assign(routing.query, { containerBookingId: 'container-1' }) + vi.mocked(listTruckAppointments).mockResolvedValue(page([existing(status)])) + const wrapper = mountView() + await flushPromises() + await (wrapper.vm as unknown as ViewModel).submit() + expect(createTruckAppointment).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) + + it('keeps request evidence and retry state when existing-appointment lookup fails', async () => { + Object.assign(routing.query, { containerBookingId: 'container-1' }) + vi.mocked(listTruckAppointments).mockRejectedValue(new ApiResponseError('预约查询失败', 'A0500', 500, undefined, 'req-existing', 'trace-existing')) + const wrapper = mountView() + await flushPromises() + expect(wrapper.text()).toContain('预约查询失败') + expect(wrapper.text()).toContain('req-existing') + expect(wrapper.text()).toContain('trace-existing') + await (wrapper.vm as unknown as ViewModel).submit() + expect(createTruckAppointment).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('ignores a stale lookup response after the selected container changes', async () => { + let resolveFirst!: (value: ReturnType) => void + let resolveSecond!: (value: ReturnType) => void + vi.mocked(listTruckAppointments).mockImplementation((query = {}) => new Promise((resolve) => { + const { containerBookingId } = query + if (containerBookingId === 'container-1') resolveFirst = resolve + else resolveSecond = resolve + })) + const wrapper = mountView() + const vm = wrapper.vm as unknown as ViewModel + vm.form.containerBookingId = 'container-1' + await wrapper.vm.$nextTick() + vm.form.containerBookingId = 'container-2' + await wrapper.vm.$nextTick() + resolveSecond(page([])) + await flushPromises() + resolveFirst(page([existing('DRAFT')])) + await flushPromises() + expect(vm.checkedContainerBookingId).toBe('container-2') + expect(vm.existingAppointment).toBeNull() + expect(routing.replace).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('suppresses duplicate create clicks while the first request is pending', async () => { + Object.assign(routing.query, { containerBookingId: 'container-1' }) + let resolveCreate!: (value: TruckAppointmentResource) => void + vi.mocked(createTruckAppointment).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve })) + const wrapper = mountView() + await flushPromises() + const vm = wrapper.vm as unknown as ViewModel + const first = vm.submit() + const second = vm.submit() + expect(createTruckAppointment).toHaveBeenCalledTimes(1) + resolveCreate(existing('DRAFT')) + await Promise.all([first, second]) + wrapper.unmount() + }) +}) diff --git a/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts b/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts index 2e0926c7..8cde5d1c 100644 --- a/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts +++ b/src/modules/trucking/pages/TruckAppointmentCreateView.test.ts @@ -8,7 +8,7 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => { it('starts with required booking and container choices empty until the user selects them', () => { expect(source).toContain('carrierId: null') expect(source).not.toContain('customerId') - expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null') + expect(source).toContain('oceanBookingId: initialOceanBookingId || null, containerBookingId: initialContainerBookingId || null, loadingPlanId: null') expect(source).toContain('plannedLoadingAt: null') expect(source).toContain("payableAmount: '0.00', currency: 'RMB'") expect(source).not.toContain('receivableAmount') @@ -18,11 +18,22 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => { it('keeps the form visible and exposes the real backend error details', () => { expect(source).toContain("getApiErrorDetails(err, '创建失败,请稍后重试')") expect(source).toContain('') - expect(source).toContain(' { - expect(source).toContain('if (saving.value) return') + expect(source).toContain('if (saving.value || existingLookupLoading.value) return') + }) + + it('reuses non-terminal appointments by container and separates lookup failures from booking-file errors', () => { + expect(source).toContain("listTruckAppointments({ containerBookingId: normalizedId, page: 1, size: 20 })") + expect(source).toContain("new Set(['COMPLETED', 'CANCELLED', 'EXCEPTION'])") + expect(source).toContain('该柜已有拖车预约') + expect(source).toContain("getApiErrorDetails(cause, '已有拖车预约查询失败,请稍后重试')") + expect(source).toContain('') + expect(source).toContain("router.replace({ name: 'trucking-detail'") + expect(source).toContain('serial !== lookupSerial') }) it('places the return action beside the relation note instead of in a separate header row', () => { diff --git a/src/modules/trucking/pages/TruckAppointmentCreateView.vue b/src/modules/trucking/pages/TruckAppointmentCreateView.vue index f633bfde..1916e6cf 100644 --- a/src/modules/trucking/pages/TruckAppointmentCreateView.vue +++ b/src/modules/trucking/pages/TruckAppointmentCreateView.vue @@ -1,6 +1,6 @@