import ElementPlus, { ElMessage } from 'element-plus' import { ApiResponseError, http } from '@/shared/api/http' import { parseApiJsonResponse } from '@chongchong/shared' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, expect, it, vi } from 'vitest' import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue' import * as api from '../api/dispatches' import { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } from '../dispatchTestFixtures' import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts' const identity = vi.hoisted(() => ({ id: '' })) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '
' } })) vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() })) interface State { submit(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail } it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => { const [first, second] = sharedPalletCandidates() vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' }) const { wrapper, state } = await open('ADD_CARGO') const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable, checked: boolean): void } selection.selectCargo(first!, true) first!.selectionGroup = { ...first!.selectionGroup, groupVersion: 'composition-2' } await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('组成已变化') first!.selectionGroup.complete = false; selection.selectCargo(first!, false) await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整') first!.selectionGroup.complete = true selection.selectCargo(first!, true) vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) await state.submit(); expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] })) wrapper.unmount() }) it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => { const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] }) vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' }) const { wrapper, state } = await open('ADD_CARGO') try { (wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true) vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) await state.submit() expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, batchAllocations: [{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }] })) } finally { wrapper.unmount() } }) it('blocks cargo addition when the authoritative batch version is absent', async () => { const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] }) vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' }) const { wrapper, state } = await open('ADD_CARGO') try { (wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit() expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选') } finally { wrapper.unmount() } }) it('removes an unpicked authoritative item after real JSON and API normalization of pickedPackages string zero', async () => { const actual = await vi.importActual('../api/dispatches') const source = candidate() const data = { dispatchNo: 'PC/1', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: '2', pickedPackages: '0', remove: { allowed: true, blockCode: null, blockReason: null } }] } const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data }))) vi.mocked(api.getDispatchItems).mockImplementation(actual.getDispatchItems) const { wrapper, state } = await open('REMOVE_CARGO') try { (wrapper.vm as unknown as { itemId: string }).itemId = '9007199254740993'; state.form.reason = '本车计划调整' vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, request) => command('REMOVE_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) await state.submit() expect(api.removeDispatchItem).toHaveBeenCalledWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: '0', reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1) } finally { wrapper.unmount(); get.mockRestore() } }) async function open(action: DispatchAction, allowed = true) { vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } })) const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises() return { wrapper, state: wrapper.vm as unknown as State } } beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) }) it('keeps order allocation references out of actual measurements and submits native version zero only after real input', async () => { const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2 vi.mocked(api.getDispatchLoading).mockResolvedValue(value) const { wrapper, state } = await open('LOADING_COMPLETE') expect(state.measurements[0]).toMatchObject({ weightKg: null, volumeCbm: null }) await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('真实托位') state.measurements[0]!.weightKg = 92; state.measurements[0]!.volumeCbm = 1.5 vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey)) vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING', recordVersion: 1 })) await state.submit() expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] })) expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => { const value = loadingPreview(); value.pallets[0]!.weightKg = 92; value.pallets[0]!.volumeCbm = 1.5; value.pallets[0]!.deliveryIndex = null value.pallets[0]!.evidence.push({ field: 'weightKg', actual: true, value: 92, sourceType: 'MEASURED', sourceBusinessNo: 'PALLET-1', observedAt: null }) value.confirmedLoad = { manifestHash: 'manifest', operatorId: 'operator', confirmedAt: '2026-10-19T00:00:00Z', pallets: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] } vi.mocked(api.getDispatchLoading).mockResolvedValue(value) const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null }) await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount() }) it('requires a rejection reason and retains the full original command after an unknown review result', async () => { const { wrapper, state } = await open('DISPATCH_REVIEW'); state.form.decision = 'REJECTED' await state.submit(); expect(api.reviewDispatch).not.toHaveBeenCalled() state.form.reason = '预约未核实'; vi.mocked(api.reviewDispatch).mockRejectedValue(new Error('unknown result')) await state.submit(); state.form.reason = 'later changed' vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('DISPATCH_REVIEW', key)) vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) await state.submit(); expect(api.reviewDispatch).toHaveBeenCalledTimes(1) expect(vi.mocked(api.reviewDispatch).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' }) expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('blocks an action without a literal service allowance', async () => { const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount() }) it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => { const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2, sourceOrigin: null } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] })) vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null }) const { wrapper, state } = await open('ARRANGE_DELIVERY') const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups groups.forEach(group => Object.assign(group, { plannedDeliveryAt: '2026-10-20T00:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'ONE_PICKUP_MULTI_DROP', loadingType: 'PALLET' })) await state.submit(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled() groups[1]!.fees.find(fee => fee.feeType === 'DELIVERY_FEE')!.amount = 77 vi.mocked(api.transferDispatchToDelivery).mockImplementation(async (_, body): Promise => ({ ...command('ARRANGE_DELIVERY', body.idempotencyKey), action: 'ARRANGE_DELIVERY', result: { dispatchNo: 'PC/1', recordVersion: 1, arrangementKey: 'arrangement', cargoHash: 'whole-cargo', arrangedAt: '2026-10-19T00:00:00Z', outbounds: [], deliveries: [] } })) vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, stage: 'AFTER_LOADING' })); await state.submit() const sent = vi.mocked(api.transferDispatchToDelivery).mock.calls[0]![1] expect(sent).toMatchObject({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash' }) expect(sent.groups).toHaveLength(2); expect(sent.groups[0]!.fees).toEqual([]); expect(sent.groups[1]!.fees).toEqual([expect.objectContaining({ feeType: 'DELIVERY_FEE', amount: 77 })]) expect(api.getDispatchArrangement).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => { const { wrapper, state } = await open('DEPART') let resolve!: (value: DispatchDetail) => void vi.mocked(api.getDispatch).mockImplementationOnce(() => new Promise(done => { resolve = done })) await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled() resolve(detail({ dispatchNo: 'PC/2', actions: { DEPART: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises() await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount() }) it('removes only the authoritative unpicked item using its item ID as path identity and retains that original target on recovery', async () => { const source = candidate() vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: { allowed: true, blockCode: null, blockReason: null } }] }) const { wrapper, state } = await open('REMOVE_CARGO'); const target = wrapper.vm as unknown as { itemId: string }; target.itemId = '9007199254740993' await state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled() state.form.reason = '本车计划调整'; vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown')); await state.submit() target.itemId = 'another-item'; state.form.reason = 'later changed' vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) await state.submit(); expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() }) it('preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update', async () => { const toast = vi.spyOn(ElMessage, 'success') vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: '实际起点', destinationKey: null }, { index: 1, name: 'ONT8', address: '实际地址A', destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接卸货点', address: '实际地址B', destinationKey: 'PRIVATE:actual' }] }) const { wrapper, state } = await open('EDIT'); await wrapper.setProps({ operation: 'ROUTE' }); await flushPromises() const routeState = wrapper.vm as unknown as { moveStop(index: number, offset: number): void }; routeState.moveStop(1, -1); state.form.reason = '按本车现场顺序卸货' vi.mocked(api.reorderDispatchRoute).mockRejectedValueOnce(new Error('unknown route')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey)) await state.submit(); vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('not found', 'HTTP_404')) vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })); await state.submit() const first = vi.mocked(api.reorderDispatchRoute).mock.calls[0]![1] expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(api.reorderDispatchRoute).toHaveBeenNthCalledWith(2, 'PC/1', first); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount() }) it('refuses already picked item removal and a route that drops an authoritative destination', async () => { const source = candidate() vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: 'item-picked', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 1, remove: { allowed: false, blockCode: 'PICKED', blockReason: '本来源已经真实扫码' } }] }) const removed = await open('REMOVE_CARGO'); (removed.wrapper.vm as unknown as { itemId: string }).itemId = 'item-picked'; removed.state.form.reason = '调整'; await removed.state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled(); expect(removed.wrapper.text()).toContain('已经真实扫码'); removed.wrapper.unmount() vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] }) const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount() })