fix(appointments): resume existing batch dispatch after refresh

This commit is contained in:
2026-10-19 08:44:26 +08:00
parent 67d917010d
commit 4bb21641d9
5 changed files with 115 additions and 18 deletions
@@ -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<typeof import('@/modules/overseas-warehouse')>(), 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<typeof import('@/modules/overseas-warehouse')>(), 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<typeof import('./receivingApi')>(), 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: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
function button(wrapper: ReturnType<typeof render>, 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()