From a5a19769244f51ca58ca0da8a637dc5d413b6aab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 16:37:37 +0800 Subject: [PATCH] fix(dispatch): align dispatch actions and native return workflows --- .../api/tripBookings.test.ts | 5 + .../overseas-master-data/api/tripBookings.ts | 6 +- .../overseas-master-data/bookingTypes.ts | 2 +- .../TripBookingFulfillmentDialog.test.ts | 92 ++++++++++- .../TripBookingFulfillmentDialog.vue | 66 +++++--- .../components/TripBookingPanel.test.ts | 28 ++++ .../components/TripBookingPanel.vue | 10 +- .../api/deliveryOrders.test.ts | 24 ++- .../overseas-warehouse/api/deliveryOrders.ts | 21 ++- .../overseas-warehouse/api/dispatches.ts | 13 ++ .../api/exceptionOrders.test.ts | 7 + .../overseas-warehouse/api/exceptionOrders.ts | 7 +- .../ExceptionProcessingPanel.test.ts | 16 ++ .../components/ExceptionProcessingPanel.vue | 10 +- .../components/SmartDispatchDialog.test.ts | 117 ++++++++++++-- .../components/SmartDispatchDialog.vue | 144 +++++++++++++----- .../pages/DeliveryOrdersView.test.ts | 125 +++++++++++++-- .../pages/DeliveryOrdersView.vue | 33 ++-- .../pages/DispatchDetailView.test.ts | 23 +++ .../pages/DispatchDetailView.vue | 17 +++ .../pages/DispatchesView.test.ts | 75 ++++++++- .../pages/DispatchesView.vue | 54 ++++--- .../pages/OutboundsView.returnSku.test.ts | 2 +- .../pages/OutboundsView.selection.test.ts | 2 +- .../pages/OutboundsView.test.ts | 33 +++- .../pages/OutboundsView.vue | 33 +++- .../pages/prototypeContract.test.ts | 10 +- .../overseas-warehouse/returnSource.ts | 28 ++++ src/modules/overseas-warehouse/types.ts | 1 + 29 files changed, 854 insertions(+), 150 deletions(-) create mode 100644 src/modules/overseas-warehouse/returnSource.ts diff --git a/src/modules/overseas-master-data/api/tripBookings.test.ts b/src/modules/overseas-master-data/api/tripBookings.test.ts index 6dfd312a..6b5a2f01 100644 --- a/src/modules/overseas-master-data/api/tripBookings.test.ts +++ b/src/modules/overseas-master-data/api/tripBookings.test.ts @@ -19,4 +19,9 @@ it('normalizes quote source without overwriting negotiated price and handles man expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25) expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull() expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } }) + const received = normalizeBooking({ ...row, status: 'RECEIVED', receipt: { + source: { incomingNo: 'WT-1', inboundCode: 'IN-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'READY_FOR_DISPATCH', receiptHash: 'merged', receivedPackages: '1', pallets: [{ warehousePalletId: '9007199254740997', palletNo: 'PALLET-MERGED', packages: '1', weightKg: '12.345', volumeCbm: '0.123' }] }, + pallets: [{ warehousePalletId: '9007199254740997', palletSpaces: '2', weightKg: '12.345', volumeCbm: '0.123' }], operatorId: 'operator', confirmedAt: '', + } }) + expect(received.receipt?.pallets[0]).toEqual({ warehousePalletId: '9007199254740997', palletSpaces: 2, weightKg: 12.345, volumeCbm: 0.123 }) }) diff --git a/src/modules/overseas-master-data/api/tripBookings.ts b/src/modules/overseas-master-data/api/tripBookings.ts index 6e3e8050..e8c67e0e 100644 --- a/src/modules/overseas-master-data/api/tripBookings.ts +++ b/src/modules/overseas-master-data/api/tripBookings.ts @@ -7,13 +7,15 @@ type N = number | string type WireCargo = Omit & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null } export type WireBooking = Omit & { expectedPalletCount: N | null; recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null } export type WireIncoming = Omit & { receivedPackages: N; pallets: (Omit & { packages: N; weightKg: N | null; volumeCbm: N | null })[] } -type WireReceipt = Omit & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] } +type WireReceipt = Omit & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N; weightKg?: N | null; volumeCbm?: N | null }[] } export type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean } const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result } const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result } const nullable = (value: N | null) => value == null ? null : number(value) +const normalizeReceiptPallet = ({ weightKg, volumeCbm, ...row }: WireReceipt['pallets'][number]) => ({ ...row, palletSpaces: integer(row.palletSpaces), + ...(weightKg === undefined ? {} : { weightKg: nullable(weightKg) }), ...(volumeCbm === undefined ? {} : { volumeCbm: nullable(volumeCbm) }) }) export const normalizeIncoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) }) -export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, +export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: normalizeIncoming(value.receipt.source), pallets: value.receipt.pallets.map(normalizeReceiptPallet) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, packageCount: value.cargo.packageCount == null ? null : integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } }) export const normalizeBookingResult = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true }) export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number } diff --git a/src/modules/overseas-master-data/bookingTypes.ts b/src/modules/overseas-master-data/bookingTypes.ts index eda6cd6d..d9aeaff5 100644 --- a/src/modules/overseas-master-data/bookingTypes.ts +++ b/src/modules/overseas-master-data/bookingTypes.ts @@ -16,7 +16,7 @@ export const bookingStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认 export interface TripIncomingPallet { warehousePalletId: string; palletNo: string; packages: number; weightKg: number | null; volumeCbm: number | null } export interface TripIncomingEvidence { incomingNo: string; inboundCode: string; warehouseId: string; destinationKey: string; status: string; receiptHash: string; pallets: TripIncomingPallet[]; receivedPackages: number } -export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number } +export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number; weightKg?: number | null; volumeCbm?: number | null } export interface TripAcceptedReceipt { source: TripIncomingEvidence; pallets: TripPalletSpaces[]; operatorId: string; confirmedAt: string } export interface TripIncomingPreview { tripRecordVersion: number; recordVersion: number; incoming: TripIncomingEvidence | null } export interface TripIncomingStart extends Omit { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null } diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts index d9382681..9b9e3547 100644 --- a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts +++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts @@ -1,4 +1,4 @@ -import { ElMessage } from 'element-plus' +import { ElMessage, ElMessageBox } from 'element-plus' import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index' import { ElInputNumber } from 'element-plus/es/components/input-number/index' import { ElInput } from 'element-plus/es/components/input/index' @@ -9,9 +9,12 @@ import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue' import * as api from '../api/tripBookings' import type { FleetTrip } from '../tripTypes' import type { TripBooking, TripIncomingPreview } from '../bookingTypes' -vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) +import { ApiResponseError } from '@/shared/api/http' +const writable = vi.hoisted(() => ({ value: true })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => writable.value }) })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() })) -const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip +const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } } const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22, @@ -21,12 +24,12 @@ function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') { const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '' } const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: { AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '
' }, - DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, + DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field, ElButton: { template: '' }, LoadErrorState: true, ApiErrorMeta: true, } } }); wrappers.push(wrapper); return wrapper } -beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) }) +beforeEach(() => { vi.resetAllMocks(); writable.value = true; vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited>); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) }) afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())) it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => { vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false }) @@ -115,3 +118,82 @@ it('compares real receipt with the forecast, preserves unknown values, and does expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false) expect(api.acceptTripIncoming).not.toHaveBeenCalled() }) + +it('requires explicit acceptance of a changed received snapshot and submits the current complete pallets and versions', async () => { + const previous = { ...booking, status: 'RECEIVED' as const, receipt: { source: { ...preview.incoming!, receiptHash: 'previous-hash' }, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }], operatorId: 'operator', confirmedAt: '2026-10-01T00:00:00Z' } } + vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 11, recordVersion: 8, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-hash', pallets: [ + { warehousePalletId: '903', palletNo: 'PLT-MERGED', packages: 22, weightKg: 150, volumeCbm: 1.5 }, + ] } }) + const wrapper = mount('receipt'); await wrapper.setProps({ booking: previous }); await flushPromises() + expect(wrapper.text()).toContain('上次核实与当前实收对照') + expect(wrapper.text()).not.toContain('无法还原原始预报') + vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel') + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(api.acceptTripIncoming).not.toHaveBeenCalled() + vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 12, booking: { ...previous, recordVersion: 9 }, replayed: false }) + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(ElMessageBox.confirm).toHaveBeenLastCalledWith(expect.stringContaining('50.00 USD'), '确认实收差异', expect.any(Object)) + expect(api.acceptTripIncoming).toHaveBeenCalledExactlyOnceWith('TR-1', 'FB-1', { tripRecordVersion: 11, recordVersion: 8, idempotencyKey: expect.any(String), receiptHash: 'merged-hash', unitPrice: 50, pallets: [{ warehousePalletId: '903', palletSpaces: 1 }] }) +}) + +it('uses a new key after a completed confirmation while preserving the original key for unknown retries', async () => { + vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false }) + const wrapper = mount('receipt'); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + const first = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2] + await wrapper.setProps({ modelValue: false }) + vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 6, recordVersion: 3, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'new-hash' } }) + await wrapper.setProps({ modelValue: true, booking: { ...booking, status: 'RECEIVED', recordVersion: 3 } }); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + const next = vi.mocked(api.acceptTripIncoming).mock.calls[1]![2] + expect(next.idempotencyKey).not.toBe(first.idempotencyKey) + expect(next).toMatchObject({ tripRecordVersion: 6, recordVersion: 3, receiptHash: 'new-hash' }) +}) + +it('does not report success when the server rejects the updated receipt capacity', async () => { + vi.mocked(api.acceptTripIncoming).mockRejectedValue(new ApiResponseError('当前车次容量不足,请重新核对', 'A0409', 409, null, 'capacity-request')) + const wrapper = mount('receipt'); await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(wrapper.emitted('saved')).toBeUndefined() + expect(ElMessage.success).not.toHaveBeenCalled() + expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toContain('当前车次容量不足') + expect((wrapper.vm as unknown as { error: { requestId: string } }).error).toMatchObject({ requestId: 'capacity-request' }) +}) + +it.each(['bound', 'departed', 'permission'] as const)('rejects a receipt write if the context changes to %s after opening', async context => { + const wrapper = mount('receipt'); await flushPromises() + if (context === 'bound') await wrapper.setProps({ booking: { ...booking, status: 'BOUND', dispatchNo: 'DS-1' } }) + if (context === 'departed') await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } }) + if (context === 'permission') { + writable.value = false + await wrapper.setProps({ booking: { ...booking, recordVersion: 3 } }) + } + await flushPromises() + wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() + expect(api.acceptTripIncoming).not.toHaveBeenCalled() + expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true) +}) + +it('requires new measurements for changed pallet composition and keeps them in the exact unknown-result retry', async () => { + const merged = { ...preview, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletNo: 'MERGED-1', packages: 22, weightKg: null, volumeCbm: null }] } } + vi.mocked(api.getTripIncoming).mockResolvedValue(merged) + vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true }) + const wrapper = mount('receipt'); await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } }); await flushPromises() + const confirm = () => wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm') + confirm(); await flushPromises() + expect(api.acceptTripIncoming).not.toHaveBeenCalled() + expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('重新填写')) + const fields = wrapper.findAllComponents({ name: 'ElInputNumber' }) + fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测重量')!.vm.$emit('update:modelValue', 123.456) + fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测体积')!.vm.$emit('update:modelValue', 1.234) + await flushPromises(); confirm(); await flushPromises() + const original = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2] + expect(original).toMatchObject({ receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletSpaces: 1, weightKg: 123.456, volumeCbm: 1.234 }] }) + confirm(); await flushPromises() + expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original) +}) + +it('does not offer measurement overrides for unchanged source pallets', async () => { + const wrapper = mount('receipt'); await flushPromises() + expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).some(field => /实测重量|实测体积/.test(field.attributes('aria-label') || ''))).toBe(false) +}) diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue index 3fac1437..e73c1835 100644 --- a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue +++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue @@ -1,6 +1,8 @@