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>) => 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() }) })