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
@@ -109,22 +109,22 @@ describe('dispatch workflow native layout', () => {
expect(api.submitDispatchReview).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
})
it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => {
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT')
await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED')
dialog.vm.$emit('confirm'); await flushPromises()
const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch
it('keeps failed input editable and permits a new request and another target',async()=>{
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认','S0503',200,null,'workflow-request')).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
const {wrapper,dialog}=await render('EDIT')
await field(dialog,'车牌号').get('input').setValue('CA-PRESERVED')
dialog.vm.$emit('confirm');await flushPromises()
const original=structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(dialog.text()).toContain('workflow-request')
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises()
expect(dialog.props('title')).toBe('维护车辆 · PC/1')
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
expect(original.remark).toBe('真实备注')
expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false)
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
expect(field(dialog,'车牌号').get('input').attributes('disabled')).toBeUndefined()
expect((field(dialog,'车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED')
await field(dialog,'车牌号').get('input').setValue('CA-CHANGED')
dialog.vm.$emit('confirm');await flushPromises()
const next=vi.mocked(api.updateDispatch).mock.calls[1]![1]
expect(next.vehicle?.plate).toBe('CA-CHANGED');expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
await wrapper.setProps({dispatchNo:'PC/2',action:'CANCEL'});await flushPromises()
expect(dialog.props('title')).toContain('PC/2')
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => {
const { dialog } = await render(action)
@@ -143,7 +143,7 @@ describe('dispatch workflow native layout', () => {
expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' })
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and keeps an unknown result on the original request', async action => {
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and permits a new decision after failure', async action => {
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
vi.mocked(review).mockRejectedValueOnce(new Error('结果未知'))
const { dialog } = await render(action)
@@ -153,10 +153,10 @@ describe('dispatch workflow native layout', () => {
const original = structuredClone(vi.mocked(review).mock.calls[0]![1])
expect(dialog.props('showCancel')).toBe(false)
expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false)
expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(false)
await dialog.findAll('button').find(button => button.text() === '核对并重试原请求')!.trigger('click')
expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click')
await flushPromises()
expect(vi.mocked(review).mock.calls[1]![1]).toEqual(original)
expect(vi.mocked(review).mock.calls[1]![1].decision).toBe('APPROVED');expect(vi.mocked(review).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('disables both %s decisions when write permission is missing', async action => {
can.mockReturnValue(false)
@@ -169,7 +169,7 @@ describe('dispatch workflow native layout', () => {
expect(api.reviewDispatch).not.toHaveBeenCalled()
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
})
it('uploads all three photos during resource maintenance and retries the exact original edit', async () => {
it('retains all three uploaded photos and permits a fresh vehicle save after failure', async () => {
can.mockImplementation(code => code === 'platform:overseas-warehouse:dispatch:edit')
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
const { wrapper, dialog } = await render('EDIT', undefined, true)
@@ -191,16 +191,16 @@ describe('dispatch workflow native layout', () => {
dialog.vm.$emit('confirm'); await flushPromises()
const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1])
expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } })
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original)
const next=vi.mocked(api.updateDispatch).mock.calls[1]![1]
expect(next).toMatchObject({...original,recordVersion:1,idempotencyKey:expect.any(String)})
expect(next.idempotencyKey).not.toBe(original.idempotencyKey)
expect(api.reviewDispatch).not.toHaveBeenCalled()
expect(api.reviewDispatchLoading).not.toHaveBeenCalled()
})
it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => {
const { wrapper, dialog } = await render(action)
const value = detail({ actions })
value.vehicleInfo.vehicle!.evidenceFileIds = ['401']
value.vehicleInfo.driver!.evidence!.photoFileIds = ['402']
value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] }
vi.mocked(api.getDispatch).mockResolvedValueOnce(value)
@@ -208,9 +208,6 @@ describe('dispatch workflow native layout', () => {
const fields = dialog.findAllComponents(DispatchEvidenceFiles)
expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片'])
expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true)
expect(fields[0]!.props('modelValue')).toEqual(['401'])
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '401')
expect(fields[1]!.props('modelValue')).toEqual(['402', '403'])
expect(dialog.findAll('input[type="file"]')).toHaveLength(0)
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
@@ -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 () => {
@@ -166,26 +166,23 @@ async function submit(submitReviewAfterSave = false) {
if (value.dispatchNo === currentNo.value) detail.value = value
return value
}
let result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
let result = await recovery.execute(send, readCurrent)
if (!result) return
if (continueReview) {
emit('saved')
if (!canDispatchAction(detail.value?.actions, 'SUBMIT_REVIEW', access.can)) throw new DispatchInputError(detail.value?.actions.SUBMIT_REVIEW?.blockReason || '车辆信息已保存,当前不能提交审核,请核对资料')
recovery.begin(result.dispatchNo, 'SUBMIT_REVIEW', { recordVersion: requireVersion(detail.value?.recordVersion), idempotencyKey: crypto.randomUUID(), remark: null })
editorRequestKey = String(pending.value?.body.idempotencyKey || '')
result = await recovery.execute(send, readCurrent, api.getDispatchCommand)
result = await recovery.execute(send, readCurrent)
if (!result) return
}
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
emit('saved'); open.value = false
} catch (cause) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
// Recovery clears only a fresh, definite rejection. A later refusal must not erase an unknown write.
if (!pending.value && isDefiniteDispatchRejection(cause)) {
editorRequestKey = ''
emit('rejected', error.value)
await refreshFacts()
}
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次操作失败,请重试')
editorRequestKey = ''
if (isDefiniteDispatchRejection(cause)) emit('rejected', error.value)
if (!(cause instanceof DispatchInputError)) await refreshFacts()
} finally { submitting.value = false }
}
async function refreshFacts() {
@@ -214,12 +211,11 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
<div class="density-dialog-sections">
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<el-alert v-if="pending && !cargoAction" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
@@ -268,7 +264,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<template v-if="vehicleEditor" #footer="{ close }">
<el-button :disabled="submitting || busy || uploadingEvidence" @click="close">取消</el-button>
<el-button v-if="!pending" :disabled="submitting || loading || uploadingEvidence || !allowed" @click="submit()">保存</el-button>
<el-button type="primary" :loading="submitting || busy" :disabled="loading || uploadingEvidence || !pending && !allowed" @click="submit(true)">{{ pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '提交审核' }}</el-button>
<el-button type="primary" :loading="submitting || busy" :disabled="loading || uploadingEvidence || !pending && !allowed" @click="submit(true)">提交审核</el-button>
</template>
<template v-else-if="isReview && !pending" #footer>
<div class="workflow-review-actions">
@@ -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 () => {
@@ -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
@@ -7,7 +7,6 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
@@ -33,15 +32,13 @@ it('switches to existing cargo inside the same dialog without creating a draft o
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('opens %s recovery in existing cargo without reading creation drafts', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/1', action, { recordVersion: 0, idempotencyKey: 'existing-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
expect(api.listDispatchDrafts).not.toHaveBeenCalled()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(recovery.pending.value?.body.idempotencyKey).toBe('existing-key')
wrapper.unmount()
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s browser requests and opens a fresh workspace',async action=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action,attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
expect(wrapper.findComponent({name:'SmartDispatchExistingCargo'}).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('原请求结果尚待核对');wrapper.unmount()
})
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
const [first, second] = sharedPalletCandidates(), original = draft()
@@ -68,7 +65,7 @@ it('allows one atomic removal of a restored group that is no longer selectable',
})
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in conversion and refreshes the batch version on a new click', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
@@ -80,7 +77,10 @@ it.each([0, '0', '9007199254740993'])('transmits original authoritative batch ve
const request = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(request.groups[0]!.dispatch.batchAllocations).toEqual([{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }])
original.sources[0]!.appointmentBatches[0]!.recordVersion = '9007199254740994'
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenNthCalledWith(2, request)
await state.submit()
const next=vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]
expect(next.idempotencyKey).not.toBe(request.idempotencyKey)
expect(next.groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe('9007199254740994')
} finally { wrapper.unmount() }
})
it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restored candidate with missing or invalid batch version %s', async recordVersion => {
@@ -113,11 +113,11 @@ it('restores an explicit draft and submits complete server source versions in ea
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] }))
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
it('releases an unknown conversion and starts a fresh request on the next click', async () => {
vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('network unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]); wrapper.unmount()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0].idempotencyKey).not.toBe(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].idempotencyKey); wrapper.unmount()
})
it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
@@ -127,7 +127,7 @@ it('accepts a partial candidate page without requiring the filtered whole set',
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 21, page: 1, size: 20, pages: 2, candidates: [source()], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('尚未完整返回'); wrapper.unmount()
})
it('keeps planning remarks independent with unassigned resources and reuses a proven conversion after a detail read fails', async () => {
it('keeps planning remarks independent with unassigned resources and permits resubmission after a failed detail read', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
@@ -137,16 +137,19 @@ it('keeps planning remarks independent with unassigned resources and reuses a pr
state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.form.remark = '车乙'
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('never sends an existing departure command through the empty creation transport', async () => {
useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
await (wrapper.vm as unknown as { submit(): Promise<void> }).submit()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
it('clears an old departure request without blocking new empty creation',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action:'DEPART',attempted:true,body:{idempotencyKey:'old'}}))
vi.mocked(api.createDispatch).mockImplementation(async body=>command('CREATE',body.idempotencyKey))
const wrapper=mount(SmartDispatchDialog,options);await flushPromises()
const state=wrapper.vm as unknown as {mode:string;submit():Promise<void>}
state.mode='empty';await flushPromises();await state.submit()
expect(api.createDispatch).toHaveBeenCalledOnce()
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull();wrapper.unmount()
})
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
@@ -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<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[] }
const pending = ref<DraftCommand | null>(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<PlatformWarehouseResource[]>([])
@@ -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<string, unknown>, 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<string, unknown>, 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 })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="needsRecovery ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
@@ -269,8 +261,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections dispatch-workspace">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="needsRecovery" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
@@ -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 () => {
@@ -31,7 +31,6 @@ const reading = ref(false), listing = ref(false), preparing = ref(false), stale
const removing = ref<DispatchCargoItem | null>(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
<template>
<div class="existing-cargo density-dialog-sections">
<el-alert v-if="pending" :title="pendingMessage" type="warning" :closable="false" show-icon />
<el-button v-if="cargoPending" :loading="busy" @click="execute">{{ pending?.result ? '刷新当前资料' : '核对并重试原请求' }}</el-button>
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
<div class="existing-cargo-query">