From c59608ff2cf87d75d1273531b616cb0bb8a5ab51 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:43:19 +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 --- .../trucking/api/platformTrucking.test.ts | 11 +- src/modules/trucking/api/platformTrucking.ts | 38 +++++++ .../TruckAppointmentForm.behavior.test.ts | 30 ++++++ .../components/TruckAppointmentForm.vue | 102 ++++++++++-------- src/modules/trucking/types.ts | 34 ++++++ 5 files changed, 172 insertions(+), 43 deletions(-) diff --git a/src/modules/trucking/api/platformTrucking.test.ts b/src/modules/trucking/api/platformTrucking.test.ts index b029bd54..2971ae9b 100644 --- a/src/modules/trucking/api/platformTrucking.test.ts +++ b/src/modules/trucking/api/platformTrucking.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking' +import { createTruckAppointment, deleteTruckAppointmentAttachment, getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentCandidates, listTruckAppointmentHistory, listTruckAppointments, performTruckAppointmentAction, referenceTruckAppointmentAttachment, rescheduleTruckAppointment, uploadTruckAppointmentAttachment } from './platformTrucking' vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data })) @@ -13,6 +13,15 @@ describe('truck appointment API', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerBookingId: '77' } }) }) + it('uses the server-owned create candidate contract without rebuilding availability', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { records: [{ oceanBookingId: 11, oceanBookingNo: 'OB-11', billOfLadingNo: 'BL-11', warehouseId: 12, warehouseName: '义乌仓', channelCode: 'SEA', channelName: '海运', requirementId: 13, containerType: '40HC', requirementQuantity: '2', containerBookingId: 14, cabinetNo: 'CAB-14', containerNo: null, loadingPlanId: 15, loadingPlanNo: 'LP-15', loadingPlanStatus: 'EDITING', bookingDocumentReady: true }], page: '1', size: '200', total: '1', pages: '1' } }) + await expect(listTruckAppointmentCandidates({ keyword: ' OB ', page: 1, size: 200 })).resolves.toMatchObject({ + records: [{ oceanBookingId: '11', warehouseId: '12', requirementId: '13', containerBookingId: '14', loadingPlanId: '15', requirementQuantity: 2, bookingDocumentReady: true }], + total: 1, + }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments/candidates', { params: { keyword: 'OB', page: 1, size: 200 } }) + }) + it('uses detail, create, and status contracts', async () => { vi.mocked(http.get).mockResolvedValue({ data: { id: '9001', status: 'DRAFT' } }) vi.mocked(http.post).mockResolvedValue({ data: '9001' }) diff --git a/src/modules/trucking/api/platformTrucking.ts b/src/modules/trucking/api/platformTrucking.ts index 40fceb4e..9074ba2f 100644 --- a/src/modules/trucking/api/platformTrucking.ts +++ b/src/modules/trucking/api/platformTrucking.ts @@ -6,6 +6,9 @@ import type { TruckAppointmentQuery, TruckAppointmentResource, TruckAppointmentActionRequest, + TruckAppointmentCandidate, + TruckAppointmentCandidatePage, + TruckAppointmentCandidateQuery, TruckAppointmentStatusHistory, TruckAttachmentReference, TruckAttachmentType, @@ -60,6 +63,41 @@ export async function listTruckAppointments(query: TruckAppointmentQuery = {}) { return normalizePage(unwrapApiResponse(response)) } +type CandidatePagePayload = Omit & { + records?: Array> + page: number | string + size: number | string + total: number | string + pages?: number | string +} + +function normalizeCandidate(candidate: TruckAppointmentCandidate & Record): TruckAppointmentCandidate { + const normalized = { ...candidate } as Record + for (const key of ['oceanBookingId', 'warehouseId', 'requirementId', 'containerBookingId', 'loadingPlanId']) { + if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key]) + } + normalized.requirementQuantity = Number(normalized.requirementQuantity || 0) + normalized.bookingDocumentReady = normalized.bookingDocumentReady === true + return normalized as unknown as TruckAppointmentCandidate +} + +export async function listTruckAppointmentCandidates(query: TruckAppointmentCandidateQuery = {}) { + const response = await http.get, ApiEnvelope>(`${basePath}/candidates`, { + params: { keyword: query.keyword?.trim() || undefined, page: query.page || 1, size: query.size || 200 }, + }) + const payload = unwrapApiResponse(response) + const page = Number(payload.page || 1) + const size = Number(payload.size || 200) + const total = Number(payload.total || 0) + return { + records: (payload.records || []).map(normalizeCandidate), + page, + size, + total, + pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)), + } satisfies TruckAppointmentCandidatePage +} + export async function getTruckAppointment(id: string | number) { const response = await http.get, ApiEnvelope>(`${basePath}/${id}`) return normalizeResource(unwrapApiResponse(response)) diff --git a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts index f5fe46b2..008eea6f 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts @@ -87,6 +87,36 @@ describe('TruckAppointmentForm booking-only behavior', () => { wrapper.unmount() }) + it('ignores stale booking details when the user switches selections quickly', 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] } }) + 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 flushPromises() + + resolveSecond(detail('booking-2')) + await flushPromises() + resolveFirst({ ...detail('booking-1'), documents: [] }) + 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-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') + 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] } }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index 3ed53071..1380f29c 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -2,9 +2,10 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import type { FormInstance, FormRules } from 'element-plus' import { Eye, Save } from '@lucide/vue' -import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types' +import { TRUCK_APPOINTMENT_CURRENCIES, normalizeTruckAppointmentCurrency, type SaveTruckAppointmentRequest, type TruckAppointmentCandidate, type TruckAppointmentResource } from '../types' +import { listTruckAppointmentCandidates } from '../api/platformTrucking' import { listSuppliers, type Supplier } from '@/modules/suppliers' -import { getOceanBooking, listOceanBookings, type OceanBookingAllocatedContainer, type OceanBookingDocument, type OceanBookingResource } from '@/modules/ocean-bookings' +import { getOceanBooking, type OceanBookingDocument } from '@/modules/ocean-bookings' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' @@ -15,6 +16,7 @@ const relationWarning = ref('') const bookingContainersLoading = ref(false) const bookingContainersError = ref(null) const bookingDocuments = ref([]) +const relationLocked = computed(() => Boolean(props.resource)) const defaults: Partial = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 } function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: normalizeTruckAppointmentCurrency(value.currency) } } const draft = reactive(normalizeDraft(props.modelValue)) @@ -31,12 +33,12 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno }, trigger: 'blur' }) const rules: FormRules = { oceanBookingId: [{ required: true, message: '请选择已放舱订舱', trigger: 'change' }, { validator: (_rule, value, callback) => { - if (!(bookings.value.some((item) => item.id === String(value) && item.status === 'RELEASED') || selectedBookingFallback.value?.id === String(value))) return callback(new Error('请选择有效的已放舱订舱')) + if (!(bookings.value.some((item) => item.id === String(value)) || (relationLocked.value && selectedBookingFallback.value?.id === String(value)))) return callback(new Error('请选择仍有可预约柜次的已放舱订舱')) if (bookingContainersError.value) return callback(new Error('订舱资料状态加载失败,请重试')) if (!draft.billOfLadingNo?.trim()) return callback(new Error('所选订舱缺少有效提单号')) return callback(bookingDocumentReady.value ? undefined : new Error('所选订舱缺少有效的订舱确认文件或提单文件')) }, trigger: 'change' }], - containerBookingId: [{ required: true, message: '请选择该订舱下已分配的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (props.readonly && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下有效的已分配柜次')), trigger: 'change' }], + containerBookingId: [{ required: true, message: '请选择该订舱下可预约的柜次', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableContainers.value.some((item) => item.containerBookingId === String(value)) || (relationLocked.value && selectedContainerFallback.value?.id === String(value)) ? undefined : new Error('请选择该订舱下仍可预约的柜次')), trigger: 'change' }], carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }], plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }], fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')], @@ -44,33 +46,47 @@ const rules: FormRules = { grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], } const carriers = ref([]) -const containers = ref([]) -const bookings = ref([]) +const candidates = ref([]) const optionsLoading = ref(false) const optionsError = ref('') +let bookingLoadSequence = 0 const selectableCarriers = computed(() => carriers.value.filter((item) => ( item.status === 'ACTIVE' ))) -const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null) -const bookingDocumentReady = computed(() => bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) -const selectableContainers = computed(() => containers.value) -function containerOptionLabel(item: OceanBookingAllocatedContainer) { +const bookings = computed(() => { + 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, + billOfLadingNo: item.billOfLadingNo, + warehouseId: item.warehouseId, + warehouseName: item.warehouseName, + bookingDocumentReady: item.bookingDocumentReady, + }) + return [...result.values()] +}) +const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId) || null) +const bookingDocumentReady = computed(() => relationLocked.value + ? bookingDocuments.value.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType)) + : Boolean(selectedReleasedBooking.value?.bookingDocumentReady)) +const selectableContainers = computed(() => candidates.value.filter((item) => item.oceanBookingId === draft.oceanBookingId)) +function containerOptionLabel(item: TruckAppointmentCandidate) { return `柜次号:${item.cabinetNo || '待生成'} / 柜型:${item.containerType || '—'} / 集装箱号:${item.containerNo || '待补录'}` } const selectedContainerFallback = computed(() => { const id = draft.containerBookingId const snapshot = props.resource - if (!id || selectableContainers.value.some((item) => String(item.containerBookingId) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null + if (!id || !relationLocked.value || String(snapshot?.containerBookingId) !== String(id)) return null return { id: String(id), - label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 柜型:${snapshot?.relatedContainerType || '—'} / 集装箱号:${snapshot?.containerNo || '待补录'}`, + label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 柜型:${snapshot?.relatedContainerType || '—'} / 集装箱号:${snapshot?.containerNo || '待补录'}(当前绑定,不可重选)`, } }) const selectedBookingFallback = computed(() => { const id = draft.oceanBookingId const snapshot = props.resource - if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null - return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' } + if (!id || !relationLocked.value || String(snapshot?.oceanBookingId) !== String(id)) return null + return { id: String(id), label: `${snapshot?.oceanBookingNo || '订舱号缺失'}(当前绑定,不可重选)` } }) function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null } function selectContainer(containerId: string) { @@ -81,48 +97,37 @@ function selectContainer(containerId: string) { draft.loadingPlanId = null return } - draft.loadingPlanId = null + draft.loadingPlanId = item.loadingPlanId } -async function loadBookingContainers(bookingId: string) { +async function loadCurrentBookingContext(bookingId: string) { + const sequence = ++bookingLoadSequence bookingContainersLoading.value = true bookingContainersError.value = null try { const booking = await getOceanBooking(bookingId) + if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return if (booking.status !== 'RELEASED') { - containers.value = [] bookingDocuments.value = [] - draft.containerBookingId = null - draft.loadingPlanId = null - bookingContainersError.value = { message: '所选订舱尚未放舱,不能创建拖车预约' } + bookingContainersError.value = { message: '当前绑定订舱已不是放舱状态,请刷新后处理' } return } - const bookingIndex = bookings.value.findIndex((item) => item.id === booking.id) - if (bookingIndex >= 0) bookings.value[bookingIndex] = booking - else bookings.value.push(booking) draft.warehouseId = booking.warehouseId draft.billOfLadingNo = booking.billOfLadingNo - containers.value = booking.containers || [] bookingDocuments.value = booking.documents || [] - if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId)) { - draft.containerBookingId = null - draft.loadingPlanId = null - } } catch (cause) { - containers.value = [] + if (sequence !== bookingLoadSequence || String(draft.oceanBookingId || '') !== bookingId) return bookingDocuments.value = [] - draft.containerBookingId = null - draft.loadingPlanId = null - bookingContainersError.value = getApiErrorDetails(cause, '该订舱柜次加载失败,请重试') + bookingContainersError.value = getApiErrorDetails(cause, '当前绑定订舱资料加载失败,请重试') } finally { - bookingContainersLoading.value = false + if (sequence === bookingLoadSequence) bookingContainersLoading.value = false } } async function selectBooking(bookingId: string) { + bookingLoadSequence += 1 const item = bookings.value.find((entry) => entry.id === bookingId) draft.oceanBookingId = item?.id || null draft.containerBookingId = null draft.loadingPlanId = null - containers.value = [] bookingDocuments.value = [] relationWarning.value = '' if (!item) { @@ -132,26 +137,39 @@ async function selectBooking(bookingId: string) { } draft.warehouseId = item.warehouseId draft.billOfLadingNo = item.billOfLadingNo - await loadBookingContainers(item.id) } async function loadOptions() { optionsLoading.value = true; optionsError.value = '' + if (relationLocked.value) candidates.value = [] const results = await Promise.allSettled([ listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }), - listOceanBookings({ page: 1, size: 200, status: 'RELEASED' }), + relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }), ]) if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE') - if (results[1].status === 'fulfilled') bookings.value = results[1].value.records.filter((item) => item.status === 'RELEASED') - const failed = ['承运商', '已放舱订舱'].filter((_, index) => results[index]?.status === 'rejected') + if (results[1].status === 'fulfilled') candidates.value = results[1].value.records + const failed = ['承运商', '可预约订舱柜次'].filter((_, index) => results[index]?.status === 'rejected') if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败` optionsLoading.value = false - if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId)) + if (relationLocked.value && draft.oceanBookingId) await loadCurrentBookingContext(String(draft.oceanBookingId)) + else if (draft.oceanBookingId) { + const candidate = candidates.value.find((item) => item.oceanBookingId === draft.oceanBookingId && item.containerBookingId === draft.containerBookingId) + if (candidate) { + draft.warehouseId = candidate.warehouseId + draft.billOfLadingNo = candidate.billOfLadingNo + draft.loadingPlanId = candidate.loadingPlanId + } else { + draft.oceanBookingId = null + draft.containerBookingId = null + draft.loadingPlanId = null + } + } } async function validate() { return formRef.value?.validate() } async function submitForm() { if (props.submitting) return; if (await formRef.value?.validate()) emit('submit') } defineExpose({ validate }) onMounted(() => { void loadOptions() }) -watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.length) void loadOptions() }) +watch(() => props.readonly, (readonly) => { if (!readonly && !carriers.value.length) void loadOptions() }) +watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) void loadOptions() })