Files
admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts
T
2026-10-22 11:56:54 +08:00

378 lines
33 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 SmartDispatchDialog from './SmartDispatchDialog.vue'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ConvertDraft, DispatchDraft } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
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 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 }
const vehicleRecord: VehicleDetail = { id: '9223372036854775001', plateNumber: 'CA-MASTER', model: '档案车型', maxPalletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62, fuelConsumptionPer100Km: 10, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'operator', updatedAt: '' }
const driverRecord: DriverDetail = { id: '9223372036854775002', driverNo: 'DR-1', active: true, reviewStatus: 'APPROVED', licenseFileId: 'LICENSE-FILE', recordVersion: 0, updatedBy: 'operator', updatedAt: '', licenseState: 'VALID', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-01-01' }, details: { name: '档案司机', phone: '555-0101', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE-1', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-01-01', licenseZone: 'America/Los_Angeles', remark: null, profile: null } }
function draft(): DispatchDraft {
const first = candidate(), second = candidate({ sourceKey: 'source-2', customerId: 'C2', customerName: '客户乙', selection: { ...first.selection, planPalletId: '12', warehousePalletId: '22' }, palletNo: 'PALLET-2' })
return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [first.sourceKey] }, { draftContainerId: 'container-2', sequenceNo: 2, capacity: 15, sourceKeys: [second.sourceKey] }], sources: [first, second] }
}
beforeEach(() => {
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true)
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: draft().sources, complete: true, snapshotAt: '' })
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
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 }))
vi.mocked(listVehicles).mockResolvedValue({ records: [vehicleRecord], page: 1, size: 100, total: 1, pages: 1 })
vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
})
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function selectWarehouse(dialog: VueWrapper) {
const select = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '请选择起运仓')
if (select) { select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321') }
await flushPromises()
}
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function selectFirstOrder(dialog: VueWrapper) { await view(dialog).get('[aria-label="待排订单"] tbody tr button').trigger('click'); await flushPromises() }
describe('new dispatch native layout', () => {
it('opens all regional cargo without a warehouse picker or a silently selected warehouse', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' }])
const { dialog } = await render()
expect(view(dialog).get('[aria-label="候选货物查询"]').find('[aria-label="起运仓"]').exists()).toBe(false)
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: undefined, warehouseTab: 'ALL', page: 1 }))
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').map(button => button.text())).toEqual(['全部', '洛杉矶', '纽约'])
expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('reads regional candidates independently of the optional empty-dispatch warehouse directory', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
const { dialog } = await render()
expect(api.listDispatchCandidates).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: undefined }))
expect(view(dialog).text()).toContain('待排订单')
})
it('takes the first selected pallet warehouse instead of the first directory entry and locks the draft to that warehouse', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' }])
const row = candidate({ warehouseId: '654', warehouseCode: 'NYC', warehouseName: '纽约仓' })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [row], total: 1, page: 1, size: 20, pages: 1, complete: true, snapshotAt: '' })
const empty = { ...draft(), warehouseId: '654', sources: [], containers: [] }
const car = { draftContainerId: 'ny-car', sequenceNo: 1, capacity: 30, sourceKeys: [] as string[] }
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue({ ...empty, recordVersion: 1, containers: [car] })
vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue({ ...empty, recordVersion: 2, sources: [row], containers: [{ ...car, sourceKeys: [row.sourceKey] }] })
const { dialog } = await render(); await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '654' }))
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654', draftToken: 'server-token' }))
const tabs = view(dialog).get('[aria-label="候选仓库展示"]').findAll('button')
expect(tabs[1]!.attributes('disabled')).toBeDefined()
expect(tabs[2]!.attributes('disabled')).toBeUndefined()
})
it('retains the restored draft warehouse instead of selecting the first warehouse on reopen', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse, { ...warehouse, id: '654', warehouseName: '纽约仓' }])
vi.mocked(api.listDispatchDrafts).mockResolvedValue([{ ...draft(), warehouseId: '654', sources: [], containers: [] }])
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 0, page: 1, size: 20, pages: 0, candidates: [], complete: true, snapshotAt: '' })
const { wrapper, dialog } = await render()
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '洛杉矶')!.trigger('click'); await flushPromises()
await restore(dialog)
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654', warehouseTab: 'ALL' }))
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ warehouseId: '654' }))
})
it('keeps the restored workbench controls together and shows the selected warehouse tab', async () => {
const { dialog } = await render()
const content = view(dialog)
const searchPanel = content.get('[aria-label="候选货物查询"]')
expect(searchPanel.find('.el-form-item__label').exists()).toBe(false)
expect(searchPanel.find('[aria-label="起运仓"]').exists()).toBe(false)
expect(searchPanel.find('input[placeholder="订单号/入仓码"]').exists()).toBe(true)
const settingsToggle = searchPanel.findAll('button').find(button => button.text() === '排车设置')!
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(false)
await settingsToggle.trigger('click')
expect(settingsToggle.attributes('aria-expanded')).toBe('true')
expect(content.get('[aria-label="排车设置"]').isVisible()).toBe(true)
expect(content.get('[aria-label="排车设置"]').text()).toContain('建单方式')
expect(content.get('[aria-label="排车设置"]').find('[aria-label="车辆选货安排"]').exists()).toBe(true)
expect(content.text()).not.toContain('1. 排车设置')
expect(content.text()).not.toContain('2. 为车辆选货')
const vehicles = content.get('[aria-label="车辆装载"]')
expect(vehicles.text()).toContain('托位')
expect(vehicles.get('.cargo-board-vehicle-tools').text()).toContain('添加车辆')
expect(vehicles.text()).toContain('添加车辆')
const tabs = content.get('[aria-label="候选仓库展示"]').findAll('button')
expect(tabs[0]!.classes()).toContain('active')
await selectWarehouse(dialog)
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('active')
expect(tabs[0]!.classes()).not.toContain('active')
const capacity = dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '计划托位')!
capacity.vm.$emit('update:modelValue', 15); await flushPromises()
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('15 托位')
})
it('does not show a recovery warning or blank the board during a successful slow addition', async () => {
const row = candidate()
const empty = { ...draft(), sources: [], containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
const { dialog } = await render(); await restore(dialog); await selectFirstOrder(dialog)
let finishLock!: (value: DispatchDraft) => void
let finishRefresh!: (value: Awaited<ReturnType<typeof api.listDispatchCandidates>>) => void
vi.mocked(api.lockDispatchDraftPallet).mockReturnValueOnce(new Promise(resolve => { finishLock = resolve }))
vi.mocked(api.listDispatchCandidates).mockReturnValueOnce(new Promise(resolve => { finishRefresh = resolve }))
const content = view(dialog)
await content.get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(content.text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
expect(dialog.props('confirmLoading')).toBe(true)
finishLock({ ...empty, recordVersion: 1, sources: [row], containers: [{ ...empty.containers[0]!, sourceKeys: [row.sourceKey] }] })
await flushPromises()
expect(dialog.props('loading')).toBe(false)
expect(content.get('[aria-label="待排订单"]').text()).toContain(row.orderNo)
expect(content.get('[aria-label="托盘明细"]').text()).not.toContain('请选择左侧订单')
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('true')
expect(content.text()).not.toContain('原请求结果尚待核对')
finishRefresh({ total: 1, page: 1, size: 20, pages: 1, candidates: [row], complete: true, snapshotAt: '' })
await flushPromises()
expect(content.get('[aria-label="智能排车三栏选货"]').attributes('aria-busy')).toBe('false')
expect(content.get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
})
it('adds cargo directly, preparing the first vehicle internally and enabling submission after assignment', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
const planned = candidate({ selection: { ...candidate().selection, warehousePalletId: null } })
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue(grouped)
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [planned], complete: true, snapshotAt: '' })
vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue({ ...grouped, recordVersion: 2, sources: [planned], containers: [{ ...grouped.containers[0]!, sourceKeys: [planned.sourceKey] }] })
const { dialog } = await render()
expect(view(dialog).text()).not.toContain('开始安排第一辆车')
expect(view(dialog).find('[aria-label="第 1 辆车"]').exists()).toBe(true)
expect(api.createDispatchDraft).not.toHaveBeenCalled()
await selectWarehouse(dialog)
expect(view(dialog).text()).toContain('在途计划')
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
expect(dialog.props('confirmDisabled')).toBe(true)
await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledExactlyOnceWith({ warehouseId: '321', idempotencyKey: expect.any(String) })
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls + 1)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
expect(dialog.props('confirmDisabled')).toBe(false)
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(view(dialog).find('[aria-label="托盘明细"] tr[draggable]').exists()).toBe(false)
})
it('unlocks after a failed first container and accepts a new cargo click',async()=>{
const empty:DispatchDraft={...draft(),containers:[],sources:[]}
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({...empty,recordVersion:1,containers:[{draftContainerId:'first-group',sequenceNo:1,capacity:30,sourceKeys:[]}]})
const {dialog}=await render();await selectWarehouse(dialog);await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click');await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog,'建单方式').getComponent({name:'ElRadioGroup'}).props('disabled')).toBe(false)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click');await flushPromises()
const calls=vi.mocked(api.createDispatchDraftContainer).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
expect(api.createDispatchDraft).toHaveBeenCalledOnce();expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
})
it('keeps publication available before cargo selection without extra planning fields', async () => {
const { dialog } = await render()
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
expect(view(dialog).classes()).not.toContain('admin-density-workspace')
expect(dialog.getComponent({ name: 'ElDialog' }).props()).toMatchObject({ appendToBody: true, lockScroll: true })
expect(view(dialog).text()).not.toContain('点击“开始选货”建立第一组')
expect(view(dialog).text()).not.toContain('选择起运仓后点击')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
})
it('creates an empty dispatch with optional publication preserved through fullscreen', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车计划时间与备注"]').exists()).toBe(false)
expect(view(dialog).findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
const publication = view(dialog).get('.dispatch-candidate-query input[type="checkbox"]')
expect(publication.element).toHaveProperty('checked', false)
await publication.setValue(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
expect(view(dialog).find('[aria-label="智能排车三栏选货"]').exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicle: null, driver: null })
})
it('creates with only the warehouse and never requests vehicle or driver directories', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }))
expect(listVehicles).not.toHaveBeenCalled()
expect(listDrivers).not.toHaveBeenCalled()
})
it('applies publication to all cars and keeps it editable after conversion read failure', async () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
const radios = view(dialog).get('[aria-label="车辆装载"]').findAll('input[type="radio"]')
await radios[1]!.setValue(true); await flushPromises()
await radios[0]!.setValue(true); await flushPromises()
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
expect(request.recordVersion).toBe(0)
expect(request.groups.map(group => [group.dispatch.publishLooseboardDemand, group.dispatch.plannedDepartureAt, group.dispatch.estimatedArrivalAt, group.dispatch.remark])).toEqual([[true, null, null, null], [true, null, null, null]])
expect(wrapper.emitted('created')).toBeUndefined()
expect(view(dialog).text()).toContain('本次排车操作失败,请重试')
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('created')).toHaveLength(1)
})
it('prevents active double clicks but unlocks editing and starts a new conversion after failure', async () => {
let reject!: (reason: unknown) => void
vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
const { wrapper, dialog } = await render(); await restore(dialog)
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey)
})
it('keeps all current-page orders and reveals complete pallet source details only after selecting an order', async () => {
const rows = Array.from({ length: 20 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
rows[0] = sharedPalletCandidates()[0]!
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 20, page: 1, size: 20, pages: 1, candidates: rows, complete: true, snapshotAt: '' })
const { dialog } = await render(); await selectWarehouse(dialog)
const table = view(dialog).get('[aria-label="待排订单"]')
expect(table.findAll('tbody tr')).toHaveLength(20)
expect(table.text()).toContain('ORDER-19')
expect(view(dialog).get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
await selectFirstOrder(dialog)
const sourceGroup = view(dialog).get('[aria-label="托盘明细"] details')
expect(sourceGroup.findAll('li')).toHaveLength(2)
expect(sourceGroup.text()).toContain('客户A')
expect(sourceGroup.text()).toContain('客户B')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
})
it.each(['PLAN', 'RELABEL'])('keeps %s query conditions and regional tabs on the original candidate transport', async sourceStage => {
const { dialog } = await render(); await selectWarehouse(dialog)
await view(dialog).get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises()
await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: undefined, orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls + 1)
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC', orderNo: 'IN-QUERY', sourceStage }))
})
it('shows loading and initialization diagnostics without issuing create commands', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
const { dialog } = await render()
expect(view(dialog).text()).toContain('draft-load-request')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
})
it('unfreezes after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await selectFirstOrder(dialog)
await view(dialog).get('button[aria-label^="将托盘 "]').trigger('click'); await flushPromises()
expect(view(dialog).text()).toContain('draft-rejected-request')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(1)
})
it('rejects removal of a populated car before sending a command and keeps the draft editable', async () => {
const { dialog } = await render(); await restore(dialog)
dialog.getComponent(SmartDispatchCargoBoard).vm.$emit('removeContainer', 'container-1'); await flushPromises()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(view(dialog).text()).toContain('请先移回车内托盘')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it.each(['rejection','unknown'] as const)('unlocks an empty-car deletion after %s and accepts another click',async failure=>{
const empty={...draft(),sources:[],containers:[{...draft().containers[0]!,sourceKeys:[]}]}
vi.mocked(api.listDispatchDrafts).mockResolvedValue([empty])
vi.mocked(api.deleteDispatchDraftContainer).mockRejectedValueOnce(failure==='rejection'?new ApiResponseError('仅空容器可以删除','A0409',409):new Error('network unknown'))
const {dialog}=await render();await restore(dialog)
await view(dialog).findAll('button').find(button=>button.text()==='删除车辆')!.trigger('click');await flushPromises()
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem('dispatch-draft-command:'+identity.id)).toBeNull()
vi.mocked(api.deleteDispatchDraftContainer).mockResolvedValueOnce({...empty,recordVersion:1,containers:[]})
await view(dialog).findAll('button').find(button=>button.text()==='删除车辆')!.trigger('click');await flushPromises()
const calls=vi.mocked(api.deleteDispatchDraftContainer).mock.calls
expect(calls).toHaveLength(2);expect(calls[1]![2].idempotencyKey).not.toBe(calls[0]![2].idempotencyKey)
})
it('clears a saved definite rejection on reopen without restoring the failed request', async () => {
sessionStorage.setItem(`dispatch-draft-command:${identity.id}`, JSON.stringify({ action: 'DELETE', attempted: true, rejected: true, containerId: 'container-1', body: { draftToken: 'server-token', recordVersion: 0, idempotencyKey: 'rejected-key' } }))
const { dialog } = await render()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(sessionStorage.getItem(`dispatch-draft-command:${identity.id}`)).toBeNull()
expect(api.deleteDispatchDraftContainer).not.toHaveBeenCalled()
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.getComponent(SmartDispatchCargoBoard).props('disabled')).toBe(false)
})
it('keeps creation unavailable without its write permission', async () => {
can.mockReturnValue(false)
const { dialog } = await render()
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled()
})
})