import ElementPlus from 'element-plus' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import SmartDispatchDialog from './SmartDispatchDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue' import { listAllPlatformWarehouses } from '@/modules/warehouses' import * as api from '../api/dispatches' import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures' import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts' import { useDispatchCommandRecovery } from '../dispatchCommandRecovery' const identity = vi.hoisted(() => ({ id: 'smart-operator' })) const push = vi.hoisted(() => vi.fn()) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push }) })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '
' } })) vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() })) const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' }) it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => { const [first, second] = sharedPalletCandidates(), original = draft() original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey] vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' }) vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original) vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) })) const wrapper = mount(SmartDispatchDialog, options); await flushPromises() const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise; submit(): Promise } state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('LOCK', first, original.containers[0]!.draftContainerId) expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first!.selection })) await state.submit(); expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual([first!.selection, second!.selection]) wrapper.unmount() }) it('allows one atomic removal of a restored group that is no longer selectable', async () => { const first = sharedPalletCandidates()[0]!, original = draft() first.selectionGroup = { ...first.selectionGroup, complete: false, blockReasons: ['来源待核齐'] } original.sources = [first]; original.containers[0]!.sourceKeys = [first.sourceKey] vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.unlockDispatchDraftPallet).mockResolvedValue(original) const wrapper = mount(SmartDispatchDialog, options); await flushPromises() const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise } state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('UNLOCK', first, original.containers[0]!.draftContainerId) expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount() }) const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } } beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) }) it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => { const original = draft() original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]) vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) })) const wrapper = mount(SmartDispatchDialog, options) try { await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise } state.restoreDraft('server-token'); await flushPromises(); await state.submit() const request = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft expect(request.groups[0]!.dispatch.batchAllocations).toEqual([{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }]) original.sources[0]!.appointmentBatches[0]!.recordVersion = '9007199254740994' await state.submit(); expect(api.convertDispatchDraft).toHaveBeenNthCalledWith(2, request) } finally { wrapper.unmount() } }) it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restored candidate with missing or invalid batch version %s', async recordVersion => { const original = draft() original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: recordVersion as Version, sources: [] }] vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]) const wrapper = mount(SmartDispatchDialog, options) try { await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise } state.restoreDraft('server-token'); await flushPromises(); await state.submit() expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选') } finally { wrapper.unmount() } }) it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => { const wrapper = mount(SmartDispatchDialog, options); await flushPromises() const state = wrapper.vm as unknown as { mode: string; warehouseId: string; submit: () => Promise } state.mode = 'empty'; await wrapper.getComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.createDispatch).not.toHaveBeenCalled() state.warehouseId = '321'; vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey)) await state.submit(); await flushPromises() expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', cargo: [], vehicle: null, driver: null })) expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount() }) it('restores an explicit draft and submits complete server source versions in each container', async () => { const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙', selection: { ...source().selection, planPalletId: '22', warehousePalletId: '32' } })); original.containers[0]!.sourceKeys.push('source-B') vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]) vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, 'PC/1') })) })) const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise } state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await flushPromises() expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] })) expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount() }) it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => { vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('network unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) })) const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise } state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await state.submit() expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]); wrapper.unmount() }) it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => { vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable')) const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount() }) it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => { vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], complete: false, snapshotAt: '' }) const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.text()).toContain('尚未完整返回'); wrapper.unmount() }) it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => { const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' })) vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]) vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) })) vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 })) const wrapper = mount(SmartDispatchDialog, options); await flushPromises() const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise; activeContainerId: string; vehicle: { vehicleId: string | null }; form: { remark: string } } state.restoreDraft('server-token'); await flushPromises(); state.vehicle.vehicleId = 'vehicle-A'; state.form.remark = '车甲' state.activeContainerId = 'container-B'; state.vehicle.vehicleId = 'vehicle-B'; state.form.remark = '车乙' await state.submit(); expect(wrapper.emitted('created')).toBeUndefined() await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1) const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups expect(groups.map(group => [group.dispatch.vehicle?.vehicleId, group.dispatch.remark])).toEqual([['vehicle-A', '车甲'], ['vehicle-B', '车乙']]) expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount() }) it('never sends an existing departure command through the empty creation transport', async () => { useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' }) const wrapper = mount(SmartDispatchDialog, options); await flushPromises() await (wrapper.vm as unknown as { submit(): Promise }).submit() expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('从列表的原请求恢复入口'); wrapper.unmount() }) it('rejects a restored source whose batch customer differs from its real cargo owner', async () => { const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }] vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]) const wrapper = mount(SmartDispatchDialog, options); await flushPromises() const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise }; state.restoreDraft('server-token'); await flushPromises(); await state.submit() expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约客户不一致'); wrapper.unmount() })