fix(dispatch): release failed request state and return rejected reviews
This commit is contained in:
@@ -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 }] }]
|
||||
|
||||
Reference in New Issue
Block a user