From adb077a7144e80eff1295eb909771a074f2bd213 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:46:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TruckAppointmentForm.behavior.test.ts | 80 ++++++++----------- .../components/TruckAppointmentForm.test.ts | 39 ++++++--- .../components/TruckAppointmentForm.vue | 9 ++- src/modules/trucking/types.ts | 2 +- 4 files changed, 67 insertions(+), 63 deletions(-) diff --git a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts index 008eea6f..7b7ee398 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts @@ -3,16 +3,17 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError } from '@/shared/api/http' import TruckAppointmentForm from './TruckAppointmentForm.vue' -import type { SaveTruckAppointmentRequest } from '../types' -import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings' +import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types' +import { getOceanBooking } from '@/modules/ocean-bookings' +import { listTruckAppointmentCandidates } from '../api/platformTrucking' vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE' }] }), })) vi.mock('@/modules/ocean-bookings', () => ({ - listOceanBookings: vi.fn(), getOceanBooking: vi.fn(), })) +vi.mock('../api/platformTrucking', () => ({ listTruckAppointmentCandidates: vi.fn() })) const model: SaveTruckAppointmentRequest = { expectedVersion: 0, @@ -22,29 +23,28 @@ const model: SaveTruckAppointmentRequest = { carrierId: null, plannedLoadingAt: null, } -const bookings = [ - { id: 'booking-1', bookingNo: 'OB-1', status: 'RELEASED', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓' }, - { id: 'booking-2', bookingNo: 'OB-2', status: 'RELEASED', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓' }, - { id: 'booking-missing-bill', bookingNo: 'OB-3', status: 'RELEASED', billOfLadingNo: null, warehouseId: 'WH-3', warehouseName: '宁波仓' }, +const candidates = [ + { oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-1', containerType: '40HC', requirementQuantity: 2, containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883', loadingPlanId: 'plan-1', loadingPlanNo: 'LP-1', loadingPlanStatus: 'EDITING', bookingDocumentReady: true }, + { oceanBookingId: 'booking-1', oceanBookingNo: 'OB-1', billOfLadingNo: 'BL-1', warehouseId: 'WH-1', warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-1', containerType: '40HC', requirementQuantity: 2, containerBookingId: 'container-pending', cabinetNo: 'CAB-X', containerNo: null, loadingPlanId: null, loadingPlanNo: null, loadingPlanStatus: null, bookingDocumentReady: true }, + { oceanBookingId: 'booking-2', oceanBookingNo: 'OB-2', billOfLadingNo: 'BL-2', warehouseId: 'WH-2', warehouseName: '东莞仓', channelCode: 'SEA', channelName: '海运', requirementId: 'requirement-2', containerType: '40GP', requirementQuantity: 1, containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870', loadingPlanId: null, loadingPlanNo: null, loadingPlanStatus: null, bookingDocumentReady: true }, ] function detail(id: string) { - const booking = bookings.find((item) => item.id === id)! + const candidate = candidates.find((item) => item.oceanBookingId === id)! return { - ...booking, + id, bookingNo: candidate.oceanBookingNo, status: 'RELEASED', billOfLadingNo: candidate.billOfLadingNo, warehouseId: candidate.warehouseId, warehouseName: candidate.warehouseName, documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }], - containers: id === 'booking-1' - ? [ - { containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' }, - { containerBookingId: 'container-pending', cabinetNo: 'CAB-X', containerNo: null, containerType: '40HC' }, - ] - : [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }], + containers: [], } } +function resource(id: string, bookingId: string, containerId: string): TruckAppointmentResource { + const candidate = candidates.find((item) => item.oceanBookingId === bookingId && item.containerBookingId === containerId)! + return { id, status: 'DRAFT', oceanBookingId: bookingId, oceanBookingNo: candidate.oceanBookingNo, containerBookingId: containerId, cabinetNo: candidate.cabinetNo, containerNo: candidate.containerNo, relatedContainerType: candidate.containerType } +} describe('TruckAppointmentForm booking-only behavior', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(listOceanBookings).mockResolvedValue({ records: bookings } as never) + vi.mocked(listTruckAppointmentCandidates).mockResolvedValue({ records: candidates, page: 1, size: 200, total: candidates.length, pages: 1 }) vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never) }) @@ -55,7 +55,8 @@ describe('TruckAppointmentForm booking-only behavior', () => { selects[0]!.vm.$emit('update:modelValue', 'booking-1') await flushPromises() - expect(getOceanBooking).toHaveBeenCalledWith('booking-1') + expect(listTruckAppointmentCandidates).toHaveBeenCalledWith({ page: 1, size: 200 }) + expect(getOceanBooking).not.toHaveBeenCalled() const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || '')) expect(labels.some((label) => label.includes('CAB-1') && label.includes('CBHU8888883'))).toBe(true) expect(labels.some((label) => label.includes('CAB-X') && label.includes('40HC') && label.includes('待补录'))).toBe(true) @@ -87,19 +88,18 @@ describe('TruckAppointmentForm booking-only behavior', () => { wrapper.unmount() }) - it('ignores stale booking details when the user switches selections quickly', async () => { + it('ignores stale current-booking details when a cached edit route changes', async () => { let resolveFirst!: (value: ReturnType) => void let resolveSecond!: (value: ReturnType) => void vi.mocked(getOceanBooking).mockImplementation((id) => new Promise((resolve) => { if (id === 'booking-1') resolveFirst = resolve as typeof resolveFirst else resolveSecond = resolve as typeof resolveSecond }) as never) - const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) + const firstResource = resource('appointment-1', 'booking-1', 'container-1') + const secondResource = resource('appointment-2', 'booking-2', 'container-2') + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: firstResource }, global: { plugins: [ElementPlus] } }) await flushPromises() - const bookingSelect = wrapper.findAllComponents({ name: 'ElSelect' })[0]! - bookingSelect.vm.$emit('update:modelValue', 'booking-1') - await flushPromises() - bookingSelect.vm.$emit('update:modelValue', 'booking-2') + await wrapper.setProps({ modelValue: { ...model, oceanBookingId: 'booking-2', containerBookingId: 'container-2' }, resource: secondResource }) await flushPromises() resolveSecond(detail('booking-2')) @@ -108,20 +108,17 @@ describe('TruckAppointmentForm booking-only behavior', () => { await flushPromises() const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || '')) - expect(labels.some((label) => label.includes('CAB-2'))).toBe(true) + expect(labels.some((label) => label.includes('CAB-2') && label.includes('不可重选'))).toBe(true) expect(labels.some((label) => label.includes('CAB-1'))).toBe(false) expect(wrapper.text()).not.toContain('所选订舱缺少有效的订舱确认文件或提单文件') - const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest - expect(emitted.oceanBookingId).toBe('booking-2') - expect(emitted.billOfLadingNo).toBe('BL-2') + expect(listTruckAppointmentCandidates).not.toHaveBeenCalled() wrapper.unmount() }) it('preserves the business code and request trace when booking readiness fails', async () => { vi.mocked(getOceanBooking).mockRejectedValueOnce(new ApiResponseError('订舱版本冲突', 'A0409', 409, undefined, 'req-booking', 'trace-booking')) - const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) - await flushPromises() - wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1') + const current = resource('appointment-1', 'booking-1', 'container-1') + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: current }, global: { plugins: [ElementPlus] } }) await flushPromises() expect(wrapper.text()).toContain('订舱版本冲突') expect(wrapper.text()).toContain('A0409') @@ -130,26 +127,13 @@ describe('TruckAppointmentForm booking-only behavior', () => { wrapper.unmount() }) - it('blocks a released booking that has no bill of lading number', async () => { - const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) - await flushPromises() - wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-missing-bill') - await flushPromises() - expect(wrapper.text()).toContain('所选订舱缺少有效提单号') - const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest - expect(emitted.billOfLadingNo).toBeNull() - wrapper.unmount() - }) - - it('rejects a booking that becomes unreleased before its detail is loaded', async () => { + it('flags an edit binding whose booking has become unreleased without adding it to create candidates', async () => { vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never) - const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) + const current = resource('appointment-1', 'booking-1', 'container-1') + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model, oceanBookingId: 'booking-1', containerBookingId: 'container-1' }, resource: current }, global: { plugins: [ElementPlus] } }) await flushPromises() - wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'booking-1') - await flushPromises() - expect(wrapper.text()).toContain('所选订舱尚未放舱') - const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest - expect(emitted.containerBookingId).toBeNull() + expect(wrapper.text()).toContain('当前绑定订舱已不是放舱状态') + expect(listTruckAppointmentCandidates).not.toHaveBeenCalled() wrapper.unmount() }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts index f06ff3bb..de199600 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts @@ -7,21 +7,21 @@ const typesSource = readFileSync(resolve(process.cwd(), 'src/modules/trucking/ty describe('TruckAppointmentForm interaction contract', () => { it('requires a released booking before an allocated internal container', () => { - expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })") + expect(source).toContain('listTruckAppointmentCandidates({ page: 1, size: 200 })') expect(source).toContain("oceanBookingId: [{ required: true, message: '请选择已放舱订舱'") - expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下已分配的柜次'") + expect(source).toContain("containerBookingId: [{ required: true, message: '请选择该订舱下可预约的柜次'") expect(source.indexOf('label="订舱"')).toBeLessThan(source.indexOf('label="柜次"')) - expect(source).toContain(':disabled="!draft.oceanBookingId || bookingContainersLoading"') + expect(source).toContain(':disabled="relationLocked || !draft.oceanBookingId || optionsLoading || bookingContainersLoading"') expect(source).not.toContain('关联订舱(可选)') expect(source).not.toContain('关联柜次(可选)') }) - it('loads every container allocated to the selected booking without requiring a physical number', () => { - expect(source).toContain('getOceanBooking(bookingId)') - expect(source).toContain('containers.value = booking.containers || []') - expect(source).toContain('bookingDocuments.value = booking.documents || []') - expect(source).toContain('const selectableContainers = computed(() => containers.value)') - expect(source).toContain('该订舱暂无已分配柜次') + it('uses only server-owned create candidates and never rebuilds availability from booking detail', () => { + expect(source).toContain('candidates.value = results[1].value.records') + expect(source).toContain('candidates.value.filter((item) => item.oceanBookingId === draft.oceanBookingId)') + expect(source).toContain('该订舱暂无可预约柜次') + expect(source).not.toContain('booking.containers') + expect(source).not.toContain('listOceanBookings') expect(source).not.toContain('isIso6346ContainerNumber') expect(source).not.toContain('listContainers') }) @@ -29,7 +29,8 @@ describe('TruckAppointmentForm interaction contract', () => { it('clears an incompatible container whenever the booking changes', () => { expect(source).toContain('draft.containerBookingId = null') expect(source).toContain('draft.loadingPlanId = null') - expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))') + expect(source).toContain('draft.containerBookingId = null') + expect(source).toContain('candidate.loadingPlanId') }) it('freezes warehouse and bill of lading snapshots without retaining customer data', () => { @@ -48,13 +49,29 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).toContain('所选订舱缺少有效提单号') expect(source).toContain("booking.status !== 'RELEASED'") expect(source).toContain('bookingContainersError') - expect(source).toContain("getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')") + expect(source).toContain("getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试')") expect(source).toContain('') expect(source).toContain('重新加载选项') expect(source).toContain('重新加载') expect(source).toContain('请选择有效的合作中拖车供应商') }) + it('freezes the current edit relation and marks its fallback options as non-selectable', () => { + expect(source).toContain('const relationLocked = computed(() => Boolean(props.resource))') + expect(source).toContain('(当前绑定,不可重选)') + expect(source).toContain(':disabled="relationLocked"') + expect(source).toContain(' { + expect(source).toContain('const sequence = ++bookingLoadSequence') + expect(source).toContain("sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId") + expect(source).toContain('const sequence = ++optionsLoadSequence') + expect(source).toContain('if (sequence !== optionsLoadSequence) return') + expect(source).toContain("watch(() => props.resource?.id") + }) + it('labels the internal cabinet, type and pickup-time physical number separately', () => { expect(source).toContain('柜次号:${item.cabinetNo') expect(source).toContain('柜型:${item.containerType') diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index 1380f29c..ea4b8192 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -50,11 +50,12 @@ const candidates = ref([]) const optionsLoading = ref(false) const optionsError = ref('') let bookingLoadSequence = 0 +let optionsLoadSequence = 0 const selectableCarriers = computed(() => carriers.value.filter((item) => ( item.status === 'ACTIVE' ))) const bookings = computed(() => { - const result = new Map() + const result = new Map() for (const item of candidates.value) if (!result.has(item.oceanBookingId)) result.set(item.oceanBookingId, { id: item.oceanBookingId, bookingNo: item.oceanBookingNo, @@ -139,12 +140,14 @@ async function selectBooking(bookingId: string) { draft.billOfLadingNo = item.billOfLadingNo } async function loadOptions() { + const sequence = ++optionsLoadSequence optionsLoading.value = true; optionsError.value = '' - if (relationLocked.value) candidates.value = [] + candidates.value = [] const results = await Promise.allSettled([ listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }), relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }), ]) + if (sequence !== optionsLoadSequence) return if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE') if (results[1].status === 'fulfilled') candidates.value = results[1].value.records const failed = ['承运商', '可预约订舱柜次'].filter((_, index) => results[index]?.status === 'rejected') @@ -179,7 +182,7 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v 重新加载选项 订舱与柜次(必填) - +
{{ bookingContainersError.message }}重新加载
该订舱当前没有可创建拖车预约的柜次。
基本信息 diff --git a/src/modules/trucking/types.ts b/src/modules/trucking/types.ts index 7c80ed90..ecaef01d 100644 --- a/src/modules/trucking/types.ts +++ b/src/modules/trucking/types.ts @@ -207,7 +207,7 @@ export interface TruckAppointmentCandidateQuery { export interface TruckAppointmentCandidate { oceanBookingId: string oceanBookingNo: string - billOfLadingNo: string + billOfLadingNo: string | null warehouseId: string warehouseName: string | null channelCode: string | null