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

This commit is contained in:
2026-10-22 10:10:32 +08:00
parent ef85ed30e4
commit 090431b409
18 changed files with 373 additions and 536 deletions
@@ -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 () => {