diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts
index 6c74604d..44418914 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts
@@ -186,21 +186,19 @@ describe('new dispatch native layout', () => {
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(view(dialog).find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
})
- it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
- const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
+ it('unlocks after a failed first container and accepts a new cargo click',async()=>{
+ const empty:DispatchDraft={...draft(),containers:[],sources:[]}
+ vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
- vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
- const { dialog } = await render(); await selectWarehouse(dialog)
- await selectFirstOrder(dialog)
- await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
- const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
- expect(dialog.props('confirmDisabled')).toBe(false)
- dialog.vm.$emit('confirm'); await flushPromises()
- expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
- expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original)
- expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
- expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
- expect(api.convertDispatchDraft).not.toHaveBeenCalled()
+ vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({...empty,recordVersion:1,containers:[{draftContainerId:'first-group',sequenceNo:1,capacity:30,sourceKeys:[]}]})
+ const {dialog}=await render();await selectWarehouse(dialog);await selectFirstOrder(dialog)
+ await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click');await flushPromises()
+ expect(dialog.props('confirmDisabled')).toBe(true)
+ expect(field(dialog,'建单方式').getComponent({name:'ElRadioGroup'}).props('disabled')).toBe(false)
+ await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click');await flushPromises()
+ const calls=vi.mocked(api.createDispatchDraftContainer).mock.calls
+ expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
+ expect(api.createDispatchDraft).toHaveBeenCalledOnce();expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
})
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
const { dialog } = await render()
@@ -231,7 +229,7 @@ describe('new dispatch native layout', () => {
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
expect(view(dialog).find('[aria-label="智能排车三栏选货"]').exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
- expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
+ expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
})
it('creates with only the warehouse and never requests vehicle or driver directories', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
@@ -241,7 +239,7 @@ describe('new dispatch native layout', () => {
expect(listVehicles).not.toHaveBeenCalled()
expect(listDrivers).not.toHaveBeenCalled()
})
- it('keeps each group configuration independent and verifies proven conversions without creating them twice', async () => {
+ it('keeps each group configuration independent and keeps form values editable after conversion read failure', async () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
await field(dialog, '备注').get('input').setValue('第一组')
@@ -260,12 +258,12 @@ describe('new dispatch native layout', () => {
expect(request.recordVersion).toBe(0)
expect(request.groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '第一组'], [null, null, '第二组']])
expect(wrapper.emitted('created')).toBeUndefined()
- expect(view(dialog).text()).toContain('草稿操作结果未确认,原请求已保留')
- dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
- expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
+ expect(view(dialog).text()).toContain('本次排车操作失败,请重试')
+ dialog.vm.$emit('confirm'); await flushPromises()
+ expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('created')).toHaveLength(1)
})
- it('locks the real frame and all resource edits while pending and reuses the original conversion after failure', async () => {
+ it('prevents active double clicks but unlocks editing and starts a new conversion after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
const { wrapper, dialog } = await render(); await restore(dialog)
@@ -278,12 +276,13 @@ describe('new dispatch native layout', () => {
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
- expect(view(dialog).text()).toContain('原请求结果尚待核对')
- expect(dialog.props('confirmText')).toBe('重试原请求')
+ expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
+ expect(dialog.props('confirmText')).toBe('生成排车单')
+ expect(field(dialog,'备注').get('input').attributes('disabled')).toBeUndefined()
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
- expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
+ expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey)
})
it('keeps all current-page orders and reveals complete pallet source details only after selecting an order', async () => {
const rows = Array.from({ length: 20 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
@@ -344,40 +343,27 @@ describe('new dispatch native layout', () => {
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
- it.each(['rejection', 'unknown'] as const)('handles an empty-car deletion %s without confusing rejected and unknown results', async failure => {
- const empty = { ...draft(), sources: [], containers: [{ ...draft().containers[0]!, sourceKeys: [] }] }
+ it.each(['rejection','unknown'] as const)('unlocks an empty-car deletion after %s and accepts another click',async failure=>{
+ const empty={...draft(),sources:[],containers:[{...draft().containers[0]!,sourceKeys:[]}]}
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
- vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure === 'rejection'
- ? new ApiResponseError('仅空容器可以删除', 'A0409', 409, null, 'delete-request') : new Error('network unknown'))
- const { dialog } = await render(); await restore(dialog)
- await view(dialog).findAll('button').find(button => button.text() === '删除车辆')!.trigger('click'); await flushPromises()
- expect(api.deleteDispatchDraftContainer).toHaveBeenCalledTimes(1)
- expect(view(dialog).find('[aria-label="第 1 辆车"]').exists()).toBe(true)
- expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(failure === 'unknown')
- if (failure === 'rejection') {
- expect(view(dialog).text()).toContain('仅空容器可以删除')
- expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
- expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
- } else {
- const original = vi.mocked(api.deleteDispatchDraftContainer).mock.calls[0]!
- vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(new ApiResponseError('仅空容器可以删除', 'A0409', 409))
- dialog.vm.$emit('confirm'); await flushPromises()
- expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(2, ...original)
- expect(view(dialog).text()).toContain('原请求结果尚待核对')
- expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).not.toBeNull()
- vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [] })
- dialog.vm.$emit('confirm'); await flushPromises()
- expect(api.deleteDispatchDraftContainer).toHaveBeenNthCalledWith(3, ...original)
- expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
- }
+ vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure==='rejection'?new ApiResponseError('仅空容器可以删除','A0409',409):new Error('network unknown'))
+ const {dialog}=await render();await restore(dialog)
+ await view(dialog).findAll('button').find(button=>button.text()==='删除车辆')!.trigger('click');await flushPromises()
+ expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
+ expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
+ expect(sessionStorage.getItem('dispatch-draft-command:'+identity.id)).toBeNull()
+ vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({...empty,recordVersion:1,containers:[]})
+ await view(dialog).findAll('button').find(button=>button.text()==='删除车辆')!.trigger('click');await flushPromises()
+ const calls=vi.mocked(api.deleteDispatchDraftContainer).mock.calls
+ expect(calls).toHaveLength(2);expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
})
- it('clears a saved definite rejection on reopen while restoring the original draft', async () => {
+ it('clears a saved definite rejection on reopen without restoring the failed request', async () => {
sessionStorage.setItem(`dispatch-draft-command:${identity.id}`, JSON.stringify({ action: 'DELETE', attempted: true, rejected: true, containerId: 'container-1', body: { draftToken: 'server-token', recordVersion: 0, idempotencyKey: 'rejected-key' } }))
const { dialog } = await render()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
- expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
+ expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it('keeps creation unavailable without its write permission', async () => {
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts
index c94bf0b0..8d8a44dd 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.planning.test.ts
@@ -183,24 +183,20 @@ describe('three-column cargo planning through visible controls', () => {
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
})
- it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
- lostReply = step
- const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
+ it.each(['CREATE','CONTAINER'] as const)('unlocks after a failed %s and permits a new cargo click',async step=>{
+ lostReply=step
+ const dialog=await render();await warehouseSelected(dialog);await join(dialog,'PALLET-1')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
- const before = step === 'CREATE' ? clone(vi.mocked(api.createDispatchDraft).mock.calls[0]![0]) : clone(vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0])
- await button(dialog, '重试原请求').trigger('click'); await flushPromises()
- if (step === 'CREATE') {
- expect(api.createDispatchDraft).toHaveBeenNthCalledWith(2, before)
- expect(api.createDispatchDraftContainer).not.toHaveBeenCalled()
- } else {
- expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, before)
- expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
+ expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
+ expect(sessionStorage.getItem('dispatch-draft-command:'+identity.id)).toBeNull()
+ await join(dialog,'PALLET-1')
+ expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
+ if(step==='CREATE'){
+ const calls=vi.mocked(api.createDispatchDraft).mock.calls
+ expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
+ }else{
+ expect(api.createDispatchDraftContainer).toHaveBeenCalledOnce();expect(api.createDispatchDraft).toHaveBeenCalledOnce()
}
- expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
- await join(dialog, 'PALLET-1')
- expect(api.createDispatchDraft).toHaveBeenCalledTimes(step === 'CREATE' ? 2 : 1)
- expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CONTAINER' ? 2 : 1)
- expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
})
it('keeps the preparation chain frozen while CREATE is in flight', async () => {
let resolve!: (value: DispatchDraft) => void
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
index 6d86eb1a..83dbeb23 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
@@ -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
>); 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 }
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 }).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}
+ 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 }] }]
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
index 6cfa4f21..2b29c8a5 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
@@ -17,7 +17,7 @@ import { useDispatchCandidates } from '../useDispatchCandidates'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, DraftSelection, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
-import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
+import { useDispatchCommandRecovery, validateCurrentDispatch } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
@@ -28,12 +28,9 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>
const router = useRouter(), auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
-type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
+type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record; containerId?: string; priorContainerIds?: string[] }
const pending = ref(null)
-if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
-// Only a recorded first-attempt rejection proves that no write occurred.
-let rejectedDraftToken = pending.value?.rejected && !pending.value.result ? String(pending.value.body.draftToken || '') : ''
-if (pending.value?.rejected && !pending.value.result) { pending.value = null; sessionStorage.removeItem(storageKey) }
+if (owner) sessionStorage.removeItem(storageKey)
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref([])
@@ -51,11 +48,10 @@ const currentSettings = computed(() => {
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
-const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
-const needsRecovery = computed(() => Boolean(pending.value || recovery.pending.value) && !saving.value && !preparing.value && !recovery.busy.value)
+const frozen = computed(() => !canCreate.value || preparing.value || saving.value || recovery.busy.value)
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
-const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
+const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || recovery.busy.value)
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
@@ -113,25 +109,17 @@ function changeSize(value: number) { size.value = value; void search() }
let generation = 0
let candidateFilterKey = ''
-function persist() {
- if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
- if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey)
-}
async function init() {
const sequence = ++generation
working.value = true; error.value = null
try {
- if (recovery.pending.value && ['ADD_CARGO', 'REMOVE_CARGO'].includes(recovery.pending.value.action)) workspace.value = 'existing'
- else if (pending.value || recovery.pending.value?.action === 'CREATE') workspace.value = 'create'
- else if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
+ if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
if (workspace.value === 'existing' || !canCreate.value) return
- if (recovery.pending.value?.action === 'CREATE') { mode.value = 'empty'; warehouseId.value = String(recovery.pending.value.body.warehouseId || '') }
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
warehouses.value = directory; drafts.value = saved
- const resumeToken = pending.value?.body.draftToken || rejectedDraftToken
+ const resumeToken = draft.value?.draftToken
if (resumeToken) draft.value = saved.find(row => row.draftToken === resumeToken) || null
- rejectedDraftToken = ''
if (draft.value) warehouseId.value = draft.value.warehouseId
if (!warehouseId.value) {
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
@@ -196,16 +184,17 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
body = { ...common, groups } satisfies ConvertDraft
}
}
- pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}), attempted: false, rejected: false }; persist()
+ if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
+ pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}) }
}
await retryDraft()
return true
- } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留'); return false }
+ } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次排车操作失败,请重试'); return false }
}
async function retryDraft() {
if (!pending.value || saving.value) return
- const command = pending.value, previousAttempt = command.attempted
- saving.value = true; command.attempted = true; persist()
+ const command = pending.value
+ saving.value = true
try {
const body = command.body
let result: DispatchDraft | ConvertedDraft
@@ -216,52 +205,55 @@ async function retryDraft() {
case 'UNLOCK': result = await api.unlockDispatchDraftPallet(body as unknown as DraftSelection); break
case 'RELEASE': result = await api.releaseDispatchDraft(body as unknown as VersionCommand & { draftToken: string }); break
case 'DELETE': result = await api.deleteDispatchDraftContainer(String(body.draftToken), command.containerId!, body as unknown as VersionCommand & { reason: string }); break
- case 'CONVERT': result = command.result || await api.convertDispatchDraft(body as unknown as ConvertDraft); break
+ case 'CONVERT': result = await api.convertDispatchDraft(body as unknown as ConvertDraft); break
}
if (body.draftToken && result.draftToken !== body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致')
if (command.action === 'CONVERT') {
const converted = result as ConvertedDraft, expected = (body as unknown as ConvertDraft).groups
- if (converted.vehicles.length !== expected.length || new Set(converted.vehicles.map(row => row.dispatch.dispatchNo)).size !== expected.length || expected.some(group => !converted.vehicles.some(row => row.draftContainerId === group.draftContainerId && row.dispatch.action === 'CREATE' && row.dispatch.idempotencyKey === group.dispatch.idempotencyKey && Boolean(row.dispatch.dispatchNo)))) throw new DispatchInputError('整车转换结果与原草稿容器不一致,请核对原请求')
- command.result = converted; persist()
+ if (converted.vehicles.length !== expected.length || new Set(converted.vehicles.map(row => row.dispatch.dispatchNo)).size !== expected.length || expected.some(group => !converted.vehicles.some(row => row.draftContainerId === group.draftContainerId && row.dispatch.action === 'CREATE' && row.dispatch.idempotencyKey === group.dispatch.idempotencyKey && Boolean(row.dispatch.dispatchNo)))) throw new DispatchInputError('整车转换结果与本次草稿容器不一致,请刷新当前排车')
await Promise.all(converted.vehicles.map(async row => validateCurrentDispatch(await api.getDispatch(row.dispatch.dispatchNo), row.dispatch)))
- pending.value = null; persist(); emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
+ emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
}
requireVersion((result as DispatchDraft).recordVersion)
let addedContainerId: string | undefined
if (command.action === 'CONTAINER') {
const updated = result as DispatchDraft, previous = command.priorContainerIds
const added = previous && updated.containers.filter(row => !previous.includes(row.draftContainerId))
- if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果尚未核对,请保留原请求重试')
+ if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果异常,请刷新后重试')
addedContainerId = added[0]!.draftContainerId
}
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
if (draft.value) warehouseId.value = draft.value.warehouseId
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
- // No new source mutation before the original response has been validated.
- pending.value = null; persist()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
- } catch (cause) { // A later rejection cannot erase an earlier unknown write result.
- if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) pending.value = null
- persist(); throw cause }
- finally { saving.value = false }
+ } catch (cause) {
+ // 请求失败后读取服务器最新草稿,保留本地时间和备注,后续点击按当前版本重新提交。
+ if (command.body.draftToken) {
+ try {
+ const saved = await api.listDispatchDrafts()
+ drafts.value = saved; draft.value = saved.find(row => row.draftToken === command.body.draftToken) || null
+ activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
+ } catch { /* 刷新失败只保留原错误,不锁住其他操作。 */ }
+ }
+ throw cause
+ } finally { pending.value = null; saving.value = false }
}
async function submit() {
if (workspace.value === 'existing') return
if (!canCreate.value || preparing.value || saving.value || loading.value) return
error.value = null
try {
- if (pending.value) { await retryDraft(); return }
- if (recovery.pending.value && recovery.pending.value.action !== 'CREATE') throw new DispatchInputError('另一次排车操作尚待核对,请从列表的原请求恢复入口继续')
- if (!recovery.pending.value && mode.value === 'cargo') { await draftAction('CONVERT'); return }
- if (!recovery.pending.value) { if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓'); recovery.begin('', 'CREATE', creation([])) }
- const result = await recovery.execute(command => api.createDispatch(command.body as unknown as CreateDispatch), api.getDispatch, api.getDispatchCommand)
+ if (mode.value === 'cargo') { await draftAction('CONVERT'); return }
+ if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓')
+ recovery.begin('', 'CREATE', creation([]))
+ const result = await recovery.execute(command => api.createDispatch(command.body as unknown as CreateDispatch), api.getDispatch)
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
- } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单结果尚未核对,请使用原请求重试') }
+ } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单失败,请重试') }
}
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
-
+
@@ -269,8 +261,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
-
-
+
查询重置排车设置
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.test.ts
index 7b37ab99..609867dc 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.test.ts
@@ -91,7 +91,7 @@ it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
const wrapper = await open(); await add(wrapper)
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
- expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined()
+ expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeUndefined()
})
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
@@ -147,28 +147,23 @@ it.each(['loading', 'permission', 'task-type', 'version', 'read-failure'] as con
if (scenario === 'version') expect(wrapper.text()).toContain('一致版本')
})
-it('auto-opens the original removal command after remount and checks its result without resending', async () => {
- const wrapper = await open()
- await button(wrapper, '移除货物').trigger('click')
- await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
- vi.mocked(api.removeDispatchItem).mockRejectedValue(new Error('unknown result'))
- await button(wrapper, '确认移除').trigger('click'); await flushPromises()
- const original = vi.mocked(api.removeDispatchItem).mock.calls[0]!
- wrapper.unmount()
- const resumed = await open(null)
- expect(resumed.text()).toContain('PC/1')
- const recovery = useDispatchCommandRecovery(() => identity.id)
- expect(recovery.pending.value).toMatchObject({ dispatchNo: 'PC/1', itemId: '9007199254740993', body: original[2] })
- vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key))
- vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
- vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] }))
- await button(resumed, '核对并重试原请求').trigger('click'); await flushPromises()
- expect(api.removeDispatchItem).toHaveBeenCalledTimes(1)
- expect(recovery.pending.value).toBeNull()
- expect(resumed.emitted('saved')).toHaveLength(1)
+it('unlocks a failed removal and accepts an updated reason on a fresh request',async()=>{
+ const wrapper=await open()
+ await button(wrapper,'移除货物').trigger('click');await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
+ vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown result'))
+ await button(wrapper,'确认移除').trigger('click');await flushPromises()
+ expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
+ expect((wrapper.get('textarea[aria-label="移除原因"]').element as HTMLTextAreaElement).value).toBe('原移除原因')
+ await wrapper.get('textarea[aria-label="移除原因"]').setValue('重新安排')
+ vi.mocked(api.removeDispatchItem).mockImplementationOnce(async(_,__,body)=>command('REMOVE_CARGO',body.idempotencyKey))
+ await button(wrapper,'确认移除').trigger('click');await flushPromises()
+ const calls=vi.mocked(api.removeDispatchItem).mock.calls
+ expect(calls).toHaveLength(2);expect(calls[1]![2].reason).toBe('重新安排')
+ expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
+ expect(api.getDispatchCommand).not.toHaveBeenCalled()
})
-it('keeps a confirmed result pending if the subsequent manifest cannot be read', async () => {
+it('unlocks a completed request when its follow-up manifest read fails', async () => {
const wrapper = await open()
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
@@ -176,11 +171,11 @@ it('keeps a confirmed result pending if the subsequent manifest cannot be read',
return command('ADD_CARGO', body.idempotencyKey)
})
await add(wrapper)
- expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result?.action).toBe('ADD_CARGO')
+ expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
- await button(wrapper, '刷新当前资料').trigger('click'); await flushPromises()
+ await button(wrapper, '刷新本车').trigger('click'); await flushPromises()
expect(api.addDispatchCargo).toHaveBeenCalledTimes(1)
- expect(wrapper.emitted('saved')).toHaveLength(1)
+ expect(api.getDispatchCommand).not.toHaveBeenCalled()
})
it('does not let a late previous dispatch read overwrite the selected dispatch', async () => {
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.vue b/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.vue
index 71e63695..bf0fff83 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.vue
+++ b/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.vue
@@ -31,7 +31,6 @@ const reading = ref(false), listing = ref(false), preparing = ref(false), stale
const removing = ref(null), reason = ref('')
let generation = 0, listGeneration = 0, alive = true
const cargoPending = computed(() => pending.value?.action === 'ADD_CARGO' || pending.value?.action === 'REMOVE_CARGO')
-const pendingMessage = computed(() => !cargoPending.value ? '另一个排车操作尚待核对,请返回原操作入口处理。' : pending.value?.result ? '货物调整已完成,请刷新当前资料;不会重复提交。' : '原货物调整结果尚待核对,保留原版本和原请求内容。')
const frozen = computed(() => busy.value || preparing.value || Boolean(pending.value))
const loading = computed(() => reading.value || candidatePage.loading.value)
const editable = computed(() => !stale.value && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.taskType === 'DELIVERY')
@@ -176,7 +175,7 @@ async function execute() {
const result = await recovery.execute(send, async target => {
const facts = await readFacts(target)
return facts.current
- }, api.getDispatchCommand)
+ })
if (!result || !alive) return
ElMessage.success(result.replayed ? '原货物调整结果已核对' : '本车货物已更新,请重新核对审核')
emit('saved')
@@ -185,7 +184,16 @@ async function execute() {
} catch (cause) {
if (!alive) return
stale.value = true
- report(cause, pending.value?.result ? '货物已调整,当前资料待刷新' : pending.value ? '操作结果尚未确认,请核对原请求' : '本次货物调整未通过,请刷新当前资料')
+ report(cause, '本次货物调整失败,请刷新当前资料后重试')
+ const failure = error.value
+ try {
+ const facts = await readFacts(no)
+ if (!alive || selectedNo.value !== no) return
+ detail.value = facts.current; items.value = facts.cargo; stale.value = false
+ if (removing.value && !facts.cargo.items.some(row => row.dispatchItemId === removing.value?.dispatchItemId)) removing.value = null
+ await loadCandidates()
+ } catch { /* 读取失败保留原业务错误,仍允许切换排车或手动刷新。 */ }
+ finally { error.value = failure }
}
}
async function retry() { if (cargoPending.value) await execute(); else if (selectedNo.value) await selectDispatch(selectedNo.value); else await loadList() }
@@ -200,8 +208,6 @@ onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidate
-
-
{{ pending?.result ? '刷新当前资料' : '核对并重试原请求' }}
diff --git a/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts b/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts
index 4484977f..476d1332 100644
--- a/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts
+++ b/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts
@@ -3,154 +3,65 @@ import { ApiResponseError } from '@/shared/api/http'
import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
import { command } from './dispatchTestFixtures'
beforeEach(() => sessionStorage.clear())
-it('rejects a loading result that has not completed loading', async () => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
- const read = vi.fn()
- await expect(recovery.execute(vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'LOADING' }), read, vi.fn())).rejects.toThrow('装车完成')
- expect(read).not.toHaveBeenCalled()
- expect(recovery.pending.value?.result).toBeNull()
- expect(recovery.pending.value?.body.idempotencyKey).toBe('key')
+it.each([new Error('network unknown'), new ApiResponseError('server failure','S0500',200), new ApiResponseError('gateway timeout','HTTP_504',504), new ApiResponseError('version changed','A0409',200)])('releases failed $message and permits a new request with edited input', async failure => {
+ const owner=crypto.randomUUID(), state=useDispatchCommandRecovery(()=>owner)
+ state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'first',remark:'original'})
+ await expect(state.execute(vi.fn().mockRejectedValue(failure),vi.fn())).rejects.toBe(failure)
+ expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
+ expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
+ const reopened=useDispatchCommandRecovery(()=>owner)
+ reopened.begin('PC/1','EDIT',{recordVersion:2,idempotencyKey:'second',remark:'edited'})
+ const send=vi.fn().mockResolvedValue({...command('EDIT','second'),recordVersion:3})
+ await reopened.execute(send,vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:3}))
+ expect(send.mock.calls[0]![0].body).toEqual({recordVersion:2,idempotencyKey:'second',remark:'edited'})
+ expect(reopened.pending.value).toBeNull()
})
-it('retains the exact original version and body when POST outcome is unknown and a newer detail was read', async () => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
- const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('LOADING_COMPLETE'))
- const read = vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 50 }), find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
- await expect(recovery.execute(send, read, find)).rejects.toThrow('network unknown')
- await recovery.execute(send, read, find)
- expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
- expect(recovery.pending.value).toBeNull()
+it.each(['{broken',JSON.stringify({attempted:true,result:null,action:'EDIT'})])('clears saved failed requests without restoring them',saved=>{
+ const owner=crypto.randomUUID()
+ sessionStorage.setItem(`dispatch-command:${owner}`,saved)
+ const state=useDispatchCommandRecovery(()=>owner)
+ expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
+ expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
})
-it('keeps a proven result across GET failure and does not send the command a second time', async () => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
- const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
- await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
- expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
- expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE'))
- const reopened = useDispatchCommandRecovery(() => owner)
- expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE'))
- await reopened.execute(send, read, find)
- expect(send).toHaveBeenCalledTimes(1); expect(find).not.toHaveBeenCalled()
- expect(reopened.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
+it('prevents double clicks only during the active request',async()=>{
+ const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
+ state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'first'})
+ let finish!:(result:ReturnType
)=>void
+ const send=vi.fn().mockReturnValue(new Promise(done=>{finish=done}))
+ const read=vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:1})
+ const running=state.execute(send,read),another=useDispatchCommandRecovery(()=>owner)
+ expect(another.busy.value).toBe(true)
+ expect(()=>another.begin('PC/2','EDIT',{recordVersion:0,idempotencyKey:'second'})).toThrow('正在提交')
+ await another.execute(send,read);expect(send).toHaveBeenCalledTimes(1)
+ finish(command('EDIT','first'));await running
+ expect(another.pending.value).toBeNull();expect(another.busy.value).toBe(false)
+ expect(()=>another.begin('PC/2','EDIT',{recordVersion:0,idempotencyKey:'second'})).not.toThrow()
})
-it('shares one pending command between empty-create and workflow dialogs and refuses a new command', () => {
- const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner), second = useDispatchCommandRecovery(() => owner)
- first.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
- expect(second.pending.value?.dispatchNo).toBe('PC/1')
- expect(() => second.begin('', 'CREATE', { idempotencyKey: 'new' })).toThrow('原请求')
+it('unlocks even when a successful write is followed by a failed detail read',async()=>{
+ const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
+ state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'})
+ await expect(state.execute(vi.fn().mockResolvedValue(command('EDIT')),vi.fn().mockRejectedValue(new Error('read failed')))).rejects.toThrow('read failed')
+ expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
})
-it('does not discard an earlier unknown write on a later business rejection', async () => {
- const recovery = useDispatchCommandRecovery(() => crypto.randomUUID())
- expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })).toThrow('账号')
- const owner = crypto.randomUUID(), stable = useDispatchCommandRecovery(() => owner)
- stable.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
- const send = vi.fn().mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
- const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
- await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('unknown')
- await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('version conflict')
- stable.discardRejected(); expect(stable.pending.value).not.toBeNull()
- expect(find).toHaveBeenCalledWith('PC/1', 'key')
- expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key' })
- expect(stable.pending.value?.rejected).toBe(false)
- const saved = JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!)
- expect(saved).toMatchObject({ attempted: true, rejected: false, result: null, body: { recordVersion: 0, idempotencyKey: 'key' } })
- expect(useDispatchCommandRecovery(() => owner).pending.value).toEqual(saved)
+it('keeps loading-stage and response identity checks without retaining failed requests',async()=>{
+ const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner),read=vi.fn()
+ state.begin('PC/1','LOADING_COMPLETE',{recordVersion:0,idempotencyKey:'key'})
+ await expect(state.execute(vi.fn().mockResolvedValue({...command('LOADING_COMPLETE'),stage:'LOADING'}),read)).rejects.toThrow('装车完成')
+ expect(read).not.toHaveBeenCalled();expect(state.pending.value).toBeNull()
+ state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'})
+ await expect(state.execute(vi.fn().mockResolvedValue(command('EDIT','different')),read)).rejects.toThrow('不一致')
+ expect(read).not.toHaveBeenCalled();expect(state.pending.value).toBeNull()
})
-it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
- const read = vi.fn()
- await expect(recovery.execute(vi.fn().mockResolvedValue(command('LOADING_COMPLETE', 'different')), read, vi.fn())).rejects.toThrow('不一致')
- expect(read).not.toHaveBeenCalled(); expect(recovery.pending.value).not.toBeNull()
+it('preserves large version precision and unlocks after a mismatched detail',async()=>{
+ const owner=crypto.randomUUID(),state=useDispatchCommandRecovery(()=>owner)
+ state.begin('PC/1','EDIT',{recordVersion:'9007199254740992',idempotencyKey:'key'})
+ await expect(state.execute(vi.fn().mockResolvedValue({...command('EDIT'),recordVersion:'9007199254740993'}),vi.fn().mockResolvedValue({dispatchNo:'PC/1',recordVersion:'9007199254740992'}))).rejects.toThrow('详情版本早于')
+ expect(state.pending.value).toBeNull()
})
-it('does not release a proven result for an older detail version, including versions beyond safe integer precision', async () => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
- const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), recordVersion: '9007199254740993' }), find = vi.fn()
- await expect(recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740992' }), find)).rejects.toThrow('详情版本早于')
- expect(recovery.pending.value?.result).not.toBeNull()
- await recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740994' }), find)
- expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
-})
-it('treats an internal server error as unknown instead of authorizing a new command', async () => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
- await expect(recovery.execute(vi.fn().mockRejectedValue(new ApiResponseError('server failure', 'A0500', 500)), vi.fn(), vi.fn())).rejects.toThrow('server failure')
- recovery.discardRejected(); expect(recovery.pending.value?.rejected).toBe(false)
-})
-it.each(['400', '401', '403', '404', '409', '410', '422'].flatMap(code => [code, `A0${code}`, `HTTP_${code}`]))(
- 'clears the first definite rejection %s from memory and storage and preserves its original error', async code => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'rejected-key' })
- const rejection = new ApiResponseError('明确拒绝', code)
- const send = vi.fn().mockRejectedValue(rejection), read = vi.fn(), find = vi.fn()
- await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
- expect(send).toHaveBeenCalledTimes(1)
- expect(read).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled()
- expect(recovery.pending.value).toBeNull(); expect(recovery.busy.value).toBe(false)
- expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
- const reopened = useDispatchCommandRecovery(() => owner)
- expect(reopened.pending.value).toBeNull()
- expect(() => reopened.begin('PC/1', 'EDIT', { recordVersion: 2, idempotencyKey: 'fresh-key' })).not.toThrow()
- },
-)
-it('cleans an explicitly rejected legacy browser record when its scope is restored', () => {
- const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
- sessionStorage.setItem(key, JSON.stringify({ dispatchNo: 'PC/1', action: 'SUBMIT_REVIEW',
- body: { recordVersion: 0, idempotencyKey: 'terminal-key' }, attempted: true, rejected: true, result: null }))
- const recovery = useDispatchCommandRecovery(() => owner)
- expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
- expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 1, idempotencyKey: 'new-key' })).not.toThrow()
-})
-it('cleans a terminal rejected record already shared by dialogs in the same scope', () => {
- const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner)
- first.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'terminal-key' })
- first.pending.value!.attempted = true; first.pending.value!.rejected = true
- sessionStorage.setItem(`dispatch-command:${owner}`, JSON.stringify(first.pending.value))
- const second = useDispatchCommandRecovery(() => owner)
- expect(second.pending).toBe(first.pending)
- expect(first.pending.value).toBeNull(); expect(sessionStorage.getItem(`dispatch-command:${owner}`)).toBeNull()
-})
-it('retains a restored unknown record without explicit rejection proof and reconciles its original key', async () => {
- const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
- const original = { dispatchNo: 'PC/1', action: 'EDIT', body: { recordVersion: 0, idempotencyKey: 'unknown-key', remark: 'original' },
- attempted: true, rejected: false, result: null }
- sessionStorage.setItem(key, JSON.stringify(original))
- const recovery = useDispatchCommandRecovery(() => owner)
- expect(recovery.pending.value).toEqual(original)
- const rejection = new ApiResponseError('later rejection', 'A0409')
- const send = vi.fn().mockRejectedValue(rejection), read = vi.fn()
- const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
- await expect(recovery.execute(send, read, find)).rejects.toBe(rejection)
- expect(find).toHaveBeenCalledWith('PC/1', 'unknown-key')
- expect(send.mock.calls[0]![0].body).toEqual(original.body)
- expect(read).not.toHaveBeenCalled()
- expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
-})
-it('only rereads a restored proven result and keeps it when the detail read still fails', async () => {
- const owner = crypto.randomUUID(), key = `dispatch-command:${owner}`
- const original = { dispatchNo: 'PC/1', action: 'LOADING_COMPLETE', body: { recordVersion: 0, idempotencyKey: 'key' },
- attempted: true, rejected: false, result: command('LOADING_COMPLETE') }
- sessionStorage.setItem(key, JSON.stringify(original))
- const recovery = useDispatchCommandRecovery(() => owner)
- const send = vi.fn(), find = vi.fn(), failure = new Error('detail unavailable')
- const read = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 })
- await expect(recovery.execute(send, read, find)).rejects.toBe(failure)
- expect(recovery.pending.value).toEqual(original); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(original)
- await recovery.execute(send, read, find)
- expect(send).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2)
- expect(recovery.pending.value).toBeNull(); expect(sessionStorage.getItem(key)).toBeNull()
-})
-
-it('accepts a loading confirmation that already departed and recovers it without resubmitting', async () => {
- const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
- recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
- const send = vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'DELIVERING', recordVersion: 3 })
- const read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 3 })
- await expect(recovery.execute(send, read, vi.fn())).rejects.toThrow('read failed')
- expect(recovery.pending.value?.result?.stage).toBe('DELIVERING')
- await recovery.execute(send, read, vi.fn())
- expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
+it('rejects an account change before sending and releases state',async()=>{
+ let owner=crypto.randomUUID();const state=useDispatchCommandRecovery(()=>owner)
+ state.begin('PC/1','EDIT',{recordVersion:0,idempotencyKey:'key'});owner=crypto.randomUUID()
+ const send=vi.fn()
+ await expect(state.execute(send,vi.fn())).rejects.toThrow('账号已变化')
+ expect(send).not.toHaveBeenCalled();expect(state.pending.value).toBeNull();expect(state.busy.value).toBe(false)
})
diff --git a/src/modules/overseas-warehouse/dispatchCommandRecovery.ts b/src/modules/overseas-warehouse/dispatchCommandRecovery.ts
index 71e1e019..7908a5b4 100644
--- a/src/modules/overseas-warehouse/dispatchCommandRecovery.ts
+++ b/src/modules/overseas-warehouse/dispatchCommandRecovery.ts
@@ -3,7 +3,7 @@ import { ApiResponseError } from '@/shared/api/http'
import type { CommandResult, DispatchAction } from './dispatchContracts'
import { requireVersion } from './dispatchContracts'
-export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
+export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record; operation?: 'ROUTE'; itemId?: string; submitReviewAfterSave?: boolean }
const storageKey = (scope: string) => `dispatch-command:${scope}`
const snapshot = (value: T): T => JSON.parse(JSON.stringify(value)) as T
const sessions = new Map>; busy: ReturnType> }>()
@@ -13,74 +13,46 @@ export function isDefiniteDispatchRejection(error: unknown) {
export function validateCommandResult(result: CommandResult, pending: PendingDispatchCommand) {
if (!result || result.dispatchNo !== pending.dispatchNo && pending.action !== 'CREATE'
|| result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
- || !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
+ || !result.dispatchNo || !result.result) throw new Error('操作结果与本次排车请求不一致,请刷新后重试')
requireVersion(result.recordVersion)
- if (pending.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage)) throw new Error('装车结果未确认装车完成,请核对原请求')
+ if (pending.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage)) throw new Error('装车结果未确认装车完成,请刷新当前排车')
}
export function validateCurrentDispatch(value: unknown, result: CommandResult) {
const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
if (current?.dispatchNo !== result.dispatchNo) throw new Error('恢复详情与原排车单不一致')
const latest = String(requireVersion(current.recordVersion)).replace(/^0+(?=\d)/, '')
const confirmed = String(requireVersion(result.recordVersion)).replace(/^0+(?=\d)/, '')
- if (latest.length < confirmed.length || latest.length === confirmed.length && latest < confirmed) throw new Error('详情版本早于已确认操作,请保留原请求重读')
+ if (latest.length < confirmed.length || latest.length === confirmed.length && latest < confirmed) throw new Error('详情版本早于已确认操作,请刷新后重试')
}
-/** A response proves the original command; a subsequent GET supplies current UI facts. */
+/** 仅在当前请求期间防止连点;失败立即释放页面,不持久化或恢复历史请求。 */
export function useDispatchCommandRecovery(scope: () => string) {
const owner = scope()
- const saved = owner ? sessionStorage.getItem(storageKey(owner)) : null
+ if (owner) sessionStorage.removeItem(storageKey(owner))
const existing = sessions.get(owner)
const pending = existing?.pending || ref(null)
const busy = existing?.busy || ref(false)
- if (!existing && saved) {
- try { pending.value = JSON.parse(saved) as PendingDispatchCommand }
- catch { throw new Error('本次排车请求记录损坏,请保留现场核对') }
- }
+ if (!busy.value) pending.value = null
if (!existing) sessions.set(owner, { pending, busy })
- function persist() {
+ function requireOwner() {
if (!owner || owner !== scope()) throw new Error('账号已变化,请重新进入排车列表')
- if (pending.value) sessionStorage.setItem(storageKey(owner), JSON.stringify(pending.value))
- else sessionStorage.removeItem(storageKey(owner))
}
function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick = {}) {
- if (pending.value) throw new Error('上次排车操作结果尚未核对,请先恢复原请求')
- pending.value = { dispatchNo, action, body: snapshot(body) as Record, attempted: false, result: null, rejected: false, ...target }
- persist()
+ requireOwner()
+ if (busy.value) throw new Error('操作正在提交,请稍候')
+ pending.value = { dispatchNo, action, body: snapshot(body) as Record, ...target }
}
- async function execute(send: (command: PendingDispatchCommand) => Promise, read: (dispatchNo: string) => Promise, find: (dispatchNo: string, key: string) => Promise) {
+ async function execute(send: (command: PendingDispatchCommand) => Promise, read: (dispatchNo: string) => Promise) {
if (busy.value || !pending.value) return
const command = pending.value
- const previouslyAttempted = command.attempted
busy.value = true
try {
- persist()
- if (!command.result && previouslyAttempted && command.dispatchNo) {
- try { const found = await find(command.dispatchNo, String(command.body.idempotencyKey)); validateCommandResult(found, command); command.result = found }
- catch (error) { if (!(error instanceof ApiResponseError) || !['404', 'A0404', 'HTTP_404'].includes(String(error.code))) throw error }
- }
- if (!command.result) {
- command.attempted = true; persist()
- try { const sent = await send(snapshot(command)); validateCommandResult(sent, command); command.result = sent }
- catch (error) {
- // A later rejection cannot erase an earlier unknown write result.
- if (!previouslyAttempted && isDefiniteDispatchRejection(error)) {
- pending.value = null
- }
- persist(); throw error
- }
- }
- validateCommandResult(command.result, command); persist()
- validateCurrentDispatch(await read(command.result.dispatchNo), command.result)
- pending.value = null; persist()
- return command.result
- } finally { busy.value = false }
+ requireOwner()
+ const result = await send(snapshot(command))
+ validateCommandResult(result, command)
+ validateCurrentDispatch(await read(result.dispatchNo), result)
+ return result
+ } finally { pending.value = null; busy.value = false }
}
- function discardRejected() {
- if (busy.value || pending.value?.rejected !== true) return
- pending.value = null; persist()
- }
- // Older browser records explicitly marked a first-attempt rejection as terminal.
- // Only that recorded proof permits cleanup; unknown attempts and proven results remain.
- discardRejected()
- return { pending, busy, begin, execute, discardRejected }
+ return { pending, busy, begin, execute }
}
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
index 5c0e26b0..0184b305 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
@@ -8,9 +8,8 @@ import DataTable from '@/shared/components/DataTable.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { cancelTransport, getTransport } from '../api/transportTasks'
-import { command, detail, listRow, page, reviews } from '../dispatchTestFixtures'
+import { detail, listRow, page, reviews } from '../dispatchTestFixtures'
import type { DispatchPage } from '../dispatchContracts'
-import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
const push = vi.hoisted(() => vi.fn())
const can = vi.hoisted(() => vi.fn(() => true))
@@ -104,13 +103,13 @@ it('keeps return-to-review on the loading list with its service gate and permiss
expect(denied.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
denied.unmount()
})
-it('offers recovery of the original pending command independently of the filtered list rows', async () => {
- const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'original-key' })
- const wrapper = mount(DispatchesView, options); await flushPromises()
- expect(wrapper.text()).toContain('PC-OTHER 的确认装车完成结果尚待核对')
- await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
- const dialog = wrapper.get('dispatch-workflow-dialog-stub')
- expect(dialog.attributes('dispatchno')).toBe('PC-OTHER'); expect(dialog.attributes('modelvalue')).toBe('true'); wrapper.unmount()
+it('does not let an old pending command hide or block filtered list operations',async()=>{
+ sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC-OTHER',action:'LOADING_COMPLETE',attempted:true,body:{idempotencyKey:'old'}}))
+ const wrapper=mount(DispatchesView,options);await flushPromises()
+ expect(wrapper.text()).not.toContain('结果尚待核对')
+ expect(wrapper.findAll('button').some(button=>button.text()==='核对原请求')).toBe(false)
+ expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
+ expect(wrapper.text()).toContain('PC/1');wrapper.unmount()
})
it('centralizes cargo changes in smart dispatch and never exposes row add/remove actions', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } })]))
@@ -156,13 +155,13 @@ it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action, dispatchNo: 'PC/1' })
wrapper.unmount()
})
-it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('recovers %s only inside smart dispatch', async action => {
- useDispatchCommandRecovery(() => identity.id).begin('PC-OTHER', action, { recordVersion: 0, idempotencyKey: 'cargo-key' }, action === 'REMOVE_CARGO' ? { itemId: '17' } : undefined)
- const wrapper = mount(DispatchesView, options); await flushPromises()
- await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
- expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
- expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
- wrapper.unmount()
+it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s requests while allowing smart dispatch to open normally',async action=>{
+ sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC-OTHER',action,attempted:true,body:{idempotencyKey:'old'}}))
+ const wrapper=mount(DispatchesView,options);await flushPromises()
+ await wrapper.findAll('button').find(button=>button.text()==='智能排车')!.trigger('click')
+ expect(wrapper.getComponent({name:'SmartDispatchDialog'}).props('modelValue')).toBe(true)
+ expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
+ expect(wrapper.text()).not.toContain('结果尚待核对');wrapper.unmount()
})
it.each([1, 2])('keeps only details in audit tab %s without its review permission', async index => {
can.mockReturnValue(false)
@@ -397,16 +396,12 @@ it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %
expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount()
})
-it('describes a proven command awaiting its GET as completed instead of unknown', async () => {
- const recovery = useDispatchCommandRecovery(() => identity.id)
- recovery.begin('PC/1', 'SUBMIT_REVIEW', { recordVersion: 0, idempotencyKey: 'known-key' })
- recovery.pending.value!.attempted = true
- recovery.pending.value!.result = command('SUBMIT_REVIEW', 'known-key')
- const wrapper = mount(DispatchesView, options); await flushPromises()
- expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
+it('does not restore stored failed commands or expose replay controls in the list',async()=>{
+ sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action:'SUBMIT_REVIEW',attempted:true,result:{recordVersion:1},body:{idempotencyKey:'old'}}))
+ const wrapper=mount(DispatchesView,options);await flushPromises()
expect(wrapper.text()).not.toContain('结果尚待核对')
- await wrapper.findAll('button').find(button => button.text() === '刷新页面资料')!.trigger('click')
- expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
+ expect(wrapper.text()).not.toContain('刷新页面资料')
+ expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
wrapper.unmount()
})
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
@@ -433,3 +428,17 @@ it('opens the exception form from the dispatching row and hides it without permi
expect((deniedRow.props('actions') as Array<{ key: string }>).map(item => item.key)).not.toContain('MARK_EXCEPTION')
denied.unmount()
})
+
+it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_REJECTED']] as const)('returns a rejected %s to vehicle information',async(action,index,reviewState)=>{
+ const allowed={allowed:true,blockCode:null,blockReason:null}
+ vi.mocked(listDispatches).mockResolvedValue(page([listRow({actions:{[action]:allowed}})]))
+ const wrapper=mount(DispatchesView,options);await flushPromises()
+ await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click');await flushPromises()
+ const row=wrapper.findAllComponents(RowActions).find(entry=>entry.props('moreLabel')==='更多操作 · PC/1')!
+ row.vm.$emit('action',action);await flushPromises()
+ vi.mocked(getDispatch).mockResolvedValueOnce(detail({reviewState,stage:'BEFORE_LOADING'}))
+ wrapper.getComponent({name:'DispatchWorkflowDialog'}).vm.$emit('saved');await flushPromises()
+ expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({page:1,stage:'BEFORE_LOADING',beforeLoadingSection:'VEHICLE_INFO'}))
+ expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
+ wrapper.unmount()
+})
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue
index 7b507f33..8f37420b 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue
@@ -7,7 +7,6 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
-import { useAuthStore } from '@/core/auth/auth'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -28,10 +27,8 @@ import { cancelTransport, getTransport, transportLabels, type TransportType } fr
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchDocumentsDialog from '../components/DispatchDocumentsDialog.vue'
-import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
-const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const rows = ref([]), statistics = ref(null)
const loading = ref(false), loadError = ref(null), mutationError = ref(null)
const rejectionError = ref(null)
@@ -177,12 +174,19 @@ async function cancelPickup(row: DispatchListRow) {
finally { cancellingPickup.value = '' }
}
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
-async function handleSaved() { rejectionError.value = null; await load() }
-function recoverPending() {
- const command = recovery.pending.value
- if (!command) return
- if (['CREATE', 'ADD_CARGO', 'REMOVE_CARGO'].includes(command.action)) { createOpen.value = true; return }
- operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
+async function handleSaved() {
+ rejectionError.value = null
+ if (actionNo.value && ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(action.value)) {
+ try {
+ const current = await getDispatch(actionNo.value)
+ if (current.dispatchNo === actionNo.value && current.stage === 'BEFORE_LOADING' && ['DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(current.reviewState)) {
+ stage.value = 'BEFORE_LOADING'; section.value = 'VEHICLE_INFO'; page.value = 1
+ model.value.status = 'ACTIVE'
+ if (model.value.vehicleStatus) model.value.vehicleStatus = 'WAIT_DEPARTURE'
+ }
+ } catch (cause) { mutationError.value = getApiErrorDetails(cause, '审核已处理,当前排车状态读取失败,请刷新') }
+ }
+ await load()
}
async function openBol(row: DispatchListRow) {
if (bolLoadingNo.value || !canDownloadBol(row)) return
@@ -204,7 +208,6 @@ onBeforeUnmount(() => { sequence++ })
- {{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}
返回预约工作
安排到仓取货安排本土卡车智能排车
diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
index 39c61da0..5ee56ea3 100644
--- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
+++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts
@@ -271,7 +271,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).not.toContain('一次排车只能装同一客户')
expect(dialog).not.toContain('ensureDraftToken')
expect(dialog).not.toContain('preArrangeDispatch')
- expect(dialog).toContain('原请求结果尚待核对')
+ expect(dialog).not.toContain('原请求结果尚待核对')
+ expect(dialog).not.toContain('sessionStorage.setItem')
expect(dialog).not.toContain('v-model="route"')
const vehicleSpecs = source('../dispatchVehicleSpecs.ts')
expect(vehicleSpecs).toContain("label: '大车(30托)'")