fix: align audit and dispatch workflows and release failed requests

This commit is contained in:
2026-10-22 23:10:09 +08:00
parent 9cdcb9c077
commit 4a3456756f
124 changed files with 1296 additions and 2120 deletions
@@ -11,7 +11,6 @@ const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('./DispatchAppointmentAllocationsDialog.vue', () => ({ default: { name: 'DispatchAppointmentAllocationsDialog', props: ['modelValue', 'dispatchNo'], emits: ['saved', 'update:modelValue'], template: '<div />' } }))
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string; publishLooseboardDemand: boolean }; detail: DispatchDetail }
async function open(action: DispatchAction, allowed = true) {
@@ -19,19 +18,7 @@ async function open(action: DispatchAction, allowed = true) {
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
return { wrapper, state: wrapper.vm as unknown as State }
}
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
it('opens appointment linking from vehicle maintenance and refreshes the original version without losing unsaved inputs', async () => {
const { wrapper, state } = await open('EDIT')
state.form.remark = '尚未保存的车辆备注'
const button = wrapper.findAll('button').find(button => button.text() === '关联正式预约')
expect(button).toBeDefined(); await button!.trigger('click'); await flushPromises()
const child = wrapper.findComponent({ name: 'DispatchAppointmentAllocationsDialog' })
expect(child.props('dispatchNo')).toBe('PC/1')
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2 }))
child.vm.$emit('saved'); await flushPromises()
expect(state.detail.recordVersion).toBe(2); expect(state.form.remark).toBe('尚未保存的车辆备注')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
@@ -103,17 +90,17 @@ 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' }] })
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
})
it('refreshes current actions and notifies its parent after a fresh A0409, without presenting an unknown result or replay request', async () => {
it('reports A0409 and immediately releases the form without a forced readback', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } }))
await state.submit(); await flushPromises()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(api.getDispatch).toHaveBeenCalledTimes(2)
expect(api.getDispatch).toHaveBeenCalledTimes(1)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]])
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
wrapper.unmount()
@@ -124,7 +111,7 @@ it('retains edited values and the original validation response when the action i
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
await state.submit(); await flushPromises()
expect(state.form.remark).toBe('用户未保存的本轮备注')
expect(state.detail.recordVersion).toBe(7)
expect(state.detail.recordVersion).toBe(0)
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
expect(wrapper.text()).toContain('A0400')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
@@ -135,7 +122,7 @@ it('retains edited values and the original validation response when the action i
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
wrapper.unmount()
})
it('reports a definite rejection to its parent even if the follow-up GET fails and blocks a new write until refreshed', async () => {
it('allows another click after rejection even when a later read would fail', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注'
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request'))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure'))
@@ -146,7 +133,7 @@ it('reports a definite rejection to its parent even if the follow-up GET fails a
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(state.form.remark).toBe('保留本次备注')
await state.submit()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('ends every failed request and displays a later business rejection without replaying',async()=>{
@@ -174,10 +161,10 @@ it('unlocks when a successful write is followed by a failed read',async()=>{
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 => {
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command after the existing cargo editor is retired', async action => {
const { wrapper, state } = await open(action)
try {
expect(wrapper.text()).toContain('货物安排统一在智能排车中修改')
expect(wrapper.text()).toContain('当前入口不支持调整已建排车货物')
await state.submit()
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
@@ -191,7 +178,7 @@ it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s records when open
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(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(wrapper.text()).not.toContain('请从智能排车恢复原请求')
expect(api.addDispatchCargo).not.toHaveBeenCalled();expect(api.removeDispatchItem).not.toHaveBeenCalled();wrapper.unmount()
})
@@ -252,7 +239,7 @@ it('allows a fresh save and review after a failed save without persisting review
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save unknown'))
await state.submit(true)
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).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}))
@@ -283,3 +270,14 @@ it('persists the explicit publication choice with the existing vehicle save', as
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, publishLooseboardDemand: false }))
wrapper.unmount()
})
it('saves a cleared unavailable master vehicle as an empty draft resource', async () => {
const { wrapper, state } = await open('EDIT')
const editor = wrapper.vm as unknown as { vehicle: { mode: 'MASTER'; vehicleId: string | null } }
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: null }))
expect(api.submitDispatchReview).not.toHaveBeenCalled()
wrapper.unmount()
})