import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import WorkDispatchForm from './WorkDispatchForm.vue' 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(), 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() expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] }) expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch') const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' }) expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] }) prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']]) wrapper.unmount() }) it('opens quantity splitting for only the current batch without locking physical cargo or creating a new full-source appointment', async () => { const wrapper = render(); await flushPromises() await button(wrapper, '按当前批次箱数分批').trigger('click') expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]) expect(lockDispatchDraftPallet).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount() }) it('preselects only eligible physical cargo of the entered batch, never unrelated allocations or unknown quantity', async () => { vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: 'other-batch', allocations: [{ ...candidate.allocations[0]!, batchKey: 'FBA_APPOINTMENT:999' }] }, { ...candidate, planPalletId: 'unknown', packageCount: null }] }) const wrapper = render(); await flushPromises() const inputs = wrapper.findAll('input[type=checkbox]') expect(inputs.map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false]) expect(inputs[1]!.attributes('disabled')).toBeDefined() await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises() expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]) expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(candidate.allocations) wrapper.unmount() }) it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => { vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response')) const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true) await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises() const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0] expect(await guard()).toBe(false) await button(wrapper, '重试本次预排').trigger('click'); await flushPromises() expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1) wrapper.unmount() }) it('recovers a draft container from the same token when its creation response was lost', async () => { vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response')) const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true) await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises() const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0] vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }]) await button(wrapper, '重试本次预排').trigger('click'); await flushPromises() expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount() }) it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => { vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b', allocations: [{ ...candidate.allocations[0]!, customerId: 'customer-b', planPalletId: '2' }] }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] }) const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]') expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量') await inputs[0]!.setValue(true); await inputs[1]!.setValue(true) expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false) await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises() expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount() }) it('does not read or write scoped cargo without all original permissions', async () => { access.allowed = false; const wrapper = render(); await flushPromises() expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount() }) it('continues the actual later 60-box dispatch through the shared-trip entry without re-creating or reallocating cargo', async () => { const sixty = { ...candidate, packageCount: 60, allocations: [{ ...candidate.allocations[0]!, packageCount: 60 }] } vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [sixty], blockReasons: [] }) vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-later', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'origin-1', packageCount: 60 } }]) const wrapper = render(); await flushPromises(); await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises() await button(wrapper, '与同仓其他排车单共车').trigger('click'); await flushPromises() const shared = wrapper.getComponent({ name: 'WorkSharedTripForm' }) expect(shared.props()).toMatchObject({ dispatchNo: 'DS-60', canWrite: true }) expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(sixty.allocations) shared.vm.$emit('busy', true); await flushPromises(); expect(button(wrapper, '继续安排其他实物').attributes('disabled')).toBeDefined() shared.vm.$emit('external', '/overseas-master-data/trips/TR-100'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-100']]) wrapper.unmount() })