import ElementPlus from 'element-plus' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue' import * as api from '../api/dispatches' import { candidate, command, detail, listRow, page, sharedPalletCandidates } from '../dispatchTestFixtures' import type { DispatchCargoItem, DispatchDetail, DispatchItems } from '../dispatchContracts' import { useDispatchCommandRecovery } from '../dispatchCommandRecovery' const identity = vi.hoisted(() => ({ id: '', allowed: true })) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => identity.allowed }) })) vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), listDispatchCandidates: vi.fn(), addDispatchCargo: vi.fn(), removeDispatchItem: vi.fn(), getDispatchCommand: vi.fn() })) enableAutoUnmount(afterEach) const allowed = { allowed: true, blockCode: null, blockReason: null } const current = (overrides: Partial = {}) => detail({ taskType: 'DELIVERY', actions: { ADD_CARGO: allowed, REMOVE_CARGO: allowed }, ...overrides }) function cargo(overrides: Partial = {}): DispatchCargoItem { const source = candidate() return { ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: allowed, ...overrides } } const manifest = (overrides: Partial = {}): DispatchItems => ({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [cargo()], ...overrides }) function button(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(row => row.text() === text); if (!found) throw new Error(`Missing button ${text}`); return found } async function open(dispatchNo: string | null = 'PC/1') { const wrapper = mount(SmartDispatchExistingCargo, { props: { dispatchNo: dispatchNo ?? undefined }, global: { plugins: [ElementPlus] } }) await flushPromises() return wrapper } async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await wrapper.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises() } beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' })])) vi.mocked(api.getDispatch).mockResolvedValue(current()) vi.mocked(api.getDispatchItems).mockResolvedValue(manifest()) vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' })) }) it('keeps both searches in one panel and the manifest inside the shared board column', async () => { const wrapper = await open() const search = wrapper.get('[aria-label="已有排车查询"]') expect(search.find('[aria-label="选择已有排车"]').exists()).toBe(true) expect(search.text()).not.toContain('查询排车') expect(search.find('input[aria-label="查找可排货物"]').exists()).toBe(true) expect(search.findAll('.existing-cargo-query > label')).toHaveLength(0) const column = wrapper.get('[aria-label="车辆装载"]') expect(column.get(':scope > .cargo-board-heading').text()).toContain('本车货物 · PC/1') expect(column.find(':scope > .cargo-board-vehicle-content [aria-label="当前排车货物"]').exists()).toBe(true) expect(wrapper.get('.dispatch-cargo-board').findAll(':scope > section > .cargo-board-heading')).toHaveLength(3) expect(column.text()).not.toContain('添加车辆') }) it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => { const [first, second] = sharedPalletCandidates() vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' })) const wrapper = await open() vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => { vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 })) vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 })) return command('ADD_CARGO', body.idempotencyKey) }) await add(wrapper) expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargo: [first!.selection, second!.selection] })) expect(wrapper.emitted('saved')).toHaveLength(1) expect(api.getDispatchItems).toHaveBeenCalledTimes(3) expect(api.listDispatchCandidates).toHaveBeenCalledTimes(3) expect(wrapper.find('[aria-label="当前排车货物"]').text()).toContain('真实已拣 0 箱') expect(wrapper.text()).not.toContain('添加车辆') }) it.each(['complete', 'missing', 'changed'] as const)('reads appointments of a hidden second group source: %s', async scenario => { const first = candidate() const second = candidate({ sourceKey: 'source-2', customerId: 'C2', selection: { ...first.selection, planPalletId: '12' }, appointmentBatches: [{ batchKey: 'B2', batchNo: 'AP2', customerId: 'C2', recordVersion: '9007199254740993', sources: [{ sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }] }) const group = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['source-1', 'source-2'], packageCount: 4 }] } first.selectionGroup = group; second.selectionGroup = group vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size !== 100 ? [first] : query.page === 1 ? [first] : scenario === 'missing' ? [] : [{ ...second, selectionGroup: scenario === 'changed' ? { ...group, groupVersion: 'new-composition' } : group }], page: query.page!, size: query.size!, total: 101, pages: 2, complete: true, snapshotAt: '' })) vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown')) const wrapper = await open(); await add(wrapper) if (scenario === 'complete') { expect(api.listDispatchCandidates).toHaveBeenCalledWith({ warehouseId: '321', warehouseTab: 'ALL', page: 2, size: 100 }) expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection], batchAllocations: [{ planPalletId: '12', batchKey: 'B2', customerId: 'C2', recordVersion: '9007199254740993', sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] })) expect(wrapper.emitted('busy')?.at(-1)).toEqual([false]) } else { expect(api.addDispatchCargo).not.toHaveBeenCalled() expect(wrapper.text()).toContain(scenario === 'missing' ? '同托货物及预约资料未完整返回' : '选货组组成已变化') } }) it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation version %s in additions', async recordVersion => { const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }] }) vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' }) vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown')) const wrapper = await open(); await add(wrapper) expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] })) expect(wrapper.get('button[aria-label^="将托盘 "]').attributes('disabled')).toBeDefined() }) it('requires a removal reason and submits the authoritative item ID and zero version', async () => { const wrapper = await open() await button(wrapper, '移除货物').trigger('click') expect(button(wrapper, '确认移除').attributes('disabled')).toBeDefined() await wrapper.get('textarea[aria-label="移除原因"]').setValue('本车计划调整') vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => { vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 })) vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] })) return command('REMOVE_CARGO', body.idempotencyKey) }) await button(wrapper, '确认移除').trigger('click'); await flushPromises() expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' })) expect(wrapper.text()).toContain('本车暂无货物') }) it('rejects an addition whose authoritative appointment version is missing', async () => { const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] }) vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' }) const wrapper = await open(); await add(wrapper) expect(api.addDispatchCargo).not.toHaveBeenCalled() expect(wrapper.text()).toContain('预约批次版本缺失或无效') expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull() }) it('rejects a candidate page from another warehouse before exposing selectable cargo', async () => { vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [candidate({ warehouseId: 'OTHER' })], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' }) const wrapper = await open() expect(wrapper.text()).toContain('候选起运仓或来源版本不完整') expect(wrapper.findAll('button').some(row => row.text() === '加入这辆车')).toBe(false) expect(api.addDispatchCargo).not.toHaveBeenCalled() }) it.each(['picked', 'item-gate', 'shared-pallet'] as const)('locks removal for %s instead of changing real picking facts', async scenario => { vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ items: scenario === 'shared-pallet' ? [cargo(), cargo({ dispatchItemId: 'other', sourceKey: 'other' })] : [cargo(scenario === 'picked' ? { pickedPackages: 1 } : { remove: { allowed: false, blockCode: 'LOCK', blockReason: '正在作业' } })] })) const wrapper = await open() for (const row of wrapper.findAll('button').filter(row => row.text() === '移除货物')) expect(row.attributes('disabled')).toBeDefined() expect(api.removeDispatchItem).not.toHaveBeenCalled() if (scenario === 'shared-pallet') expect(wrapper.text()).toContain('同一实物托盘包含多个来源') }) it.each(['loading', 'permission', 'task-type', 'version', 'read-failure'] as const)('fails closed when %s blocks editing', async scenario => { if (scenario === 'loading') { vi.mocked(api.getDispatch).mockResolvedValue(current({ stage: 'LOADING' })); vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ stage: 'LOADING' })) } if (scenario === 'permission') identity.allowed = false if (scenario === 'task-type') vi.mocked(api.getDispatch).mockResolvedValue(current({ taskType: 'PICKUP' })) if (scenario === 'version') vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 2 })) if (scenario === 'read-failure') vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable')) const wrapper = await open() expect(api.listDispatchCandidates).not.toHaveBeenCalled() expect(wrapper.findAll('button').filter(row => row.text() === '移除货物').every(row => row.attributes('disabled') !== undefined)).toBe(true) expect(api.addDispatchCargo).not.toHaveBeenCalled() if (scenario === 'version') expect(wrapper.text()).toContain('一致版本') }) it('auto-opens the original removal command after remount and checks its result without resending', async () => { const wrapper = await open() await button(wrapper, '移除货物').trigger('click') await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因') vi.mocked(api.removeDispatchItem).mockRejectedValue(new Error('unknown result')) await button(wrapper, '确认移除').trigger('click'); await flushPromises() const original = vi.mocked(api.removeDispatchItem).mock.calls[0]! wrapper.unmount() const resumed = await open(null) expect(resumed.text()).toContain('PC/1') const recovery = useDispatchCommandRecovery(() => identity.id) expect(recovery.pending.value).toMatchObject({ dispatchNo: 'PC/1', itemId: '9007199254740993', body: original[2] }) vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key)) vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 })) vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] })) await button(resumed, '核对并重试原请求').trigger('click'); await flushPromises() expect(api.removeDispatchItem).toHaveBeenCalledTimes(1) expect(recovery.pending.value).toBeNull() expect(resumed.emitted('saved')).toHaveLength(1) }) it('keeps a confirmed result pending if the subsequent manifest cannot be read', async () => { const wrapper = await open() vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => { vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 })) vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable')) return command('ADD_CARGO', body.idempotencyKey) }) await add(wrapper) expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result?.action).toBe('ADD_CARGO') vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 })) await button(wrapper, '刷新当前资料').trigger('click'); await flushPromises() expect(api.addDispatchCargo).toHaveBeenCalledTimes(1) expect(wrapper.emitted('saved')).toHaveLength(1) }) it('does not let a late previous dispatch read overwrite the selected dispatch', async () => { let resolve!: (value: DispatchDetail) => void vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' }), listRow({ dispatchNo: 'PC/2', taskType: 'DELIVERY' })])) vi.mocked(api.getDispatch).mockImplementation(no => no === 'PC/1' ? new Promise(done => { resolve = done }) : Promise.resolve(current({ dispatchNo: 'PC/2' }))) vi.mocked(api.getDispatchItems).mockImplementation(async no => manifest({ dispatchNo: no })) const wrapper = await open() await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises() resolve(current()); await flushPromises() expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).toContain('PC/2') expect(wrapper.get('[aria-label="车辆装载"] > .cargo-board-heading').text()).not.toContain('PC/1') }) it('queries and paginates active before-loading dispatches using the entered business number', async () => { const wrapper = await open(null) vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 })) const picker = wrapper.getComponent({ name: 'ElSelect' }) expect(picker.props('remote')).toBe(true) await picker.get('.el-select__wrapper').trigger('click'); await flushPromises() await picker.props('remoteMethod')('PC2026'); await flushPromises() await wrapper.getComponent({ name: 'ElPagination' }).get('button.btn-next').trigger('click'); await flushPromises() expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' }) })