fix(dispatch): align dispatch actions and native return workflows

This commit is contained in:
2026-10-18 16:37:37 +08:00
parent 4806f88d7c
commit a5a1976924
29 changed files with 854 additions and 150 deletions
@@ -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: '<input />' }
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: '<div><slot/></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/><slot name="weightKg" :row="row"/><slot name="volumeCbm" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
ElButton: { template: '<button><slot/></button>' }, 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<ReturnType<typeof ElMessageBox.confirm>>); 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)
})