244 lines
19 KiB
TypeScript
244 lines
19 KiB
TypeScript
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 = <T>(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<string, DispatchDraft>, 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)
|
|
})
|
|
})
|