From 090431b40927b8f4f7770d4564dd23a463c0cf48 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 10:10:32 +0800 Subject: [PATCH] fix(dispatch): release failed request state and return rejected reviews --- .../appointment-calendar/WorkDispatchForm.vue | 2 +- .../WorkSharedTripForm.test.ts | 37 ++-- .../WorkSharedTripForm.vue | 4 - src/modules/overseas-warehouse/AGENTS.md | 2 +- .../DispatchWorkflowDialog.layout.test.ts | 49 ++--- .../components/DispatchWorkflowDialog.test.ts | 148 ++++++------- .../components/DispatchWorkflowDialog.vue | 22 +- .../SmartDispatchDialog.layout.test.ts | 86 ++++---- .../SmartDispatchDialog.planning.test.ts | 28 ++- .../components/SmartDispatchDialog.test.ts | 45 ++-- .../components/SmartDispatchDialog.vue | 77 +++---- .../SmartDispatchExistingCargo.test.ts | 43 ++-- .../components/SmartDispatchExistingCargo.vue | 16 +- .../dispatchCommandRecovery.test.ts | 199 +++++------------- .../dispatchCommandRecovery.ts | 66 ++---- .../pages/DispatchesView.test.ts | 59 +++--- .../pages/DispatchesView.vue | 23 +- .../pages/prototypeContract.test.ts | 3 +- 18 files changed, 373 insertions(+), 536 deletions(-) diff --git a/src/modules/appointment-calendar/WorkDispatchForm.vue b/src/modules/appointment-calendar/WorkDispatchForm.vue index 987058b4..5a6f43d1 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.vue +++ b/src/modules/appointment-calendar/WorkDispatchForm.vue @@ -139,7 +139,7 @@ async function executeStep() { if (command.action === 'CONVERT') { const converted = value as ConvertedDraft if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致') - validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null }) + validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } }) request.converted = converted } else { const draft = value as DispatchDraft; requireVersion(draft.recordVersion) diff --git a/src/modules/appointment-calendar/WorkSharedTripForm.test.ts b/src/modules/appointment-calendar/WorkSharedTripForm.test.ts index 3a5df8fc..bb8018f6 100644 --- a/src/modules/appointment-calendar/WorkSharedTripForm.test.ts +++ b/src/modules/appointment-calendar/WorkSharedTripForm.test.ts @@ -1,7 +1,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import WorkSharedTripForm from './WorkSharedTripForm.vue' -import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse' +import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse' import type { LeaveGuard } from './useWorkDraft' const access = vi.hoisted(() => ({ allowed: true })) const identity = vi.hoisted(() => ({ id: '' })) @@ -25,11 +25,14 @@ it('honors native stage/gate and permissions and rejects a different returned bu vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount() access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount() }) -it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => { - const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] }) - const wrapper = render(); await flushPromises(); expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(await guard()).toBe(false) - await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true) - expect(recovery.pending.value?.body.idempotencyKey).toBe('original-key'); wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('update:modelValue', false); await flushPromises(); expect(await guard()).toBe(false); wrapper.unmount() +it('ignores an old add command without blocking the calendar or another action',async()=>{ + sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,body:{idempotencyKey:'old'}})) + const wrapper=render();await flushPromises() + expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined() + expect(await guard()).toBe(true) + expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull() + expect(wrapper.text()).not.toContain('原排车请求尚未核对') + wrapper.unmount() }) it('retries lookup as a read and keeps the original detail navigation', async () => { vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2) @@ -50,22 +53,12 @@ it.each([false, true])('refreshes the original add-cargo gate after a rejection if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货') wrapper.unmount() }) -it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => { - const recovery = useDispatchCommandRecovery(() => identity.id) - recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] }) - recovery.pending.value!.attempted = true - const completed: CommandResult = { - dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', - occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 }, - replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] }, - } - recovery.pending.value!.result = completed - const wrapper = render(); await flushPromises() - expect(wrapper.text()).toContain('操作已完成,页面资料待刷新') - expect(wrapper.text()).not.toContain('原排车请求尚未核对') - await button(wrapper, '刷新加货资料').trigger('click') - expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true) - expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key') +it('does not offer a stored command replay when opening the form again',async()=>{ + sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,result:{recordVersion:1}})) + const wrapper=render();await flushPromises() + expect(wrapper.text()).not.toContain('刷新加货资料') + expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined() + expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull() wrapper.unmount() }) it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => { diff --git a/src/modules/appointment-calendar/WorkSharedTripForm.vue b/src/modules/appointment-calendar/WorkSharedTripForm.vue index 72b899ab..fb38910d 100644 --- a/src/modules/appointment-calendar/WorkSharedTripForm.vue +++ b/src/modules/appointment-calendar/WorkSharedTripForm.vue @@ -31,9 +31,6 @@ async function load() { finally { if (current === generation) loading.value = false } } function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } } -function recover() { - if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true -} function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() } function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() } watch(() => props.dispatchNo, load, { immediate: true }) @@ -47,7 +44,6 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })

{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱

{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}

-

{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}

选择同仓未安排货物查看原排车单 diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index f7cb91c0..904b8081 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -35,7 +35,7 @@ ## 履约硬边界 -- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车中可同步上传五张装车照片,确认装车完成且资料齐全自动生成派送单并进入派送中,资料未齐时留在装车后,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。 +- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车中可同步上传五张装车照片,确认装车完成且资料齐全自动生成派送单并进入派送中,资料未齐时留在装车后,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、幂等键和完整载荷;前端仅请求进行中防连点,成功或失败均立即结束请求态,不持久化未确认请求、不重放旧请求、不因失败锁住后续操作。失败保留表单并读取当前事实,用户下一次点击按当前版本和输入新建请求;成功后另读当前事实。 - 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。 - 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。 diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts index 5d3b4e3d..92013dc6 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts @@ -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() diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts index f98ddae1..e5bdf3de 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts @@ -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 () => { diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 203337a5..2342054d 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -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 })