From fa5692f7317bbe6fdd9aaf846f172678dc14c6f4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 11:28:42 +0800 Subject: [PATCH] Fix dispatch review action refresh and definite rejection recovery --- .../WorkSharedTripForm.test.ts | 39 +++++++++ .../WorkSharedTripForm.vue | 11 ++- .../components/DispatchWorkflowDialog.test.ts | 81 ++++++++++++++++++- .../components/DispatchWorkflowDialog.vue | 48 ++++++++--- .../dispatchCommandRecovery.test.ts | 75 ++++++++++++++++- .../dispatchCommandRecovery.ts | 9 ++- .../pages/DispatchDetailView.test.ts | 43 +++++++++- .../pages/DispatchDetailView.vue | 8 +- .../pages/DispatchesView.test.ts | 44 +++++++++- .../pages/DispatchesView.vue | 9 ++- 10 files changed, 344 insertions(+), 23 deletions(-) diff --git a/src/modules/appointment-calendar/WorkSharedTripForm.test.ts b/src/modules/appointment-calendar/WorkSharedTripForm.test.ts index 151d97f3..df931125 100644 --- a/src/modules/appointment-calendar/WorkSharedTripForm.test.ts +++ b/src/modules/appointment-calendar/WorkSharedTripForm.test.ts @@ -3,6 +3,7 @@ import { beforeEach, expect, it, vi } from 'vitest' import WorkSharedTripForm from './WorkSharedTripForm.vue' import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse' import type { LeaveGuard } from './useWorkDraft' +import { command } from '@/modules/overseas-warehouse/dispatchTestFixtures' const access = vi.hoisted(() => ({ allowed: true })) const identity = vi.hoisted(() => ({ id: '' })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) })) @@ -35,3 +36,41 @@ 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) await button(wrapper, '查看原排车单').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS-60']]); wrapper.unmount() }) +it.each([false, true])('refreshes the original add-cargo gate after a rejection and preserves its message when GET fails: %s', async readFails => { + const wrapper = render(); await flushPromises(); await button(wrapper, '选择同仓未安排货物').trigger('click') + if (readFails) vi.mocked(getDispatch).mockRejectedValueOnce(new Error('read unavailable')) + else vi.mocked(getDispatch).mockResolvedValueOnce(detail('DS-60', false)) + const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }) + dialog.vm.$emit('rejected', { message: '来源已变化,请重新核对', code: 'A0409' }) + dialog.vm.$emit('update:modelValue', false); await flushPromises() + expect(getDispatch).toHaveBeenCalledTimes(2) + expect(wrapper.emitted('updated')).toHaveLength(1) + expect(wrapper.text()).toContain('来源已变化,请重新核对') + expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined() + expect(wrapper.text()).not.toContain('原排车请求尚未核对') + 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 + recovery.pending.value!.result = command('ADD_CARGO', 'known-key', 'DS-60') + 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') + wrapper.unmount() +}) +it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => { + const wrapper = render(); await flushPromises() + const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' }) + dialog.vm.$emit('rejected', { message: '来源版本需重新核对', code: 'A0409' }); await flushPromises() + expect(wrapper.text()).toContain('来源版本需重新核对') + dialog.vm.$emit('saved'); await flushPromises() + expect(wrapper.text()).not.toContain('来源版本需重新核对') + expect(wrapper.emitted('updated')).toHaveLength(2) + wrapper.unmount() +}) diff --git a/src/modules/appointment-calendar/WorkSharedTripForm.vue b/src/modules/appointment-calendar/WorkSharedTripForm.vue index 16b0d4aa..72b899ab 100644 --- a/src/modules/appointment-calendar/WorkSharedTripForm.vue +++ b/src/modules/appointment-calendar/WorkSharedTripForm.vue @@ -11,6 +11,7 @@ const props = defineProps<{ dispatchNo: string; canWrite: boolean }>() const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>() const access = useAccessPreviewStore(), auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '') const detail = ref(null), open = ref(false), loading = ref(false), error = ref(null) +const rejectionError = ref(null) const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list')) const allowed = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.actions.ADD_CARGO?.allowed === true) const busy = computed(() => loading.value || open.value || Boolean(recovery.pending.value)) @@ -29,11 +30,12 @@ async function load() { } catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') } finally { if (current === generation) loading.value = false } } -function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) open.value = true } +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; emit('updated'); void load() } +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 }) onBeforeUnmount(() => { generation++; emit('busy', false) }) @@ -42,10 +44,11 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })

为原排车添加同仓货物

本车 {{ dispatchNo }}。只选择尚未安排的真实货物加入本车,不合并另一张排车单或搬迁已扫码货物。

箱数无法对应完整实物时,先在原库存作业完成拆托;预约分批不代替实物拆托。

+

{{ 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/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts index 1918a79c..6104a9de 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts @@ -7,12 +7,13 @@ import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue' import * as api from '../api/dispatches' import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures' import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts' +import { useDispatchCommandRecovery } from '../dispatchCommandRecovery' const identity = vi.hoisted(() => ({ id: '' })) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '
' } })) vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() })) -interface State { submit(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail } +interface State { submit(): Promise; retryError(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail } it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => { const [first, second] = sharedPalletCandidates() vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' }) @@ -155,3 +156,81 @@ it('refuses already picked item removal and a route that drops an authoritative vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] }) const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount() }) +it('refreshes current actions and notifies its parent after a fresh A0409, without presenting an unknown result or replay request', async () => { + const { wrapper, state } = await open('SUBMIT_REVIEW') + vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace')) + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })) + await state.submit(); await flushPromises() + expect(api.submitDispatchReview).toHaveBeenCalledTimes(1) + expect(api.getDispatch).toHaveBeenCalledTimes(2) + expect(api.getDispatchCommand).not.toHaveBeenCalled() + expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull() + expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]]) + expect(wrapper.emitted('update:modelValue')).toContainEqual([false]) + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.text()).not.toContain('原操作结果尚待核对') + wrapper.unmount() +}) +it('retains edited values and the original validation response when the action is still allowed', async () => { + const { wrapper, state } = await open('EDIT'); state.form.remark = '用户未保存的本轮备注' + vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('预计到达时间须在发车之后', 'A0400', 400, undefined, 'validation-request')) + vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } })) + await state.submit(); await flushPromises() + expect(state.form.remark).toBe('用户未保存的本轮备注') + expect(state.detail.recordVersion).toBe(7) + expect(wrapper.text()).toContain('预计到达时间须在发车之后') + expect(wrapper.text()).toContain('A0400') + expect(wrapper.text()).not.toContain('原操作结果尚待核对') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + await state.retryError() + expect(state.form.remark).toBe('用户未保存的本轮备注') + expect(api.updateDispatch).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('预计到达时间须在发车之后') + wrapper.unmount() +}) +it('reports a definite rejection to its parent even if the follow-up GET fails and blocks a new write until refreshed', async () => { + const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注' + vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request')) + vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure')) + await state.submit(); await flushPromises() + expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]]) + expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull() + expect(wrapper.text()).toContain('A0409') + expect(wrapper.text()).not.toContain('原操作结果尚待核对') + expect(state.form.remark).toBe('保留本次备注') + await state.submit() + 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)) + 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(wrapper.text()).not.toContain('原操作结果尚待核对') + expect(wrapper.text()).not.toContain('核对并重试原请求') + vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 })) + await state.submit() + expect(api.submitDispatchReview).toHaveBeenCalledTimes(1) + expect(api.getDispatchCommand).not.toHaveBeenCalled() + expect(wrapper.emitted('saved')).toHaveLength(1) + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 06b25eaf..b2c10732 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -14,14 +14,14 @@ import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candi import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts' import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts' import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation' -import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery' +import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery' import DispatchResourceFields from './DispatchResourceFields.vue' import DispatchArrangeFields from './DispatchArrangeFields.vue' import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' const open = defineModel({ default: false }) const props = defineProps<{ dispatchNo: string; action: DispatchAction; operation?: 'ROUTE' }>() -const emit = defineEmits<{ saved: [] }>() +const emit = defineEmits<{ saved: []; rejected: [details: ApiErrorDetails] }>() const auth = useAuthStore(), access = useAccessPreviewStore() const recovery = useDispatchCommandRecovery(() => auth.user?.id || '') const { pending, busy } = recovery @@ -30,6 +30,7 @@ const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo) const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation) const items = ref(null), itemId = ref(''), route = ref(null), stops = ref([]) const detail = ref(null), loading = ref(false), error = ref(null) +const factsStale = ref(false) const preview = ref(null), arrangement = ref(null), groups = ref([]) const arrangeFields = ref | null>(null) const candidates = ref([]), selected = ref>([]), candidateKeyword = ref('') @@ -40,7 +41,7 @@ const vehicle = ref(blankVehicle()), driver = ref(blankDriver() const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' }) let generation = 0 let editorRequestKey = '' -const allowed = computed(() => !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true)) +const allowed = computed(() => !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true)) const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px') const frozen = computed(() => busy.value || Boolean(pending.value)) const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false }) @@ -49,7 +50,7 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR async function load() { const sequence = ++generation, no = currentNo.value, action = currentAction.value if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return } - loading.value = true; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = [] + loading.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = [] items.value = null; itemId.value = ''; route.value = null; stops.value = [] try { if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车') @@ -207,17 +208,46 @@ async function submit() { if (!result) return ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`) emit('saved'); open.value = false - } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '操作结果尚未确认,请保留原请求核对') } + } 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() + } + } } +async function refreshFacts() { + const sequence = ++generation, no = currentNo.value + loading.value = true; factsStale.value = true + try { + const value = await api.getDispatch(no) + if (sequence !== generation) return + if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开') + detail.value = value + if (currentOperation.value === 'ROUTE') { + const data = await api.getDispatchRoute(no) + if (sequence !== generation) return + if (data.dispatchNo !== no) throw new DispatchInputError('路线与原排车单不一致') + route.value = data + } + factsStale.value = false + if (!pending.value && !allowed.value) open.value = false + } catch (cause) { + // Retain the original rejection and every editor value if its follow-up read also fails. + if (sequence === generation && !error.value) error.value = getApiErrorDetails(cause, '当前操作资格读取失败,请重试刷新') + } finally { if (sequence === generation) loading.value = false } +} +async function retryError() { if (pending.value) await submit(); else if (detail.value) await refreshFacts(); else await load() } function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] } watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })