From 66c6dfdadc8b4dad52f89a44e5e6e49b448f1345 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 31 Aug 2026 21:05:03 +0800 Subject: [PATCH] feat(admin): remove customer from truck appointments --- .../trucking/api/platformTrucking.test.ts | 13 +- src/modules/trucking/api/platformTrucking.ts | 8 +- .../TruckAppointmentForm.behavior.test.ts | 125 ++++++++++++++++++ .../components/TruckAppointmentForm.test.ts | 15 ++- .../components/TruckAppointmentForm.vue | 27 ++-- .../components/TruckAttachmentDialog.test.ts | 15 +++ .../components/TruckAttachmentDialog.vue | 9 +- .../pages/TruckAppointmentCreateView.test.ts | 7 +- .../pages/TruckAppointmentCreateView.vue | 4 +- .../pages/TruckAppointmentDetailView.test.ts | 5 +- .../pages/TruckAppointmentDetailView.vue | 14 +- .../pages/TruckAppointmentsView.test.ts | 18 +-- .../trucking/pages/TruckAppointmentsView.vue | 11 +- src/modules/trucking/types.ts | 14 +- 14 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts diff --git a/src/modules/trucking/api/platformTrucking.test.ts b/src/modules/trucking/api/platformTrucking.test.ts index 71d01a59..784b5f5f 100644 --- a/src/modules/trucking/api/platformTrucking.test.ts +++ b/src/modules/trucking/api/platformTrucking.test.ts @@ -18,12 +18,21 @@ describe('truck appointment API', () => { vi.mocked(http.post).mockResolvedValue({ data: '9001' }) vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } }) await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' }) - await expect(createTruckAppointment({ customerId: '100', carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' }) - expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { customerId: '100', carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 }) + await expect(createTruckAppointment({ carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 }) await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' }) expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } }) }) + it('drops legacy customer fields instead of forwarding hidden compatibility data', async () => { + vi.mocked(http.post).mockResolvedValueOnce({ data: { id: '9001', status: 'DRAFT', attachmentCount: 0 } }) + const payload = { carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, customerId: 'legacy-customer', customerName: '旧客户快照' } + await createTruckAppointment(payload as never) + expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments', { + carrierId: '200', oceanBookingId: '300', containerBookingId: '400', plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0, + }) + }) + it('sends the required idempotency header for rescheduling', async () => { vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'RESCHEDULED' } }) await expect(rescheduleTruckAppointment('9001', '2026-08-20T10:00:00', 2, '调整时间')).resolves.toMatchObject({ status: 'RESCHEDULED' }) diff --git a/src/modules/trucking/api/platformTrucking.ts b/src/modules/trucking/api/platformTrucking.ts index 0f1c1f2d..9e58410c 100644 --- a/src/modules/trucking/api/platformTrucking.ts +++ b/src/modules/trucking/api/platformTrucking.ts @@ -1,4 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { TRUCK_APPOINTMENT_SAVE_FIELDS } from '../types' import type { SaveTruckAppointmentRequest, TruckAppointmentPage, @@ -15,7 +16,10 @@ function operationId(action: string, id: string | number) { } function dtoPayload(payload: SaveTruckAppointmentRequest) { - return Object.fromEntries(Object.entries(payload).map(([key, value]) => [key, value === '' ? null : value])) as SaveTruckAppointmentRequest + return Object.fromEntries(TRUCK_APPOINTMENT_SAVE_FIELDS.flatMap((key) => { + const value = payload[key] + return value === undefined ? [] : [[key, value === '' ? null : value]] + })) as unknown as SaveTruckAppointmentRequest } type PagePayload = Omit & { @@ -33,7 +37,7 @@ function normalizePage(payload: PagePayload): TruckAppointmentPage { } function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource { - const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'shippingCompanyId', 'bookingAgentId', 'carrierId'] + const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'shippingCompanyId', 'bookingAgentId', 'carrierId'] const normalized = { ...resource } as Record for (const key of idKeys) { if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key]) diff --git a/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts new file mode 100644 index 00000000..5047e616 --- /dev/null +++ b/src/modules/trucking/components/TruckAppointmentForm.behavior.test.ts @@ -0,0 +1,125 @@ +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 TruckAppointmentForm from './TruckAppointmentForm.vue' +import type { SaveTruckAppointmentRequest } from '../types' +import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings' + +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(), +})) + +const model: SaveTruckAppointmentRequest = { + expectedVersion: 0, + oceanBookingId: null, + containerBookingId: null, + loadingPlanId: null, + 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: '宁波仓' }, +] +function detail(id: string) { + const booking = bookings.find((item) => item.id === id)! + return { + ...booking, + documents: [{ id: `doc-${id}`, documentType: 'BILL_OF_LADING', valid: true }], + containers: id === 'booking-1' + ? [ + { containerBookingId: 'container-1', cabinetNo: 'CAB-1', containerNo: 'CBHU8888883' }, + { containerBookingId: 'container-invalid', cabinetNo: 'CAB-X', containerNo: '错误柜号' }, + ] + : [{ containerBookingId: 'container-2', cabinetNo: 'CAB-2', containerNo: 'MSCU6639870' }], + } +} + +describe('TruckAppointmentForm booking-only behavior', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(listOceanBookings).mockResolvedValue({ records: bookings } as never) + vi.mocked(getOceanBooking).mockImplementation(async (id) => detail(id) as never) + }) + + it('selects a released booking, exposes only valid real containers, and never emits customer fields', async () => { + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) + await flushPromises() + const selects = wrapper.findAllComponents({ name: 'ElSelect' }) + selects[0]!.vm.$emit('update:modelValue', 'booking-1') + await flushPromises() + + expect(getOceanBooking).toHaveBeenCalledWith('booking-1') + 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'))).toBe(false) + const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest + expect(emitted.oceanBookingId).toBe('booking-1') + expect(emitted.billOfLadingNo).toBe('BL-1') + expect(emitted).not.toHaveProperty('customerId') + expect(emitted).not.toHaveProperty('customerName') + wrapper.unmount() + }) + + it('clears a selected container when switching to another booking', async () => { + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } }) + await flushPromises() + const selects = wrapper.findAllComponents({ name: 'ElSelect' }) + selects[0]!.vm.$emit('update:modelValue', 'booking-1') + await flushPromises() + selects[1]!.vm.$emit('update:modelValue', 'container-1') + await flushPromises() + expect((wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest).containerBookingId).toBe('container-1') + + selects[0]!.vm.$emit('update:modelValue', 'booking-2') + await flushPromises() + const emitted = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as SaveTruckAppointmentRequest + expect(emitted.oceanBookingId).toBe('booking-2') + expect(emitted.containerBookingId).toBeNull() + const labels = wrapper.findAllComponents({ name: 'ElOption' }).map((option) => String(option.props('label') || '')) + expect(labels.some((label) => label.includes('CAB-2'))).toBe(true) + 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') + await flushPromises() + expect(wrapper.text()).toContain('订舱版本冲突') + expect(wrapper.text()).toContain('A0409') + expect(wrapper.text()).toContain('req-booking') + expect(wrapper.text()).toContain('trace-booking') + 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 () => { + vi.mocked(getOceanBooking).mockResolvedValueOnce({ ...detail('booking-1'), status: 'DRAFT' } as never) + const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, 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() + wrapper.unmount() + }) +}) diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts index 6cecd88d..73d32c95 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.test.ts +++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts @@ -30,18 +30,21 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).toContain('if (!selectableContainers.value.some((item) => item.containerBookingId === draft.containerBookingId))') }) - it('freezes customer, warehouse and bill of lading snapshots from the booking', () => { - expect(source).toContain('draft.customerId = item.customerId') - expect(source).toContain('draft.customerName = item.customerName') + it('freezes warehouse and bill of lading snapshots without retaining customer data', () => { expect(source).toContain('draft.warehouseId = item.warehouseId') expect(source).toContain('draft.billOfLadingNo = item.billOfLadingNo') expect(source).toContain('已从放舱订舱冻结资料带入') expect(source).toContain(':model-value="draft.billOfLadingNo || \'\'" disabled') + expect(source).not.toContain('customerId') + expect(source).not.toContain('customerName') + expect(source).not.toContain('label="客户"') }) it('keeps server-side validation authoritative and preserves recoverable option errors', () => { expect(source).toContain('最终资格仍由服务端校验') expect(source).toContain('所选订舱缺少有效的订舱确认文件或提单文件') + expect(source).toContain('所选订舱缺少有效提单号') + expect(source).toContain("booking.status !== 'RELEASED'") expect(source).toContain('bookingContainersError') expect(source).toContain("getApiErrorDetails(cause, '该订舱柜次加载失败,请重试')") expect(source).toContain('') @@ -59,4 +62,10 @@ describe('TruckAppointmentForm interaction contract', () => { expect(source).toContain('@media (max-width: 480px)') expect(source).toContain('min-height: 40px') }) + + it('suppresses duplicate submits and truncates long booking or container labels', () => { + expect(source).toContain('if (props.submitting) return') + expect(source).toContain(':deep(.el-select__selected-item)') + expect(source).toContain('text-overflow:ellipsis') + }) }) diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue index 0b92e42d..a9ee6ae0 100644 --- a/src/modules/trucking/components/TruckAppointmentForm.vue +++ b/src/modules/trucking/components/TruckAppointmentForm.vue @@ -34,10 +34,10 @@ 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 (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' }], - customerId: [{ required: true, message: '所选订舱缺少客户信息', 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('门店电话')], @@ -87,7 +87,19 @@ async function loadBookingContainers(bookingId: string) { bookingContainersError.value = null try { const booking = await getOceanBooking(bookingId) - if (!bookings.value.some((item) => item.id === booking.id)) bookings.value.push(booking) + if (booking.status !== 'RELEASED') { + containers.value = [] + bookingDocuments.value = [] + draft.containerBookingId = null + draft.loadingPlanId = null + 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)) { @@ -113,14 +125,10 @@ async function selectBooking(bookingId: string) { bookingDocuments.value = [] relationWarning.value = '' if (!item) { - draft.customerId = null - draft.customerName = null draft.warehouseId = null draft.billOfLadingNo = null return } - draft.customerId = item.customerId - draft.customerName = item.customerName draft.warehouseId = item.warehouseId draft.billOfLadingNo = item.billOfLadingNo await loadBookingContainers(item.id) @@ -139,7 +147,7 @@ async function loadOptions() { if (draft.oceanBookingId) await loadBookingContainers(String(draft.oceanBookingId)) } async function validate() { return formRef.value?.validate() } -async function submitForm() { if (await formRef.value?.validate()) emit('submit') } +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() }) @@ -147,7 +155,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !bookings.value.len