import ElementPlus from 'element-plus' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AppDialog from '@/shared/components/AppDialog.vue' import { ApiResponseError } from '@/shared/api/http' import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses' import SmartDispatchDialog from './SmartDispatchDialog.vue' import DispatchResourceFields from './DispatchResourceFields.vue' import * as api from '../api/dispatches' import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures' import type { Candidate, DispatchDraft } from '../dispatchContracts' const identity = vi.hoisted(() => ({ id: '' })) 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('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } })) 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() })) enableAutoUnmount(afterEach) afterEach(() => vi.unstubAllGlobals()) const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T const warehouse: PlatformWarehouseResource = { id: '321', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null } type Step = 'CREATE' | 'CONTAINER' | 'LOCK' let catalog: Candidate[], server: DispatchDraft | null, lostReply: Step | null, reverseContainers: boolean let receipts: Map, calls: Step[] function source(index: number): Candidate { return candidate({ sourceKey: `source-${index}`, customerId: `C${index}`, customerName: `客户${index}`, orderNo: `ORDER-${index}`, inboundCode: `IN-${index}`, palletNo: `PALLET-${index}`, selection: { sourceType: 'INBOUND_PLAN', planPalletId: String(10 + index), returnReceiptId: null, relabelNo: null, warehousePalletId: String(20 + index), sourceVersion: `source-version-${index}` }, sourceVersion: `source-version-${index}` }) } function emptyDraft(): DispatchDraft { return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [], sources: [] } } function response(step: Step, key: string, mutate: () => void): DispatchDraft { calls.push(step) const identityKey = `${step}:${key}` if (!receipts.has(identityKey)) { mutate(); receipts.set(identityKey, clone(server!)) } if (lostReply === step) { lostReply = null; throw new Error(`${step} committed but response was lost`) } return clone(receipts.get(identityKey)!) } beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID() vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) catalog = [source(1), source(2), source(3)]; server = null; lostReply = null; reverseContainers = false; receipts = new Map(); calls = [] vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse]) vi.mocked(api.listDispatchDrafts).mockImplementation(async () => server ? [clone(server)] : []) vi.mocked(api.listDispatchCandidates).mockImplementation(async query => { const page = query.page ?? 1, size = query.size ?? 20 return { candidates: clone(catalog.slice((page - 1) * size, page * size)), total: catalog.length, page, size, pages: Math.ceil(catalog.length / size), complete: true, snapshotAt: '2026-10-21T00:00:00Z' } }) vi.mocked(api.createDispatchDraft).mockImplementation(async body => response('CREATE', body.idempotencyKey, () => { server = emptyDraft() })) vi.mocked(api.createDispatchDraftContainer).mockImplementation(async body => response('CONTAINER', body.idempotencyKey, () => { expect(body.draftToken).toBe(server!.draftToken); expect(body.recordVersion).toBe(server!.recordVersion) const next = Math.max(0, ...server!.containers.map(row => row.sequenceNo)) + 1 server!.containers.push({ draftContainerId: `container-${next}`, sequenceNo: next, capacity: body.capacity, sourceKeys: [] }) server!.recordVersion = Number(server!.recordVersion) + 1 if (reverseContainers) server!.containers.reverse() })) vi.mocked(api.lockDispatchDraftPallet).mockImplementation(async body => response('LOCK', body.idempotencyKey, () => { expect(body.draftToken).toBe(server!.draftToken); expect(body.recordVersion).toBe(server!.recordVersion) const selected = catalog.find(row => JSON.stringify(row.selection) === JSON.stringify(body.source))! expect(selected, 'LOCK must use a currently returned exact source selection').toBeDefined() const target = server!.containers.find(row => row.draftContainerId === body.draftContainerId)! expect(target, 'LOCK must retain its explicit container identity').toBeDefined() for (const member of selected.selectionGroup.sources) { if (!target.sourceKeys.includes(member.sourceKey)) target.sourceKeys.push(member.sourceKey) if (!server!.sources.some(row => row.sourceKey === member.sourceKey)) { server!.sources.push(clone(catalog.find(row => row.sourceKey === member.sourceKey) || { ...selected, sourceKey: member.sourceKey, customerId: member.customerId, customerName: member.customerName, selection: member.selection, sourceVersion: member.selection.sourceVersion })) } } server!.recordVersion = Number(server!.recordVersion) + 1 })) vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC-${index + 1}`) })) })) vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 })) }) async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }) await flushPromises() return wrapper.getComponent(AppDialog) } function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) } function button(dialog: VueWrapper, label: string) { return view(dialog).findAll('button').find(row => row.text() === label)! } function car(dialog: VueWrapper, index: number) { return view(dialog).get(`article[aria-label="第 ${index} 辆车"]`) } function cargoRow(dialog: VueWrapper, palletNo: string) { return view(dialog).get(`[aria-label="托盘 ${palletNo}"]`) } async function selectOrder(dialog: VueWrapper, palletNo: string) { const source = catalog.find(row => row.palletNo === palletNo)! const row = view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!))! await row.get('button').trigger('click'); await flushPromises() } async function warehouseSelected(dialog: VueWrapper) { expect(view(dialog).get('[aria-label="候选仓库展示"]').text()).toContain('全部') await flushPromises() } async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() } async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() } describe('three-column cargo planning through visible controls', () => { it('opens and selects a warehouse as reads only with one empty 30-space placeholder and no start prerequisite', async () => { const dialog = await render() expect(car(dialog, 1).text()).toContain('30 托位') expect(view(dialog).findAll('article[aria-label^="第 "]')).toHaveLength(1) expect(view(dialog).text()).not.toContain('开始安排第一辆车') expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false) expect(calls).toEqual([]) await warehouseSelected(dialog) expect(view(dialog).get('[aria-label="待排订单"]').text()).toContain('ORDER-1') expect(view(dialog).get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单') expect(calls).toEqual([]) expect(api.createDispatch).not.toHaveBeenCalled() expect(dialog.props('confirmDisabled')).toBe(true) }) it('first adds cargo through CREATE then CONTAINER then exactly one LOCK', async () => { const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1') expect(calls).toEqual(['CREATE', 'CONTAINER', 'LOCK']) expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 }) expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 1, idempotencyKey: expect.any(String), draftContainerId: 'container-1', source: catalog[0]!.selection }) expect(car(dialog, 1).text()).toContain('PALLET-1') expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false) expect(dialog.props('confirmDisabled')).toBe(false) }) it('adding a vehicle before any cargo materializes the first and second empty cars without locking stock', async () => { const dialog = await render(); await warehouseSelected(dialog) await button(dialog, '添加车辆').trigger('click'); await flushPromises() expect(calls).toEqual(['CREATE', 'CONTAINER', 'CONTAINER']) expect(view(dialog).findAll('article[aria-label^="第 "]')).toHaveLength(2) expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true) expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled() expect(dialog.props('confirmDisabled')).toBe(true) }) it('retains car cargo and the publication choice when switching cars', async () => { const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1') await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true) await button(dialog, '添加车辆').trigger('click'); await flushPromises() expect(car(dialog, 1).text()).toContain('PALLET-1') expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true) expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false) await join(dialog, 'PALLET-2') expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[1]![0].draftContainerId).toBe('container-2') await chooseCar(dialog, 1) expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false) expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true) await join(dialog, 'PALLET-3') expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[2]![0].draftContainerId).toBe('container-1') expect(car(dialog, 1).text()).toContain('PALLET-3'); expect(car(dialog, 2).text()).toContain('PALLET-2') await chooseCar(dialog, 2) expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false) expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true) }) it('selects the newly added ID even when its response lists the new container first and old container last', async () => { const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1') reverseContainers = true await button(dialog, '添加车辆').trigger('click'); await flushPromises() expect(server!.containers.map(row => row.draftContainerId)).toEqual(['container-2', 'container-1']) expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true) await join(dialog, 'PALLET-2') expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls.at(-1)![0].draftContainerId).toBe('container-2') expect(car(dialog, 1).text()).toContain('PALLET-1'); expect(car(dialog, 1).text()).not.toContain('PALLET-2') }) it('locks a dragged pallet to a non-active car and keeps the other car allocation unchanged', async () => { const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1') await button(dialog, '添加车辆').trigger('click'); await flushPromises() expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true) await selectOrder(dialog, 'PALLET-2') const dataTransfer = { setData: vi.fn(), effectAllowed: '', dropEffect: '' } await cargoRow(dialog, 'PALLET-2').trigger('dragstart', { dataTransfer }) await car(dialog, 1).trigger('dragover', { dataTransfer }) await car(dialog, 1).trigger('drop', { dataTransfer }); await flushPromises() expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(2) expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls.at(-1)![0]).toMatchObject({ draftContainerId: 'container-1', source: catalog[1]!.selection }) expect(server!.containers[0]!.sourceKeys).toEqual(['source-1', 'source-2']) expect(server!.containers[1]!.sourceKeys).toEqual([]) }) it.each(['CREATE', 'CONTAINER'] as const)('does not continue to LOCK after a definite %s rejection', async step => { const rejected = new ApiResponseError(`${step}条件不允许`, 'A0409', 409, undefined, `request-${step}`) if (step === 'CREATE') vi.mocked(api.createDispatchDraft).mockRejectedValueOnce(rejected) else vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(rejected) const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1') expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled() expect(api.createDispatchDraft).toHaveBeenCalledTimes(1) expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1) expect(view(dialog).text()).toContain(`request-${step}`) expect(view(dialog).text()).not.toContain('原请求结果尚待核对') expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull() }) it.each(['CREATE','CONTAINER'] as const)('unlocks after a failed %s and permits a new cargo click',async step=>{ lostReply=step const dialog=await render();await warehouseSelected(dialog);await join(dialog,'PALLET-1') expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled() expect(view(dialog).text()).not.toContain('原请求结果尚待核对') expect(sessionStorage.getItem('dispatch-draft-command:'+identity.id)).toBeNull() await join(dialog,'PALLET-1') expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce() if(step==='CREATE'){ const calls=vi.mocked(api.createDispatchDraft).mock.calls expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey) }else{ expect(api.createDispatchDraftContainer).toHaveBeenCalledOnce();expect(api.createDispatchDraft).toHaveBeenCalledOnce() } }) it('keeps the preparation chain frozen while CREATE is in flight', async () => { let resolve!: (value: DispatchDraft) => void vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done })) const dialog = await render(); await warehouseSelected(dialog) await selectOrder(dialog, 'PALLET-1') await cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').trigger('click') expect(button(dialog, '添加车辆').attributes('disabled')).toBeDefined() expect(cargoRow(dialog, 'PALLET-1').get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined() expect(view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr button').every(row => row.attributes('disabled') !== undefined)).toBe(true) expect(api.createDispatchDraft).toHaveBeenCalledTimes(1) expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled() server = emptyDraft(); resolve(clone(server)); await flushPromises() expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1) }) it('restores both existing cars without an extra placeholder and retains their allocations across real paging', async () => { catalog = Array.from({ length: 21 }, (_, index) => source(index + 1)) server = { ...emptyDraft(), recordVersion: 4, sources: clone(catalog.slice(0, 2)), containers: [1, 2].map(index => ({ draftContainerId: `container-${index}`, sequenceNo: index, capacity: index === 1 ? 30 : 15, sourceKeys: [`source-${index}`] })) } const dialog = await render() dialog.findAllComponents({ name: 'ElSelect' }).find(row => row.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() expect(view(dialog).findAll('article[aria-label^="第 "]')).toHaveLength(2) expect(car(dialog, 1).text()).toContain('PALLET-1'); expect(car(dialog, 2).text()).toContain('PALLET-2') expect(calls).toEqual([]) await view(dialog).get('[aria-label="候选货物分页"] button.btn-next').trigger('click'); await flushPromises() expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, draftToken: 'server-token' })) expect(view(dialog).get('[aria-label="待排订单"]').text()).toContain('ORDER-21') expect(view(dialog).get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单') expect(car(dialog, 1).text()).toContain('PALLET-1'); expect(car(dialog, 2).text()).toContain('PALLET-2') expect(calls).toEqual([]) }) it('locks a shared physical group once and converts all authoritative members without duplicate writes', async () => { catalog = sharedPalletCandidates() const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1') expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1) expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey)) for (const source of catalog) { await view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!) && row.text().includes(source.customerName!))!.get('button').trigger('click') expect(view(dialog).find('[aria-label="托盘 PALLET-1"]').exists()).toBe(false) } await button(dialog, '生成排车单').trigger('click'); await flushPromises() expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1) expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual(catalog.map(row => row.selection)) expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1) }) })