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
@@ -40,19 +40,22 @@ it('accepts actual evidence and a matching confirmed manifest while refusing an
const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('requires a rejection reason and retains the original %s decision after an unknown result', async action => {
const success = vi.spyOn(ElMessage, 'success')
const reviewApi = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
const { wrapper, state } = await open(action); state.form.decision = 'REJECTED'
await state.submit(); expect(reviewApi).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(reviewApi).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'; state.form.decision = 'APPROVED'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command(action, key))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(reviewApi).toHaveBeenCalledTimes(1)
expect(vi.mocked(reviewApi).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
expect(success).toHaveBeenCalledWith('已退回客服处理(车辆信息),修改后需重新提交调度审核')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
it.each(['DISPATCH_REVIEW','LOADING_REVIEW'] as const)('allows editing the %s decision after a failed request',async action=>{
const reviewApi=action==='DISPATCH_REVIEW'?api.reviewDispatch:api.reviewDispatchLoading
const {wrapper,state}=await open(action);state.form.decision='REJECTED'
await state.submit();expect(reviewApi).not.toHaveBeenCalled()
state.form.reason='预约未核实';vi.mocked(reviewApi).mockRejectedValueOnce(new Error('unknown result'))
await state.submit()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
state.form.reason='重新确认';state.form.decision='APPROVED'
vi.mocked(reviewApi).mockImplementationOnce(async(_,body)=>command(action,body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1}))
await state.submit()
const calls=vi.mocked(reviewApi).mock.calls
expect(calls).toHaveLength(2)
expect(calls[1]![1].decision).toBe('APPROVED')
expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it('shows the preserved rejection while customer service edits vehicle information', async () => {
const { wrapper, state } = await open('EDIT')
@@ -81,7 +84,7 @@ it('preserves external destinations and retries route EDIT through its original
await state.submit(); vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('not found', 'HTTP_404'))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })); await state.submit()
const first = vi.mocked(api.reorderDispatchRoute).mock.calls[0]![1]
expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(api.reorderDispatchRoute).toHaveBeenNthCalledWith(2, 'PC/1', first); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(vi.mocked(api.reorderDispatchRoute).mock.calls[1]![1]).toMatchObject({destinationKeys:first.destinationKeys,reason:first.reason}); expect(vi.mocked(api.reorderDispatchRoute).mock.calls[1]![1].idempotencyKey).not.toBe(first.idempotencyKey); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
})
it('refuses a route that drops an authoritative destination', async () => {
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
@@ -133,37 +136,29 @@ it('reports a definite rejection to its parent even if the follow-up GET fails a
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps an unknown original command when its later replay is rejected instead of treating it as a fresh rejection', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown transport outcome')).mockRejectedValueOnce(new ApiResponseError('当前已提交', 'A0409', 409))
it('ends every failed request and displays a later business rejection without replaying',async()=>{
const {wrapper,state}=await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('当前已提交','A0409',409))
await state.submit()
const original = structuredClone(vi.mocked(api.submitDispatchReview).mock.calls[0]![1])
expect(wrapper.text()).toContain('原操作结果尚待核对')
vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('未找到命令', 'A0404', 404))
await state.submit(); await flushPromises()
expect(api.submitDispatchReview).toHaveBeenNthCalledWith(2, 'PC/1', original)
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.body).toEqual(original)
expect(wrapper.emitted('rejected')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(api.getDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('原操作结果尚待核对')
wrapper.unmount()
})
it('describes a proven success with a failed GET as completed and retries only its read', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
await state.submit(); await flushPromises()
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result).toMatchObject({ action: 'SUBMIT_REVIEW', recordVersion: 1 })
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.text()).not.toContain('核对并重试原请求')
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
await state.submit();await flushPromises()
const calls=vi.mocked(api.submitDispatchReview).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(api.getDispatchCommand).not.toHaveBeenCalled();expect(wrapper.text()).toContain('当前已提交');wrapper.unmount()
})
it('unlocks when a successful write is followed by a failed read',async()=>{
const {wrapper,state}=await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockImplementation(async(_,body)=>command('SUBMIT_REVIEW',body.idempotencyKey))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
await state.submit();await flushPromises()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1}))
await state.submit()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and directs cargo changes to smart dispatch', async action => {
@@ -179,23 +174,13 @@ it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and d
expect(wrapper.emitted('saved')).toBeUndefined()
} finally { wrapper.unmount() }
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s command for recovery in smart dispatch', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/ORIGINAL', action, { recordVersion: '9007199254740993', idempotencyKey: 'original-key', reason: 'original reason', cargo: [{ sourceVersion: 'original-source' }] }, action === 'REMOVE_CARGO' ? { itemId: 'original-item' } : {})
recovery.pending.value!.attempted = true
const original = JSON.parse(JSON.stringify(recovery.pending.value))
const { wrapper, state } = await open('EDIT')
try {
expect(wrapper.text()).toContain('请从智能排车恢复原请求')
await state.submit(); await state.retryError()
expect(recovery.pending.value).toEqual(original)
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
expect(api.updateDispatch).not.toHaveBeenCalled()
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('rejected')).toBeUndefined()
} finally { wrapper.unmount() }
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s records when opening another operation',async action=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/ORIGINAL',action,attempted:true,body:{idempotencyKey:'old'}}))
const {wrapper,state}=await open('EDIT')
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('请从智能排车恢复原请求')
expect(api.addDispatchCargo).not.toHaveBeenCalled();expect(api.removeDispatchItem).not.toHaveBeenCalled();wrapper.unmount()
})
it('requires an exception type and submits optional direction, description and images once', async () => {
@@ -249,33 +234,30 @@ it('rechecks review eligibility after save and leaves the saved editor open if b
expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(state.detail.recordVersion).toBe(1)
expect(wrapper.text()).toContain('预约资料未齐全'); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
})
it('retains the follow-up review intent through an unknown save and does not save twice on recovery', async () => {
const { wrapper, state } = await open('EDIT')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save outcome unknown'))
it('allows a fresh save and review after a failed save without persisting review intent',async()=>{
const {wrapper,state}=await open('EDIT')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save unknown'))
await state.submit(true)
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
expect(pending.submitReviewAfterSave).toBe(true)
expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${identity.id}`)!).submitReviewAfterSave).toBe(true)
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce(command('EDIT', String(pending.body.idempotencyKey)))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull(); wrapper.unmount()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
vi.mocked(api.updateDispatch).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({recordVersion:1,actions:{SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}})).mockResolvedValueOnce(detail({recordVersion:2}))
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>({...command('SUBMIT_REVIEW',body.idempotencyKey),recordVersion:2}))
await state.submit(true)
expect(api.updateDispatch).toHaveBeenCalledTimes(2);expect(api.submitDispatchReview).toHaveBeenCalledOnce()
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it('recovers an unknown review submission without saving the vehicle again', async () => {
const { wrapper, state } = await open('EDIT')
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: true, blockCode: null, blockReason: null } } }))
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review outcome unknown'))
it('allows a new save-and-review click after review submission fails',async()=>{
const {wrapper,state}=await open('EDIT')
vi.mocked(api.updateDispatch).mockImplementation(async(_,body)=>command('EDIT',body.idempotencyKey))
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1,actions:{EDIT:{allowed:true,blockCode:null,blockReason:null},SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}}))
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review unknown'))
await state.submit(true)
const pending = useDispatchCommandRecovery(() => identity.id).pending.value!
expect(pending.action).toBe('SUBMIT_REVIEW'); expect(pending.body).toMatchObject({ recordVersion: 1, remark: null })
vi.mocked(api.getDispatchCommand).mockResolvedValueOnce({ ...command('SUBMIT_REVIEW', String(pending.body.idempotencyKey)), recordVersion: 2 })
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 2 }))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(1); expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>command('SUBMIT_REVIEW',body.idempotencyKey))
await state.submit(true)
expect(api.updateDispatch).toHaveBeenCalledTimes(2);expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
})
it('persists the explicit publication choice with the existing vehicle save', async () => {