Files
admin-web/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.test.ts
T
2026-10-21 15:10:54 +08:00

208 lines
16 KiB
TypeScript

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<DispatchDetail> = {}) => detail({ taskType: 'DELIVERY', actions: { ADD_CARGO: allowed, REMOVE_CARGO: allowed }, ...overrides })
function cargo(overrides: Partial<DispatchCargoItem> = {}): 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> = {}): 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 button(wrapper, '加入这辆车').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(button(wrapper, '加入这辆车').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' })
})