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 DispatchResourceFields from './DispatchResourceFields.vue' import AppDialog from '@/shared/components/AppDialog.vue' import LineClampText from '@/shared/components/LineClampText.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, Vehicle } 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({ 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 field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! } async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); 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 externalResources(dialog: VueWrapper, plate: string, name: string) { for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() } await field(dialog, '车牌号').get('input').setValue(plate) expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate immediately after typing').toBe(plate) await field(dialog, '拖车公司名称').get('input').setValue('本趟承运公司') for (const [label, value] of [['实际托位容量', 17], ['最大载重(kg)', 15000], ['最大容积(m³)', 62]] as const) field(dialog, label).getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', value) await field(dialog, '司机姓名').get('input').setValue(name); await field(dialog, '联系电话').get('input').setValue('555-0102'); await field(dialog, '驾驶证号').get('input').setValue('EXTERNAL-LICENSE') field(dialog, '驾驶证到期日').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01') field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises() expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate after completing driver fields').toBe(plate) } describe('new dispatch native layout', () => { it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => { const { dialog } = await render() expect(dialog.text()).toContain('请先选择装车组') expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined() expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false) await restore(dialog) expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false) expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined() }) it('preserves every external field and actual capacity through fullscreen in a real empty creation', async () => { const { wrapper, dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog) await externalResources(dialog, 'CA-EXTERNAL', '临时司机') const state = wrapper.vm as unknown as { vehicle: Vehicle } expect(state.vehicle.plate, 'parent vehicle after external inputs').toBe('CA-EXTERNAL') field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z') field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z') await field(dialog, '备注').get('input').setValue('本趟备注') expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL') await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL') expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true) expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) expect(dialog.text()).toContain('空单不能核平、装车或发车') expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false) await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises() expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }) }) it('queries real master directories and keeps business labels and task-end evidence in the command', async () => { const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog) field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises() const vehicleSelect = field(dialog, '车辆').getComponent({ name: 'ElSelect' }) vehicleSelect.vm.$emit('focus'); await flushPromises() vehicleSelect.vm.$emit('update:modelValue', vehicleRecord.id); vehicleSelect.vm.$emit('change', vehicleRecord.id); await flushPromises() const driverSelect = field(dialog, '司机').getComponent({ name: 'ElSelect' }) driverSelect.vm.$emit('focus'); await flushPromises() driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises() expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' }) expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' }) expect(dialog.text()).not.toContain(vehicleRecord.id) dialog.vm.$emit('confirm'); await flushPromises() expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })) }) it('keeps each group configuration independent and verifies proven conversions without creating them twice', 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 externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组') const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' }) radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises() await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组') radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises() expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A') expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组') await 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.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']]) expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17]) expect(wrapper.emitted('created')).toBeUndefined() expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留') dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1) expect(wrapper.emitted('created')).toHaveLength(1) }) it('locks the real frame and all resource edits while pending and reuses the original 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); await externalResources(dialog, 'CA-A', '司机甲') dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises() expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1) 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(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined() reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises() expect(dialog.text()).toContain('new-dispatch-request') expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) 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]).toEqual(original) }) it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => { const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) })) rows[0] = sharedPalletCandidates()[0]! vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' }) const { dialog } = await render(); await selectWarehouse(dialog) const table = dialog.get('[aria-label="排车候选货物"]') expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25) expect(table.text()).toContain('PALLET-24') expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined() expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50) const sourceGroup = table.findAll('details')[0]! await sourceGroup.get('summary').trigger('click'); await flushPromises() expect(sourceGroup.element.hasAttribute('open')).toBe(true) expect(sourceGroup.findAll('li')).toHaveLength(2) expect(sourceGroup.text()).toContain('客户A') expect(sourceGroup.text()).toContain('客户B') expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled() }) it('keeps query conditions and regional tabs on the original candidate transport', async () => { const { dialog } = await render(); await selectWarehouse(dialog) await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY') await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8') dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises() await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises() expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined }) const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises() expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true') expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls) }) 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(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('requires the explicit recheck action 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 dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises() expect(dialog.text()).toContain('draft-rejected-request') expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises() expect(api.createDispatchDraft).toHaveBeenCalledTimes(1) expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false) expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2) }) 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() }) })