From 4bb21641d92fade390fe633d2445e2b14dc6de28 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 08:44:26 +0800 Subject: [PATCH] fix(appointments): resume existing batch dispatch after refresh --- .../appointment-calendar/PROTOTYPE_MATRIX.md | 2 + .../WorkDispatchForm.test.ts | 56 ++++++++++++++++++- .../appointment-calendar/WorkDispatchForm.vue | 39 ++++++++++--- .../WorkExistingDispatchForm.test.ts | 16 +++++- .../WorkExistingDispatchForm.vue | 20 ++++--- 5 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md index ade323b5..2bd91bd1 100644 --- a/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md +++ b/src/modules/appointment-calendar/PROTOTYPE_MATRIX.md @@ -171,4 +171,6 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格 | 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 | | 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 | +| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 | + 视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。 diff --git a/src/modules/appointment-calendar/WorkDispatchForm.test.ts b/src/modules/appointment-calendar/WorkDispatchForm.test.ts index 52cbd909..35886319 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.test.ts +++ b/src/modules/appointment-calendar/WorkDispatchForm.test.ts @@ -1,24 +1,76 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import WorkDispatchForm from './WorkDispatchForm.vue' -import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse' +import { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse' +import { getReceivingContext, type ReceivingContext } from './receivingApi' +import { batchContext } from './batch.test-support' import type { LeaveGuard } from './useWorkDraft' const access = vi.hoisted(() => ({ allowed: true })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) })) vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) -vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() })) +vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() })) +vi.mock('./receivingApi', async original => ({ ...await original(), getReceivingContext: vi.fn() })) +function receiving(batchKey: string, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', batchKey).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'v1', vehicles, blockReasons: [] }, executionScope: { version: 'e1', members: [] }, allowedActions: [], blockReasons: [] } } const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] } let guard: LeaveGuard function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '
' }, ElButton: { props: ['disabled'], template: '' }, LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '
' } } } }) } function button(wrapper: ReturnType, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! } beforeEach(() => { vi.clearAllMocks(); access.allowed = true + vi.mocked(getReceivingContext).mockImplementation(async batchKey => receiving(batchKey)) vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] }) vi.mocked(listDispatchDrafts).mockResolvedValue([]) vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] }) vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null) vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }]) }) +it('restores the original FBA dispatch after remount and opens trip preparation without any draft or creation', async () => { + vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }])) + vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', overseasWarehouseId: 'origin-1', packageCount: 4, appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] }) + for (let mountNumber = 0; mountNumber < 2; mountNumber++) { + const wrapper = render(); await flushPromises() + expect(wrapper.text()).toContain('DS-EXISTING') + expect(wrapper.text()).not.toContain('创建本客户排车单') + await button(wrapper, '继续安排本批车辆').trigger('click'); await flushPromises() + expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-EXISTING'] }) + await button(wrapper, '查看原排车单').trigger('click') + expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches?dispatchNo=DS-EXISTING']]) + wrapper.unmount() + } + expect(getBatchDispatchCandidates).not.toHaveBeenCalled() + expect(createDispatchDraftContainer).not.toHaveBeenCalled() + expect(lockDispatchDraftPallet).not.toHaveBeenCalled() + expect(preArrangeDraftDispatch).not.toHaveBeenCalled() +}) +it('fails closed when transport relations cannot be read', async () => { + vi.mocked(getReceivingContext).mockRejectedValue(new Error('unavailable')) + const wrapper = render(); await flushPromises() + expect(wrapper.text()).not.toContain('创建本客户排车单') + expect(getBatchDispatchCandidates).not.toHaveBeenCalled() + expect(preArrangeDraftDispatch).not.toHaveBeenCalled() + wrapper.unmount() +}) +it('does not offer remaining-cargo creation when the original dispatch no longer owns this batch', async () => { + vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }])) + vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [] }) + const wrapper = render(); await flushPromises() + expect(wrapper.text()).toContain('排车单与工作批次关联不一致') + expect(wrapper.text()).not.toContain('创建本客户排车单') + expect(wrapper.text()).not.toContain('核对本批剩余可排实物') + expect(getBatchDispatchCandidates).not.toHaveBeenCalled() + wrapper.unmount() +}) +it('loads remaining eligible cargo only after an explicit choice while retaining the existing dispatch', async () => { + vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }])) + vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] }) + const wrapper = render(); await flushPromises() + expect(getBatchDispatchCandidates).not.toHaveBeenCalled() + await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises() + expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith('FBA_APPOINTMENT:1') + expect(wrapper.text()).toContain('DS-EXISTING') + expect(preArrangeDraftDispatch).not.toHaveBeenCalled() + wrapper.unmount() +}) it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => { const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true) await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises() diff --git a/src/modules/appointment-calendar/WorkDispatchForm.vue b/src/modules/appointment-calendar/WorkDispatchForm.vue index 159e863c..cb368d4e 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.vue +++ b/src/modules/appointment-calendar/WorkDispatchForm.vue @@ -6,8 +6,10 @@ import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchD import { TripPreparationDialog } from '@/modules/overseas-master-data' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' -import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import WorkSharedTripForm from './WorkSharedTripForm.vue' +import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue' +import { getReceivingContext } from './receivingApi' import { rejectedCommand, useWorkDraft } from './useWorkDraft' const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>() const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>() @@ -15,10 +17,12 @@ const access = useAccessPreviewStore(), contexts = ref(null), saveError = ref(null) const capacity = ref(30), result = ref(null), prepareOpen = ref(false), rejected = ref(false) const sharedTripOpen = ref(false), sharedTripBusy = ref(false) +const existingDispatches = ref>([]), arrangingRemaining = ref(false) +const existingReady = ref(false) const selectionBaseline = ref('[]') const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null) const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission))) -const writable = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')) +const writable = computed(() => props.canWrite && readable.value && (!existingDispatches.value.length || existingReady.value) && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')) const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] })))) function reasons(row: BatchDispatchCandidate) { const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1 @@ -28,9 +32,24 @@ const chosen = computed(() => rows.value.filter(row => selected.value.includes(r useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value)) async function load() { if (!readable.value || pending.value) return - loading.value = true; error.value = null + loading.value = true; error.value = null; existingReady.value = false try { - contexts.value = await Promise.all([...new Set(props.batchKeys)].map(getBatchDispatchCandidates)) + // 从服务端当前运输关系恢复排车单;读取失败时禁止退回创建流程。 + const batches = [...new Set(props.batchKeys)] + const receiving = await Promise.all(batches.map(batch => getReceivingContext(batch))) + const references: typeof existingDispatches.value = [] + receiving.forEach((context, index) => { + const batchKey = batches[index]! + if (context.batch.batchKey !== batchKey) throw new ApiResponseError('运输关系与当前预约批次不一致,请重新读取', 'WORK_DISPATCH_MISMATCH') + for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) { + if (!vehicle.dispatchNo) throw new ApiResponseError('本批已有运输安排但缺少排车单号,请核对原车次', 'WORK_DISPATCH_MISMATCH') + references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId }) + } + }) + existingDispatches.value = references + contexts.value = []; selected.value = []; selectionBaseline.value = '[]' + if (references.length && !arrangingRemaining.value) return + contexts.value = await Promise.all(batches.map(batch => getBatchDispatchCandidates(batch))) const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0) selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : [] selectionBaseline.value = JSON.stringify(selected.value) @@ -81,18 +100,22 @@ async function refreshRejected() { finally { saving.value = false } } function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) } -async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; await load() } } +async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; arrangingRemaining.value = true; await load() } } onMounted(load)