fix(dispatch): release failed request state and return rejected reviews

This commit is contained in:
2026-10-22 10:10:32 +08:00
parent ef85ed30e4
commit 090431b409
18 changed files with 373 additions and 536 deletions
@@ -7,7 +7,6 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
@@ -33,15 +32,13 @@ it('switches to existing cargo inside the same dialog without creating a draft o
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('opens %s recovery in existing cargo without reading creation drafts', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/1', action, { recordVersion: 0, idempotencyKey: 'existing-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
expect(api.listDispatchDrafts).not.toHaveBeenCalled()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(recovery.pending.value?.body.idempotencyKey).toBe('existing-key')
wrapper.unmount()
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s browser requests and opens a fresh workspace',async action=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action,attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
expect(wrapper.findComponent({name:'SmartDispatchExistingCargo'}).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('原请求结果尚待核对');wrapper.unmount()
})
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
const [first, second] = sharedPalletCandidates(), original = draft()
@@ -68,7 +65,7 @@ it('allows one atomic removal of a restored group that is no longer selectable',
})
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in conversion and refreshes the batch version on a new click', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
@@ -80,7 +77,10 @@ it.each([0, '0', '9007199254740993'])('transmits original authoritative batch ve
const request = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(request.groups[0]!.dispatch.batchAllocations).toEqual([{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }])
original.sources[0]!.appointmentBatches[0]!.recordVersion = '9007199254740994'
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenNthCalledWith(2, request)
await state.submit()
const next=vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]
expect(next.idempotencyKey).not.toBe(request.idempotencyKey)
expect(next.groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe('9007199254740994')
} finally { wrapper.unmount() }
})
it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restored candidate with missing or invalid batch version %s', async recordVersion => {
@@ -113,11 +113,11 @@ it('restores an explicit draft and submits complete server source versions in ea
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] }))
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
it('releases an unknown conversion and starts a fresh request on the next click', async () => {
vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('network unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]); wrapper.unmount()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0].idempotencyKey).not.toBe(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].idempotencyKey); wrapper.unmount()
})
it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
@@ -127,7 +127,7 @@ it('accepts a partial candidate page without requiring the filtered whole set',
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 21, page: 1, size: 20, pages: 2, candidates: [source()], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('尚未完整返回'); wrapper.unmount()
})
it('keeps planning remarks independent with unassigned resources and reuses a proven conversion after a detail read fails', async () => {
it('keeps planning remarks independent with unassigned resources and permits resubmission after a failed detail read', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
@@ -137,16 +137,19 @@ it('keeps planning remarks independent with unassigned resources and reuses a pr
state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.form.remark = '车乙'
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('never sends an existing departure command through the empty creation transport', async () => {
useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
await (wrapper.vm as unknown as { submit(): Promise<void> }).submit()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
it('clears an old departure request without blocking new empty creation',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action:'DEPART',attempted:true,body:{idempotencyKey:'old'}}))
vi.mocked(api.createDispatch).mockImplementation(async body=>command('CREATE',body.idempotencyKey))
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
const state=wrapper.vm as unknown as {mode:string;submit():Promise<void>}
state.mode='empty';await flushPromises();await state.submit()
expect(api.createDispatch).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull();wrapper.unmount()
})
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]