This commit is contained in:
2026-10-21 13:22:42 +08:00
parent eb0b0f85d1
commit 2333bd8bab
114 changed files with 1766 additions and 526 deletions
@@ -9,8 +9,8 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/dispatches'
import { command, detail, destination, loadingPreview, page, resources, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchCargoItem, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
import { command, detail, destination, loadingPreview, resources } from '../dispatchTestFixtures'
import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
@@ -24,7 +24,6 @@ afterEach(() => vi.unstubAllGlobals())
const gate = { allowed: true, blockCode: null, blockReason: null }
const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate }
const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 })
function item(id: string, picked = 0): DispatchCargoItem { return { dispatchItemId: id, customerId: 'C1', customerName: '客户甲', sourceKey: 'source-1', sourceVersion: 'source-v1', sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', palletNo: 'PALLET-1', orderNo: 'ORDER-1', inboundCode: 'IN-1', destination: destination(), scheduledPackages: 2, pickedPackages: picked, remove: picked ? { allowed: false, blockCode: 'PICKED', blockReason: '已经扫码,不可移除' } : gate } }
function cargo(key: string, customerId: string): CargoLine { return { dispatchItemId: 'item-' + key, customerId, customerName: '客户' + customerId, orderId: null, orderNo: 'ORDER-' + key, sourceKey: key, sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: null, inboundCode: 'IN-' + key, planPalletId: null, warehousePalletId: 'pallet-' + key, palletNo: 'PALLET-' + key, pickId: null, attributionId: null, inventoryPositionId: null, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: 'BOX-' + key, relabelNo: null, destination: destination(), packages: 2, productQuantity: 4, grossWeightKg: 10, volumeCbm: 1, batchAllocations: [], sourceOrigin: null } }
function arrangement(): ArrangementPreview {
const group = (key: string, customerId: string): PreviewGroup => ({ groupKey: key, customerId, customerName: '客户' + customerId, destination: destination(), groupCargoHash: 'cargo-' + key, cargo: [cargo(key, customerId)], externalFreightHash: 'external-' + key, externalQuotes: [], feeCoverageCargoHash: 'coverage-' + key, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] })
@@ -46,15 +45,10 @@ beforeEach(() => {
vi.mocked(api.returnDispatchToReview).mockImplementation(async (_, body) => command('RETURN_TO_REVIEW', body.idempotencyKey))
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.departDispatch).mockImplementation(async (_, body) => command('DEPART', body.idempotencyKey))
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => command('ADD_CARGO', body.idempotencyKey))
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => command('REMOVE_CARGO', body.idempotencyKey))
vi.mocked(api.reorderDispatchRoute).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview())
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] })
vi.mocked(api.listDispatches).mockResolvedValue(page())
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
@@ -130,25 +124,16 @@ describe('dispatch workflow native layout', () => {
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' })
})
it('removes the real item identity while keeping scanned cargo unavailable', async () => {
const { dialog } = await render('REMOVE_CARGO')
const radios = dialog.get('[aria-label="本车可移除货物"]').findAllComponents({ name: 'ElRadio' })
expect(radios[1]!.props('disabled')).toBe(true)
radios[0]!.vm.$emit('update:modelValue', '9223372036854775001'); await dialog.get('textarea').setValue(' 计划变更 ')
expect(dialog.text()).not.toContain('9223372036854775001')
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('replaces the old %s editor with the smart dispatch entry notice', async action => {
const { dialog } = await render(action)
expect(dialog.text()).toContain('货物安排统一在智能排车中修改')
expect(dialog.props('confirmDisabled')).toBe(true)
expect(dialog.findComponent({ name: 'ElForm' }).exists()).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9223372036854775001', { recordVersion: '9007199254740993', idempotencyKey: expect.any(String), reason: '计划变更' })
})
it('adds the complete filtered shared-pallet group and keeps every source visible when expanded', async () => {
const [first, second] = sharedPalletCandidates()
const { dialog } = await render('ADD_CARGO')
const source = dialog.get('details')
await source.get('summary').trigger('click'); await flushPromises()
expect(source.findAll('li')).toHaveLength(2)
await dialog.get('input[type="checkbox"]').setValue(true); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), cargo: [first!.selection, second!.selection], batchAllocations: [] })
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
expect(api.getDispatchItems).not.toHaveBeenCalled()
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
})
it('keeps references separate from real loading measurements and submits the exact manifest', async () => {
const { dialog } = await render('LOADING_COMPLETE')
@@ -1,11 +1,10 @@
import ElementPlus, { ElMessage } from 'element-plus'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import { ApiResponseError } from '@/shared/api/http'
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 { command, detail, destination, loadingPreview } from '../dispatchTestFixtures'
import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
@@ -14,57 +13,6 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
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<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: 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({ total: 1, page: 1, size: 20, pages: 1, candidates: [first!], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable<typeof first>, 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
await (wrapper.vm as unknown as { searchCargo(): Promise<void> }).searchCargo()
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({ total: 1, page: 1, size: 20, pages: 1, 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({ total: 1, page: 1, size: 20, pages: 1, 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<typeof import('../api/dispatches')>('../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()
@@ -129,16 +77,6 @@ it('does not reuse another dispatch detail when the target changes while its dia
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' }] })
@@ -150,10 +88,7 @@ it('preserves external destinations and retries route EDIT through its original
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()
it('refuses a route that drops an authoritative destination', async () => {
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()
})
@@ -236,29 +171,34 @@ it('describes a proven success with a failed GET as completed and retries only i
wrapper.unmount()
})
it('preserves selected complete groups across pages and submits hidden sources with their original versions', async () => {
const [first, hidden] = sharedPalletCandidates(), second = candidate({ sourceKey: 'next-page' })
vi.mocked(api.listDispatches).mockResolvedValue(page())
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.page === 2 ? [second] : [first!], page: query.page!, size: query.size!, total: 21, pages: 2, complete: false, snapshotAt: '' }))
const { wrapper, state } = await open('ADD_CARGO')
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and directs cargo changes to smart dispatch', async action => {
const { wrapper, state } = await open(action)
try {
const selection = wrapper.vm as unknown as { selectCargo(row: typeof second, checked: boolean): void; changeCandidatePage(value: number): void; candidatePageNumber: number; candidateKeyword: string; searchCargo(): Promise<void> }
selection.selectCargo(first!, true); selection.changeCandidatePage(2); await flushPromises(); selection.selectCargo(second, true); await flushPromises()
expect(wrapper.text()).toContain('已选择 2 个完整来源组')
selection.candidateKeyword = 'next'; await selection.searchCargo()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'next' }))
expect(api.listDispatches).toHaveBeenCalledTimes(1)
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
expect(wrapper.text()).toContain('货物安排统一在智能排车中修改')
await state.submit()
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first!.selection, hidden!.selection, second.selection] }))
} finally { wrapper.unmount() }
})
it('shows authoritative warehouse lookup failures when retrying a cargo search', async () => {
vi.mocked(api.listDispatches).mockRejectedValue(new Error('warehouse lookup unavailable'))
const { wrapper } = await open('ADD_CARGO')
try {
await (wrapper.vm as unknown as { searchCargo(): Promise<void> }).searchCargo()
expect(wrapper.text()).toContain('货物候选读取失败')
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
expect(api.getDispatchItems).not.toHaveBeenCalled()
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.emitted('saved')).toBeUndefined()
} finally { wrapper.unmount() }
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s command for recovery in smart dispatch', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/ORIGINAL', action, { recordVersion: '9007199254740993', idempotencyKey: 'original-key', reason: 'original reason', cargo: [{ sourceVersion: 'original-source' }] }, action === 'REMOVE_CARGO' ? { itemId: 'original-item' } : {})
recovery.pending.value!.attempted = true
const original = JSON.parse(JSON.stringify(recovery.pending.value))
const { wrapper, state } = await open('EDIT')
try {
expect(wrapper.text()).toContain('请从智能排车恢复原请求')
await state.submit(); await state.retryError()
expect(recovery.pending.value).toEqual(original)
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
expect(api.updateDispatch).not.toHaveBeenCalled()
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('rejected')).toBeUndefined()
} finally { wrapper.unmount() }
})
@@ -7,14 +7,12 @@ import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
import type { ArrangeDelivery, ArrangeGroup, ArrangementPreview, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchResourceFields from './DispatchResourceFields.vue'
@@ -30,17 +28,12 @@ const { pending, busy } = recovery
const currentAction = computed(() => pending.value?.action || props.action)
const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
const factsStale = ref(false)
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
const candidatePage = useDispatchCandidates(error)
const { candidates, page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string; row: Candidate }>>([]), candidateKeyword = ref('')
const candidateWarehouseId = ref('')
let candidateFilterKey = ''
const loading = computed(() => working.value)
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
@@ -48,8 +41,10 @@ const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver()
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
let generation = 0
let editorRequestKey = ''
const allowed = computed(() => !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
const frozen = computed(() => busy.value || Boolean(pending.value))
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
@@ -57,10 +52,10 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
items.value = null; itemId.value = ''; route.value = null; stops.value = []
candidatePage.reset(); candidateWarehouseId.value = ''
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null
route.value = null; stops.value = []
try {
if (cargoAction.value) return
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
const value = await api.getDispatch(no)
if (sequence !== generation) return
@@ -77,12 +72,6 @@ async function load() {
if (data.dispatchNo !== no || !data.stops.length || data.stops[0]?.index !== 0) throw new DispatchInputError('本车真实起点及路线未完整返回')
route.value = data; stops.value = data.stops.slice(1).map(stop => ({ ...stop }))
}
if (action === 'REMOVE_CARGO') {
const data = await api.getDispatchItems(no)
if (sequence !== generation) return
if (data.dispatchNo !== no) throw new DispatchInputError('移货清单与原排车单不一致')
items.value = data
}
if (action === 'LOADING_COMPLETE') {
const data = await api.getDispatchLoading(no)
if (sequence !== generation) return
@@ -102,48 +91,9 @@ async function load() {
arrangement.value = data
groups.value = data.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: '', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: ({ DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' })[feeType] || '服务费用', direction: 'RECEIVABLE', currency: 'USD', amount: null })) }))
}
if (action === 'ADD_CARGO') await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
finally { if (sequence === generation) working.value = false }
}
async function loadCandidates(sequence = generation) {
if (!candidateWarehouseId.value) {
// Detail has no warehouse field: resolve the authoritative list row by this business number.
const no = currentNo.value
const result = await api.listDispatches({ page: 1, size: 1, dispatchNo: no, status: 'ACTIVE' })
if (sequence !== generation) return
const row = result.records.find(item => item.dispatchNo === no)
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
candidateWarehouseId.value = row.warehouseId
}
if (sequence !== generation) return
const filterKey = JSON.stringify([candidateWarehouseId.value, candidateKeyword.value])
if (filterKey !== candidateFilterKey) { candidatePageNumber.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: candidateWarehouseId.value, orderNo: candidateKeyword.value || undefined, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
}
async function searchCargo() {
if (frozen.value) return
const sequence = generation
candidatePageNumber.value = 1
try { await loadCandidates(sequence) }
catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') }
}
function changeCandidatePage(value: number) { candidatePageNumber.value = value; void loadCandidates() }
function changeCandidateSize(value: number) { candidatePageSize.value = value; void searchCargo() }
function requirePlanPallet(row: Candidate): string {
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
return row.selection.planPalletId
}
function isCargoSelected(row: Candidate): boolean { return selected.value.some(value => value.groupKey === row.selectionGroup?.groupKey) }
function selectCargo(row: Candidate, checked: unknown) {
try {
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
const group = requireSelectionGroup(row)
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion, row })
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
}
function payload(): object {
if (!detail.value || !allowed.value) throw new DispatchInputError(detail.value?.actions[currentAction.value]?.blockReason || '当前操作未获得服务端允许,请刷新核对')
const common = { recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID() }
@@ -153,23 +103,7 @@ function payload(): object {
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
}
switch (currentAction.value) {
case 'REMOVE_CARGO': {
const row = items.value?.items.find(value => value.dispatchItemId === itemId.value)
if (!row || row.remove.allowed !== true || row.pickedPackages !== 0 || !form.reason.trim()) throw new DispatchInputError(row?.remove.blockReason || '请选择允许移除的未扫码货物并填写原因')
return { recordVersion: requireVersion(items.value!.recordVersion), idempotencyKey: common.idempotencyKey, reason: form.reason.trim() } satisfies ReasonCommand
}
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'ADD_CARGO': {
const chosen = selected.value.map(value => {
const row = value.row
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
return row
})
candidatePage.assertCurrent(chosen)
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
if (!chosen.length || chosen.some(row => row.warehouseId !== candidateWarehouseId.value || row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
}
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
@@ -201,9 +135,7 @@ async function send(command: PendingDispatchCommand): Promise<CommandResult> {
const no = command.dispatchNo, body = command.body
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
switch (command.action) {
case 'REMOVE_CARGO': if (!command.itemId) throw new DispatchInputError('原移货请求缺少真实明细,请保留现场核对'); return api.removeDispatchItem(no, command.itemId, body as unknown as ReasonCommand)
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
case 'ADD_CARGO': return api.addDispatchCargo(no, body as unknown as AddCargo)
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
case 'RETURN_TO_REVIEW': return api.returnDispatchToReview(no, body as unknown as ReasonCommand)
@@ -219,9 +151,10 @@ async function submit() {
if (busy.value || loading.value || uploadingEvidence.value) return
error.value = null
try {
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
if (!pending.value) {
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value })
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
}
const routeCommand = pending.value?.operation === 'ROUTE'
@@ -262,23 +195,21 @@ async function refreshFacts() {
}
async function retryError() { if (pending.value) await submit(); else if (detail.value) await refreshFacts(); else await load() }
function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] }
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++; candidatePage.reset() } }, { immediate: true })
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="pending" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
<el-alert v-if="pending && !cargoAction" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
</section>
<section v-else-if="currentAction === 'REMOVE_CARGO' && items" class="density-dialog-section" data-tone="source" aria-label="本车可移除货物"><h3 class="density-dialog-section__title">本车货物</h3>
<el-table :data="items.items" border><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || loading || uploadingEvidence || !allowed || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="scheduledPackages" label="本次箱数" width="100" /><el-table-column prop="pickedPackages" label="真实已拣" width="100" /><el-table-column label="移货资格" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table>
</section>
<template v-else-if="currentAction === 'EDIT'">
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
@@ -290,12 +221,6 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</section>
<section class="density-dialog-section" data-tone="work" aria-label="审核结果登记"><h3 class="density-dialog-section__title">审核结果</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
</template>
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
<el-pagination aria-label="加货候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="frozen || loading || !allowed" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
<p class="density-dialog-note">已选择 {{ selected.length }} 个完整来源组(含其他页)</p>
</section>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
@@ -309,8 +234,8 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</template>
<section v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
</el-form>
</div>
</AppDialog>
@@ -319,8 +244,4 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
.workflow-editor-table { table-layout: fixed; }
.workflow-editor-table td { width: 50%; }
.workflow-order-actions { width: 160px; white-space: nowrap; }
.workflow-cargo-query { display: flex; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.workflow-cargo-query > .el-input { max-width: 320px; }
.workflow-source-group > summary { cursor: pointer; }
.workflow-source-group ul { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
</style>
@@ -238,5 +238,5 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
.exception-return-batch + .exception-return-batch { margin-top: 12px; }
.exception-redispatch-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.exception-redispatch-table th, .exception-redispatch-table td { padding: 6px 8px; border: 1px solid var(--el-border-color-lighter); text-align: left; }
.exception-redispatch-table th { background: var(--el-fill-color-light); }
.exception-redispatch-table th { background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
</style>
@@ -0,0 +1,189 @@
import ElementPlus from 'element-plus'
import { defineComponent, h } from 'vue'
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import { candidate, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { Candidate, DispatchDraft } from '../dispatchContracts'
enableAutoUnmount(afterEach)
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
function draft(): DispatchDraft {
return { draftToken: 'server-token', recordVersion: 2, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', sources: [], containers: [
{ draftContainerId: 'car-1', sequenceNo: 1, capacity: 30, sourceKeys: [] },
{ draftContainerId: 'car-2', sequenceNo: 2, capacity: 15, sourceKeys: [] },
] }
}
function render(candidates: Candidate[] = [candidate()], extra: Record<string, unknown> = {}) {
return mount(SmartDispatchCargoBoard, { props: { candidates, draft: draft(), activeContainerId: 'car-1', capacity: 30, disabled: false, warehouseSelected: true, page: 1, size: 20, total: candidates.length, ...extra }, global: { plugins: [ElementPlus] } })
}
async function select(wrapper: VueWrapper, index = 0) { await wrapper.get('[aria-label="待排订单"]').findAll('tbody tr')[index]!.get('button').trigger('click') }
function pallet(wrapper: VueWrapper) { return wrapper.get('[aria-label="托盘明细"] article') }
function transfer() { return { setData: vi.fn(), getData: vi.fn(() => '{"sourceKey":"forged","containerId":"car-2"}'), effectAllowed: '', dropEffect: '' } }
describe('smart dispatch three-column cargo board', () => {
it('shows only the chosen order pallets and a default empty car without emitting writes', async () => {
const first = candidate(), second = candidate({ sourceKey: 'source-2', orderNo: 'ORDER-2', palletNo: 'PALLET-2' })
const wrapper = render([first, second], { draft: null, activeContainerId: '' })
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(1)
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('30 托位')
await select(wrapper, 1)
expect(pallet(wrapper).text()).toContain('PALLET-2')
expect(wrapper.get('[aria-label="托盘明细"]').text()).not.toContain('PALLET-1')
expect(wrapper.emitted('add')).toBeUndefined()
await pallet(wrapper).get('button').trigger('click')
expect(wrapper.emitted('add')).toEqual([[second, '']])
})
it('drops a current pallet into a non-active car using the drop target identity', async () => {
const source = candidate(), wrapper = render([source]), dataTransfer = transfer()
await select(wrapper)
await pallet(wrapper).trigger('dragstart', { dataTransfer })
await wrapper.get('[aria-label="第 2 辆车"]').trigger('dragover', { dataTransfer })
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
expect(wrapper.emitted('add')).toEqual([[source, 'car-2']])
expect(dataTransfer.getData).not.toHaveBeenCalled()
await wrapper.get('[aria-label="第 1 辆车"]').trigger('drop', { dataTransfer })
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('rejects external or forged drag payloads that were never selected inside this component', async () => {
const wrapper = render(), dataTransfer = transfer()
await select(wrapper)
await wrapper.get('[aria-label="第 2 辆车"]').trigger('dragover', { dataTransfer })
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
expect(wrapper.emitted('add')).toBeUndefined()
expect(dataTransfer.getData).not.toHaveBeenCalled()
})
it.each(['refresh', 'page', 'size', 'disabled', 'draft'] as const)('rejects a previously started drag after %s changes', async change => {
const source = candidate(), wrapper = render([source]), dataTransfer = transfer()
await select(wrapper); await pallet(wrapper).trigger('dragstart', { dataTransfer })
if (change === 'refresh') await wrapper.setProps({ candidates: [clone(source)] })
if (change === 'page') await wrapper.setProps({ page: 2 })
if (change === 'size') await wrapper.setProps({ size: 10 })
if (change === 'disabled') await wrapper.setProps({ disabled: true })
if (change === 'draft') await wrapper.setProps({ draft: { ...draft(), recordVersion: 3 } })
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
expect(wrapper.emitted('add')).toBeUndefined()
})
it('freezes add, removal, selection and pagination while a command is pending', async () => {
const source = candidate(), state = draft()
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
const wrapper = render([source], { draft: state, total: 21 })
await select(wrapper); await wrapper.setProps({ disabled: true })
expect(wrapper.findAll('button').filter(button => ['添加车辆', '移除货物', '删除车辆', '查看托盘'].includes(button.text())).every(button => button.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.findAll('input[type="radio"]').every(input => input.attributes('disabled') !== undefined)).toBe(true)
await pallet(wrapper).trigger('dragstart', { dataTransfer: transfer() })
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop')
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
expect(wrapper.emitted('add')).toBeUndefined()
expect(wrapper.emitted('changePage')).toBeUndefined()
})
it('keeps a shared pallet as one full source group even when only one member is on the current page', async () => {
const sources = sharedPalletCandidates(), wrapper = render([sources[0]!])
await select(wrapper)
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(1)
const group = pallet(wrapper)
expect(group.text()).toContain('整托关联货物一起加入')
expect(group.findAll('details li')).toHaveLength(2)
expect(group.text()).toContain('客户A'); expect(group.text()).toContain('客户B')
expect(group.text()).toContain('8 箱')
await group.get('button').trigger('click')
expect(wrapper.emitted('add')).toEqual([[sources[0], 'car-1']])
expect((wrapper.emitted('add')![0]![0] as Candidate).selectionGroup.sources).toHaveLength(2)
})
it('shows a shared loaded pallet only once and blocks another member from being added to another car', async () => {
const sources = sharedPalletCandidates(), state = draft()
state.sources = sources; state.containers[0]!.sourceKeys = sources.map(row => row.sourceKey)
const wrapper = render(sources, { draft: state, activeContainerId: 'car-2' })
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(wrapper.get('[aria-label="第 1 辆车"]').findAll('.cargo-board-loaded-pallet')).toHaveLength(1)
for (const index of [0, 1]) {
await select(wrapper, index)
expect(pallet(wrapper).attributes('draggable')).toBe('false')
expect(pallet(wrapper).get('button').text()).toBe('已加入第 1 辆车')
expect(pallet(wrapper).get('button').attributes('disabled')).toBeDefined()
}
expect(wrapper.emitted('add')).toBeUndefined()
})
it('blocks incomplete server-owned groups and does not convert missing measurements to zero', async () => {
const source = candidate({ suggestedWeightKg: null, suggestedVolumeCbm: null })
source.selectionGroup.complete = false
source.selectionGroup.blockReasons = ['同托货物未全部返回']
const wrapper = render([source])
await select(wrapper)
expect(pallet(wrapper).attributes('draggable')).toBe('false')
expect(pallet(wrapper).get('button').attributes('disabled')).toBeDefined()
expect(pallet(wrapper).text()).toContain('同托货物未全部返回')
expect(wrapper.text()).not.toMatch(/0(?:\.0)?\s*(?:kg|m³)/)
expect(wrapper.emitted('add')).toBeUndefined()
})
it('keeps allocated cars visible across candidate pages and reports page events without pretending to have full orders', async () => {
const source = candidate(), state = draft()
state.sources = [source]; state.containers[0]!.sourceKeys = [source.sourceKey]
const wrapper = render([source], { draft: state, total: 21 })
await select(wrapper)
await wrapper.get('[aria-label="候选货物分页"] button.btn-next').trigger('click')
expect(wrapper.emitted('changePage')).toEqual([[2]])
expect(wrapper.get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
await wrapper.setProps({ page: 2, candidates: [candidate({ orderNo: 'NEXT-PAGE', palletNo: 'NEXT-PALLET' })] })
expect(wrapper.get('[aria-label="待排订单"]').text()).toContain('货物分页,同一订单可能分布在多页')
expect(wrapper.get('[aria-label="第 1 辆车"]').text()).toContain('PALLET-1')
expect(wrapper.findAll('[aria-label="车辆装载"] article')).toHaveLength(2)
})
it('restores the chosen order after a same-page loading refresh but discards its old drag reference', async () => {
const source = candidate(), wrapper = render([source]), dataTransfer = transfer()
await select(wrapper); await pallet(wrapper).trigger('dragstart', { dataTransfer })
await wrapper.setProps({ candidates: [] })
await wrapper.setProps({ candidates: [clone(source)] })
expect(pallet(wrapper).text()).toContain('PALLET-1')
await wrapper.get('[aria-label="第 2 辆车"]').trigger('drop', { dataTransfer })
expect(wrapper.emitted('add')).toBeUndefined()
})
it('counts distinct planned groups separately when their display pallet labels are the same', async () => {
const first = candidate({ selection: { ...candidate().selection, warehousePalletId: null } })
const second = candidate({ sourceKey: 'source-2', selection: { ...first.selection, planPalletId: '12' } })
const wrapper = render([first, second])
const order = wrapper.get('[aria-label="待排订单"] tbody tr')
expect(order.findAll('td')[3]!.text()).toBe('2')
await select(wrapper)
expect(wrapper.findAll('[aria-label="托盘明细"] article')).toHaveLength(2)
})
it('uses independent vehicle radio groups when two entrypoint dialogs are mounted', () => {
const props = { candidates: [candidate()], draft: draft(), activeContainerId: 'car-1', capacity: 30, disabled: false, warehouseSelected: true, page: 1, size: 20, total: 1 }
const host = mount(defineComponent({ setup: () => () => h('div', [h(SmartDispatchCargoBoard, props), h(SmartDispatchCargoBoard, props)]) }), { global: { plugins: [ElementPlus] } })
const [first, second] = host.findAllComponents(SmartDispatchCargoBoard)
expect(first!.get('input[type="radio"]').attributes('name')).not.toBe(second!.get('input[type="radio"]').attributes('name'))
})
it('accepts an existing dispatch slot as the exact drop target without draft containers', async () => {
const source = candidate()
const wrapper = mount(SmartDispatchCargoBoard, {
props: { candidates: [source], draft: null, activeContainerId: 'PC/1', capacity: 0, disabled: false, warehouseSelected: true, page: 1, size: 20, total: 1 },
slots: { vehicles: ({ dragOver, drop }: { dragOver: (event: DragEvent, id: string) => void; drop: (event: DragEvent, id: string) => void }) => h('section', { 'aria-label': '真实本车清单', onDragover: (event: DragEvent) => dragOver(event, 'PC/1'), onDrop: (event: DragEvent) => drop(event, 'PC/1') }, 'PALLET-EXISTING') },
global: { plugins: [ElementPlus] },
})
expect(wrapper.find('[aria-label="车辆装载"]').exists()).toBe(false)
expect(wrapper.text()).toContain('PALLET-EXISTING')
await select(wrapper)
const dataTransfer = transfer()
await pallet(wrapper).trigger('dragstart', { dataTransfer })
await wrapper.get('[aria-label="真实本车清单"]').trigger('dragover', { dataTransfer })
await wrapper.get('[aria-label="真实本车清单"]').trigger('drop', { dataTransfer })
expect(wrapper.emitted('add')).toEqual([[source, 'PC/1']])
await pallet(wrapper).trigger('dragstart', { dataTransfer })
await wrapper.setProps({ activeContainerId: 'PC/2' })
await wrapper.get('[aria-label="真实本车清单"]').trigger('drop', { dataTransfer })
expect(wrapper.emitted('add')).toHaveLength(1)
})
})
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { computed, ref, shallowRef, useId, useSlots, watch } from 'vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import type { Candidate, DispatchDraft, DraftContainer } from '../dispatchContracts'
import { selectionGroupBlockReason } from '../dispatchContracts'
const props = defineProps<{
candidates: Candidate[]
draft: DispatchDraft | null
activeContainerId: string
capacity: number
disabled: boolean
warehouseSelected: boolean
page: number
size: number
total: number
}>()
const emit = defineEmits<{
add: [source: Candidate, containerId: string]
selectContainer: [id: string]
removeCargo: [source: Candidate, containerId: string]
removeContainer: [id: string]
addVehicle: []
changePage: [page: number]
changeSize: [size: number]
}>()
const selectedOrderKey = ref('')
const slots = useSlots()
const vehicleRadioName = `smart-dispatch-active-vehicle-${useId()}`
const draggedSource = shallowRef<Candidate | null>(null)
const dropTarget = ref<string | null>(null)
function orderKey(source: Candidate) {
return JSON.stringify([source.customerId, source.selection.sourceType, source.orderNo || source.inboundCode || source.planNo || source.selection.relabelNo || source.sourceKey])
}
function businessNo(source: Candidate) {
return source.orderNo || source.inboundCode || source.planNo || source.selection.relabelNo || '—'
}
function sourceLabel(source: Candidate) {
return source.selection.sourceType === 'RELABEL' ? '换标' : source.selection.sourceType === 'RETURN_RECEIPT' ? '退回实收' : source.selection.warehousePalletId ? '已收货托盘' : '在途计划'
}
function destinationLabel(source: Candidate) {
return source.destination.warehouseCode || source.destination.warehouseName || source.destination.address || '—'
}
function uniqueGroups(sources: Candidate[]) {
const seen = new Set<string>()
return sources.filter(source => {
const key = source.selectionGroup?.groupKey || source.sourceKey
if (seen.has(key)) return false
seen.add(key)
return true
})
}
const orders = computed(() => {
const grouped = new Map<string, Candidate[]>()
for (const source of props.candidates) {
const key = orderKey(source)
const existing = grouped.get(key)
if (existing) existing.push(source)
else grouped.set(key, [source])
}
return [...grouped].map(([key, sources]) => ({ key, source: sources[0]!, sources }))
})
const selectedOrder = computed(() => orders.value.find(order => order.key === selectedOrderKey.value))
const palletGroups = computed(() => uniqueGroups(selectedOrder.value?.sources || []))
const vehicles = computed<DraftContainer[]>(() => props.draft?.containers.length ? props.draft.containers : [{ draftContainerId: '', sequenceNo: 1, capacity: props.capacity, sourceKeys: [] }])
function assignedVehicle(source: Candidate) {
const keys = new Set([source.sourceKey, ...(source.selectionGroup?.sources.map(row => row.sourceKey) || [])])
return props.draft?.containers.find(vehicle => vehicle.sourceKeys.some(key => keys.has(key)))
}
function canAdd(source: Candidate) {
return !props.disabled && props.warehouseSelected && props.candidates.includes(source) && !assignedVehicle(source) && !selectionGroupBlockReason(source)
}
function validTarget(id: string) {
if (slots.vehicles) return props.warehouseSelected && Boolean(id) && id === props.activeContainerId
return vehicles.value.some(vehicle => vehicle.draftContainerId === id)
}
function add(source: Candidate, id: string) {
if (!canAdd(source) || !validTarget(id)) return
emit('add', source, id)
}
function clearDrag() { draggedSource.value = null; dropTarget.value = null }
function dragStart(event: DragEvent, source: Candidate) {
clearDrag()
if (!canAdd(source)) { event.preventDefault(); return }
draggedSource.value = source
if (event.dataTransfer) {
// Transfer only a harmless marker. The live component reference is the authority.
event.dataTransfer.setData('text/plain', 'dispatch-pallet')
event.dataTransfer.effectAllowed = 'copy'
}
}
function dragOver(event: DragEvent, id: string) {
if (!draggedSource.value || !canAdd(draggedSource.value) || !validTarget(id)) return
event.preventDefault()
dropTarget.value = id
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
}
function drop(event: DragEvent, id: string) {
event.preventDefault()
const source = draggedSource.value
clearDrag()
if (source) add(source, id)
}
function chooseOrder(key: string) {
if (props.disabled) return
clearDrag()
selectedOrderKey.value = key
}
function changePage(page: number) {
if (props.disabled) return
selectedOrderKey.value = ''
clearDrag()
emit('changePage', page)
}
function changeSize(size: number) {
if (props.disabled) return
selectedOrderKey.value = ''
clearDrag()
emit('changeSize', size)
}
function vehicleSources(vehicle: DraftContainer) {
const keys = new Set(vehicle.sourceKeys)
return uniqueGroups((props.draft?.sources || []).filter(source => keys.has(source.sourceKey)))
}
function physicalPalletCount(sources: Candidate[]): number | null {
const keys = new Set<string>()
for (const source of sources) {
if (!source.selectionGroup?.pallets.length) return null
for (const pallet of source.selectionGroup.pallets) {
if (!pallet.warehousePalletId && (!source.selectionGroup.groupKey || !pallet.palletNo)) return null
const key = pallet.warehousePalletId
? JSON.stringify(['physical', pallet.warehousePalletId])
: JSON.stringify(['planned', source.selectionGroup.groupKey, pallet.palletNo])
keys.add(key)
}
}
return keys.size
}
function vehiclePalletCount(vehicle: DraftContainer) {
if (vehicle.sourceKeys.some(key => !props.draft?.sources.some(source => source.sourceKey === key))) return null
return physicalPalletCount(vehicleSources(vehicle))
}
function groupPackages(source: Candidate) {
const packages = source.selectionGroup?.pallets.map(pallet => pallet.packageCount)
return packages?.length && packages.every(value => Number.isSafeInteger(value) && value >= 0) ? packages.reduce((sum, value) => sum + value, 0) : null
}
function palletLabel(source: Candidate) {
return source.selectionGroup?.pallets.map(pallet => pallet.palletNo || '托盘号未提供').join('、') || source.palletNo || '—'
}
watch(() => [props.page, props.size], () => { selectedOrderKey.value = ''; clearDrag() }, { flush: 'sync' })
watch(() => props.candidates, () => {
clearDrag()
// A same-page reload temporarily empties candidates. Keep the selected key;
// selectedOrder naturally becomes undefined if the refreshed page no longer has it.
}, { deep: true, flush: 'sync' })
watch(() => [props.disabled, props.warehouseSelected, props.draft, props.activeContainerId], clearDrag, { deep: true, flush: 'sync' })
</script>
<template>
<div class="dispatch-cargo-board" aria-label="智能排车三栏选货">
<section class="cargo-board-panel cargo-board-orders" aria-label="待排订单">
<header class="cargo-board-heading"><h3>待排订单</h3><span class="cargo-board-muted">本页 {{ orders.length }} 单</span><InlineHelp title="货物分页说明"><p>货物分页,同一订单可能分布在多页。点击订单查看本页货物关联托盘;关联托数包含必须同车的完整托盘组。</p></InlineHelp></header>
<div class="cargo-board-table-wrap">
<table class="density-table cargo-board-table">
<thead><tr><th class="cargo-board-operation">操作</th><th>订单号 / 来源</th><th>客户</th><th>关联托数</th><th>目的仓</th></tr></thead>
<tbody>
<tr v-for="order in orders" :key="order.key" :class="{ 'is-selected': selectedOrderKey === order.key }" @click="chooseOrder(order.key)">
<td><el-button link :disabled="disabled" :aria-pressed="selectedOrderKey === order.key" @click.stop="chooseOrder(order.key)">查看托盘</el-button></td>
<td><span class="cargo-board-business-no">{{ businessNo(order.source) }}</span><small class="cargo-board-muted">{{ sourceLabel(order.source) }}{{ !order.source.orderNo ? ' · 未关联订单' : '' }}</small></td>
<td>{{ order.source.customerName || '—' }}</td>
<td>{{ physicalPalletCount(order.sources) ?? '—' }}</td>
<td>{{ [...new Set(order.sources.map(destinationLabel))].join('、') }}</td>
</tr>
<tr v-if="!orders.length"><td colspan="5" class="cargo-board-empty">{{ warehouseSelected ? '暂无可排货物' : '请先选择起运仓' }}</td></tr>
</tbody>
</table>
</div>
<footer class="cargo-board-pagination"><span class="cargo-board-muted">货物分页,同一订单可能分布在多页</span><el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="disabled" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" /></footer>
</section>
<section class="cargo-board-panel cargo-board-pallets" aria-label="托盘明细" aria-live="polite">
<header class="cargo-board-heading"><h3>托盘明细</h3><span class="cargo-board-muted">本页货物关联托盘</span></header>
<div v-if="selectedOrder" class="cargo-board-order-caption">{{ businessNo(selectedOrder.source) }}<small v-if="!selectedOrder.source.orderNo" class="cargo-board-muted">未关联订单</small></div>
<p v-if="!selectedOrder" class="cargo-board-empty">请选择左侧订单查看托盘明细</p>
<div v-else class="cargo-board-pallet-list">
<article v-for="source in palletGroups" :key="source.selectionGroup.groupKey" class="cargo-board-pallet" :class="{ 'is-assigned': assignedVehicle(source), 'is-draggable': canAdd(source) }" :draggable="canAdd(source)" :aria-label="`托盘 ${palletLabel(source)}`" @dragstart="dragStart($event, source)" @dragend="clearDrag">
<div class="cargo-board-pallet-title"><strong>{{ palletLabel(source) }}</strong><span>{{ groupPackages(source) ?? '—' }} 箱</span></div>
<div class="cargo-board-muted">{{ destinationLabel(source) }}</div>
<details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物一起加入</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details>
<p v-if="selectionGroupBlockReason(source)" class="cargo-board-block">{{ selectionGroupBlockReason(source) }}</p>
<el-button link :disabled="!canAdd(source) || !validTarget(activeContainerId)" @click="add(source, activeContainerId)">{{ assignedVehicle(source) ? `已加入第 ${assignedVehicle(source)!.sequenceNo} 辆车` : '加入这辆车' }}</el-button>
</article>
<p v-if="!palletGroups.length" class="cargo-board-empty">该订单本页暂无托盘</p>
</div>
</section>
<slot name="vehicles" :drag-over="dragOver" :drop="drop" :drop-target="dropTarget">
<section class="cargo-board-vehicles" aria-label="车辆装载">
<header class="cargo-board-heading"><h3>车辆装载</h3><InlineHelp title="车辆装载说明"><p>将中间托盘拖入任意车辆,或选中车辆后点击“加入这辆车”。已装货物需先移除才能加入其他车辆。体积、重量以装车核对为准,本区不将缺失测量按零汇总。</p></InlineHelp><el-button type="primary" :disabled="disabled || !warehouseSelected" @click="emit('addVehicle')">添加车辆</el-button></header>
<div class="cargo-board-vehicle-list">
<article v-for="vehicle in vehicles" :key="vehicle.draftContainerId" class="cargo-board-vehicle" :class="{ 'is-current': vehicle.draftContainerId === activeContainerId, 'is-drop-target': dropTarget === vehicle.draftContainerId }" :aria-label="`第 ${vehicle.sequenceNo} 辆车`" @dragover="dragOver($event, vehicle.draftContainerId)" @dragleave.self="dropTarget = null" @drop="drop($event, vehicle.draftContainerId)">
<header class="cargo-board-vehicle-heading"><label><input type="radio" :name="vehicleRadioName" :checked="activeContainerId === vehicle.draftContainerId" :disabled="disabled" :aria-label="`选择第 ${vehicle.sequenceNo} 辆车`" @change="emit('selectContainer', vehicle.draftContainerId)" /> 第 {{ vehicle.sequenceNo }} 辆车 · {{ vehicle.capacity }} 托位</label><span>托数 {{ vehiclePalletCount(vehicle) ?? '—' }}/{{ vehicle.capacity }}</span></header>
<div class="cargo-board-vehicle-body">
<p v-if="!vehicle.sourceKeys.length" class="cargo-board-empty">拖入托盘</p>
<div v-for="source in vehicleSources(vehicle)" :key="source.selectionGroup.groupKey" class="cargo-board-loaded-pallet">
<div><strong>{{ palletLabel(source) }}</strong><small class="cargo-board-muted">{{ businessNo(source) }} · {{ groupPackages(source) ?? '—' }} 箱</small><details v-if="source.selectionGroup.sources.length > 1" class="cargo-board-group-details"><summary>整托关联货物</summary><ul><li v-for="item in source.selectionGroup.sources" :key="item.sourceKey">{{ item.sourceBusinessNo || '业务号未提供' }} · {{ item.customerName || '—' }} · {{ item.packages }} 箱</li></ul></details></div>
<el-button link :disabled="disabled" @click="emit('removeCargo', source, vehicle.draftContainerId)">移除货物</el-button>
</div>
<p v-if="vehiclePalletCount(vehicle) === null" class="cargo-board-block">已选货物资料不完整,请重新读取草稿。</p>
</div>
<footer v-if="vehicle.draftContainerId" class="cargo-board-vehicle-footer"><el-button link type="danger" :disabled="disabled" @click="emit('removeContainer', vehicle.draftContainerId)">删除车辆</el-button></footer>
</article>
</div>
</section>
</slot>
</div>
</template>
<style scoped>
.dispatch-cargo-board { display: grid; grid-template-columns: minmax(0, 42fr) minmax(0, 22fr) minmax(0, 36fr); align-items: start; gap: var(--admin-space-6); }
.cargo-board-panel { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); overflow: hidden; background: var(--admin-surface); }
.cargo-board-heading { display: flex; align-items: center; gap: var(--admin-space-3); min-height: var(--admin-control-height); padding: var(--admin-space-3) var(--admin-space-5); flex-wrap: wrap; }
.cargo-board-heading h3 { margin: 0; font-size: var(--admin-font-section); }
.cargo-board-heading > .el-button { margin-left: auto; }
.cargo-board-muted { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.cargo-board-table-wrap { overflow: auto; max-height: 48vh; }
.cargo-board-table { width: 100%; table-layout: fixed; }
.cargo-board-table td { overflow-wrap: anywhere; }
.cargo-board-table th:nth-child(1) { width: 82px; }
.cargo-board-table th:nth-child(2) { width: 32%; }
.cargo-board-table th:nth-child(4) { width: 72px; }
.cargo-board-table small, .cargo-board-loaded-pallet small { display: block; }
.cargo-board-table tbody tr { cursor: pointer; }
.cargo-board-table tr.is-selected td { background: var(--admin-action-primary-hover); }
.cargo-board-business-no { font-weight: 600; }
.cargo-board-pagination { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); padding: var(--admin-space-4); overflow-x: auto; }
.cargo-board-order-caption { padding: var(--admin-space-3) var(--admin-space-5); border-top: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.cargo-board-order-caption small { display: block; }
.cargo-board-empty { padding: var(--admin-space-7); color: var(--cc-muted); text-align: center; margin: 0; }
.cargo-board-pallet-list { max-height: 52vh; overflow-y: auto; }
.cargo-board-pallet { padding: var(--admin-space-5); border-top: 1px solid var(--admin-table-border); }
.cargo-board-pallet.is-draggable { cursor: grab; }
.cargo-board-pallet.is-assigned { background: var(--admin-label-bg); }
.cargo-board-pallet-title { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--admin-space-3); overflow-wrap: anywhere; }
.cargo-board-pallet-title strong { min-width: 0; }
.cargo-board-pallet-title > span { white-space: nowrap; }
.cargo-board-group-details { margin-top: var(--admin-space-2); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.cargo-board-group-details summary { cursor: pointer; }
.cargo-board-group-details ul { margin: var(--admin-space-2) 0; padding-left: var(--admin-space-6); }
.cargo-board-block { color: var(--admin-action-danger); font-size: var(--admin-font-caption); margin: var(--admin-space-2) 0; }
.cargo-board-vehicles { min-width: 0; }
.cargo-board-vehicle-list { display: flex; flex-direction: column; gap: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
.cargo-board-vehicle { border: 2px dashed var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface); }
.cargo-board-vehicle.is-current { border-color: var(--admin-action-primary-border); }
.cargo-board-vehicle.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
.cargo-board-vehicle-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); flex-wrap: wrap; padding: var(--admin-space-5); background: var(--admin-dialog-source-bg); }
.cargo-board-vehicle-heading label { display: flex; align-items: center; gap: var(--admin-space-2); cursor: pointer; }
.cargo-board-vehicle-body { min-height: 90px; padding: var(--admin-space-3); }
.cargo-board-loaded-pallet { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-3); padding: var(--admin-space-3); border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.cargo-board-loaded-pallet > div { min-width: 0; }
.cargo-board-loaded-pallet > .el-button { flex: none; }
.cargo-board-vehicle-footer { display: flex; justify-content: flex-end; padding: 0 var(--admin-space-5) var(--admin-space-3); }
</style>
@@ -4,14 +4,13 @@ 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'
import type { ConvertDraft, DispatchDraft } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const can = vi.hoisted(() => vi.fn((_code: string) => true))
@@ -49,21 +48,10 @@ function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialog
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)
}
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('starts cargo with one explicit action, creates the first group and enables submission only after cargo is assigned', async () => {
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 } })
@@ -72,36 +60,38 @@ describe('new dispatch native layout', () => {
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()
const start = () => view(dialog).findAll('button').find(button => button.text() === '开始选货')!
expect(start().attributes('disabled')).toBeDefined()
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
await start().trigger('click'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
await selectFirstOrder(dialog)
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.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)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(true)
expect(dialog.props('confirmDisabled')).toBe(true)
await view(dialog).findAll('button').find(button => button.text() === '加入本组')!.trigger('click'); await flushPromises()
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).text()).toContain('1 组有货')
expect(view(dialog).findAll('button').find(button => button.text() === '已加入第1组')!.attributes('disabled')).toBeDefined()
expect(view(dialog).get('[aria-label="第 1 辆车"]').text()).toContain('托数 1/30')
expect(view(dialog).findAll('button').find(button => button.text() === '已加入第 1 辆车')!.attributes('disabled')).toBeDefined()
})
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
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 view(dialog).findAll('button').find(button => button.text() === '开始选货')!.trigger('click'); await flushPromises()
await selectFirstOrder(dialog)
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(true)
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
})
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
@@ -116,59 +106,45 @@ describe('new dispatch native layout', () => {
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false)
expect(dialog.findComponent(DispatchResourceFields).exists()).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')
it('creates an unassigned empty dispatch while preserving optional dates and remark through fullscreen', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
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 view(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(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
expect(view(dialog).find('[aria-label="排车候选货物"]').exists()).toBe(false)
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), 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' } } })
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: null, driver: null })
})
it('queries real master directories and keeps business labels and task-end evidence in the command', async () => {
it('creates with only the warehouse and never requests vehicle or driver directories', 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(view(dialog).text()).not.toContain(vehicleRecord.id)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
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' } } }))
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('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 = view(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')
await field(dialog, '备注').get('input').setValue('第一组')
const radios = view(dialog).get('[aria-label="车辆装载"]').findAll('input[type="radio"]')
await radios[1]!.setValue(true); await flushPromises()
await field(dialog, '备注').get('input').setValue('第二组')
await radios[0]!.setValue(true); await flushPromises()
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
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.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(request.groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '第一组'], [null, null, '第二组']])
expect(wrapper.emitted('created')).toBeUndefined()
expect(view(dialog).text()).toContain('草稿操作结果未确认,原请求已保留')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
@@ -178,33 +154,30 @@ describe('new dispatch native layout', () => {
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', '司机甲')
const { wrapper, dialog } = await render(); await restore(dialog)
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()
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
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]).toEqual(original)
})
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
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('.el-table__body-wrapper .el-table__row')).toHaveLength(20)
expect(table.text()).toContain('PALLET-19')
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(40)
const sourceGroup = table.findAll('details')[0]!
await sourceGroup.get('summary').trigger('click'); await flushPromises()
expect(sourceGroup.element.hasAttribute('open')).toBe(true)
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')
@@ -234,7 +207,8 @@ describe('new dispatch native layout', () => {
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 view(dialog).findAll('button').find(button => button.text() === '开始选货')!.trigger('click'); await flushPromises()
await selectFirstOrder(dialog)
await view(dialog).findAll('button').find(button => button.text() === '加入这辆车')!.trigger('click'); await flushPromises()
expect(view(dialog).text()).toContain('draft-rejected-request')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
@@ -0,0 +1,247 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError } from '@/shared/api/http'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import * as api from '../api/dispatches'
import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { Candidate, DispatchDraft } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const push = vi.hoisted(() => vi.fn())
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => vi.unstubAllGlobals())
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const warehouse: PlatformWarehouseResource = { id: '321', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
type Step = 'CREATE' | 'CONTAINER' | 'LOCK'
let catalog: Candidate[], server: DispatchDraft | null, lostReply: Step | null, reverseContainers: boolean
let receipts: Map<string, DispatchDraft>, calls: Step[]
function source(index: number): Candidate {
return candidate({ sourceKey: `source-${index}`, customerId: `C${index}`, customerName: `客户${index}`, orderNo: `ORDER-${index}`, inboundCode: `IN-${index}`, palletNo: `PALLET-${index}`,
selection: { sourceType: 'INBOUND_PLAN', planPalletId: String(10 + index), returnReceiptId: null, relabelNo: null, warehousePalletId: String(20 + index), sourceVersion: `source-version-${index}` }, sourceVersion: `source-version-${index}` })
}
function emptyDraft(): DispatchDraft { return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [], sources: [] } }
function response(step: Step, key: string, mutate: () => void): DispatchDraft {
calls.push(step)
const identityKey = `${step}:${key}`
if (!receipts.has(identityKey)) { mutate(); receipts.set(identityKey, clone(server!)) }
if (lostReply === step) { lostReply = null; throw new Error(`${step} committed but response was lost`) }
return clone(receipts.get(identityKey)!)
}
beforeEach(() => {
vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
catalog = [source(1), source(2), source(3)]; server = null; lostReply = null; reverseContainers = false; receipts = new Map(); calls = []
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
vi.mocked(api.listDispatchDrafts).mockImplementation(async () => server ? [clone(server)] : [])
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => {
const page = query.page ?? 1, size = query.size ?? 20
return { candidates: clone(catalog.slice((page - 1) * size, page * size)), total: catalog.length, page, size, pages: Math.ceil(catalog.length / size), complete: true, snapshotAt: '2026-10-21T00:00:00Z' }
})
vi.mocked(api.createDispatchDraft).mockImplementation(async body => response('CREATE', body.idempotencyKey, () => { server = emptyDraft() }))
vi.mocked(api.createDispatchDraftContainer).mockImplementation(async body => response('CONTAINER', body.idempotencyKey, () => {
expect(body.draftToken).toBe(server!.draftToken); expect(body.recordVersion).toBe(server!.recordVersion)
const next = Math.max(0, ...server!.containers.map(row => row.sequenceNo)) + 1
server!.containers.push({ draftContainerId: `container-${next}`, sequenceNo: next, capacity: body.capacity, sourceKeys: [] })
server!.recordVersion = Number(server!.recordVersion) + 1
if (reverseContainers) server!.containers.reverse()
}))
vi.mocked(api.lockDispatchDraftPallet).mockImplementation(async body => response('LOCK', body.idempotencyKey, () => {
expect(body.draftToken).toBe(server!.draftToken); expect(body.recordVersion).toBe(server!.recordVersion)
const selected = catalog.find(row => JSON.stringify(row.selection) === JSON.stringify(body.source))!
expect(selected, 'LOCK must use a currently returned exact source selection').toBeDefined()
const target = server!.containers.find(row => row.draftContainerId === body.draftContainerId)!
expect(target, 'LOCK must retain its explicit container identity').toBeDefined()
for (const member of selected.selectionGroup.sources) {
if (!target.sourceKeys.includes(member.sourceKey)) target.sourceKeys.push(member.sourceKey)
if (!server!.sources.some(row => row.sourceKey === member.sourceKey)) {
server!.sources.push(clone(catalog.find(row => row.sourceKey === member.sourceKey) || { ...selected, sourceKey: member.sourceKey, customerId: member.customerId, customerName: member.customerName, selection: member.selection, sourceVersion: member.selection.sourceVersion }))
}
}
server!.recordVersion = Number(server!.recordVersion) + 1
}))
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC-${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
})
async function render() {
const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } })
await flushPromises()
return wrapper.getComponent(AppDialog)
}
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! }
function button(dialog: VueWrapper, label: string) { return view(dialog).findAll('button').find(row => row.text() === label)! }
function car(dialog: VueWrapper, index: number) { return view(dialog).get(`article[aria-label="第 ${index} 辆车"]`) }
function cargoRow(dialog: VueWrapper, palletNo: string) { return view(dialog).get(`[aria-label="托盘 ${palletNo}"]`) }
async function selectOrder(dialog: VueWrapper, palletNo: string) {
const source = catalog.find(row => row.palletNo === palletNo)!
const row = view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!))!
await row.get('button').trigger('click'); await flushPromises()
}
async function warehouseSelected(dialog: VueWrapper) {
const picker = field(dialog, '起运仓').getComponent({ name: 'ElSelect' })
picker.vm.$emit('update:modelValue', '321'); picker.vm.$emit('change', '321'); await flushPromises()
}
async function join(dialog: VueWrapper, palletNo: string) { await selectOrder(dialog, palletNo); await cargoRow(dialog, palletNo).findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click'); await flushPromises() }
async function chooseCar(dialog: VueWrapper, index: number) { await car(dialog, index).get('input[type="radio"]').setValue(true); await flushPromises() }
describe('three-column cargo planning through visible controls', () => {
it('opens and selects a warehouse as reads only with one empty 30-space placeholder and no start prerequisite', async () => {
const dialog = await render()
expect(car(dialog, 1).text()).toContain('30 托位')
expect(view(dialog).findAll('article[aria-label^="第 "]')).toHaveLength(1)
expect(view(dialog).text()).not.toContain('开始安排第一辆车')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(calls).toEqual([])
await warehouseSelected(dialog)
expect(view(dialog).get('[aria-label="待排订单"]').text()).toContain('ORDER-1')
expect(view(dialog).get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
expect(calls).toEqual([])
expect(api.createDispatch).not.toHaveBeenCalled()
expect(dialog.props('confirmDisabled')).toBe(true)
})
it('first adds cargo through CREATE then CONTAINER then exactly one LOCK', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
expect(calls).toEqual(['CREATE', 'CONTAINER', 'LOCK'])
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 1, idempotencyKey: expect.any(String), draftContainerId: 'container-1', source: catalog[0]!.selection })
expect(car(dialog, 1).text()).toContain('PALLET-1')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(dialog.props('confirmDisabled')).toBe(false)
})
it('adding a vehicle before any cargo materializes the first and second empty cars without locking stock', async () => {
const dialog = await render(); await warehouseSelected(dialog)
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
expect(calls).toEqual(['CREATE', 'CONTAINER', 'CONTAINER'])
expect(view(dialog).findAll('article[aria-label^="第 "]')).toHaveLength(2)
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(dialog.props('confirmDisabled')).toBe(true)
})
it('retains the first car cargo, isolates per-car fields and adds later cargo to the car selected again', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
await field(dialog, '备注').get('input').setValue('第一车独立备注')
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
expect(car(dialog, 1).text()).toContain('PALLET-1')
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await field(dialog, '备注').get('input').setValue('第二车独立备注'); await join(dialog, 'PALLET-2')
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[1]![0].draftContainerId).toBe('container-2')
await chooseCar(dialog, 1)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一车独立备注')
await join(dialog, 'PALLET-3')
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[2]![0].draftContainerId).toBe('container-1')
expect(car(dialog, 1).text()).toContain('PALLET-3'); expect(car(dialog, 2).text()).toContain('PALLET-2')
await chooseCar(dialog, 2)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第二车独立备注')
})
it('selects the newly added ID even when its response lists the new container first and old container last', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
reverseContainers = true
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
expect(server!.containers.map(row => row.draftContainerId)).toEqual(['container-2', 'container-1'])
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
await join(dialog, 'PALLET-2')
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls.at(-1)![0].draftContainerId).toBe('container-2')
expect(car(dialog, 1).text()).toContain('PALLET-1'); expect(car(dialog, 1).text()).not.toContain('PALLET-2')
})
it('locks a dragged pallet to a non-active car and keeps the other car allocation unchanged', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
await selectOrder(dialog, 'PALLET-2')
const dataTransfer = { setData: vi.fn(), effectAllowed: '', dropEffect: '' }
await cargoRow(dialog, 'PALLET-2').trigger('dragstart', { dataTransfer })
await car(dialog, 1).trigger('dragover', { dataTransfer })
await car(dialog, 1).trigger('drop', { dataTransfer }); await flushPromises()
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls.at(-1)![0]).toMatchObject({ draftContainerId: 'container-1', source: catalog[1]!.selection })
expect(server!.containers[0]!.sourceKeys).toEqual(['source-1', 'source-2'])
expect(server!.containers[1]!.sourceKeys).toEqual([])
})
it.each(['CREATE', 'CONTAINER'] as const)('does not continue to LOCK after a definite %s rejection', async step => {
const rejected = new ApiResponseError(`${step}条件不允许`, 'A0409', 409, undefined, `request-${step}`)
if (step === 'CREATE') vi.mocked(api.createDispatchDraft).mockRejectedValueOnce(rejected)
else vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(rejected)
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CREATE' ? 0 : 1)
expect(view(dialog).text()).toContain(`request-${step}`)
expect(view(dialog).text()).toContain('按明确驳回结果重新核对')
})
it.each(['CREATE', 'CONTAINER'] as const)('stops after an unknown %s and retries its exact command without auto-locking or recreating', async step => {
lostReply = step
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
const before = step === 'CREATE' ? clone(vi.mocked(api.createDispatchDraft).mock.calls[0]![0]) : clone(vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0])
await button(dialog, '重试原请求').trigger('click'); await flushPromises()
if (step === 'CREATE') {
expect(api.createDispatchDraft).toHaveBeenNthCalledWith(2, before)
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled()
} else {
expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, before)
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
}
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
await join(dialog, 'PALLET-1')
expect(api.createDispatchDraft).toHaveBeenCalledTimes(step === 'CREATE' ? 2 : 1)
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(step === 'CONTAINER' ? 2 : 1)
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
})
it('keeps the preparation chain frozen while CREATE is in flight', async () => {
let resolve!: (value: DispatchDraft) => void
vi.mocked(api.createDispatchDraft).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const dialog = await render(); await warehouseSelected(dialog)
await selectOrder(dialog, 'PALLET-1')
await cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.trigger('click')
expect(button(dialog, '添加车辆').attributes('disabled')).toBeDefined()
expect(cargoRow(dialog, 'PALLET-1').findAll('button').find(row => row.text() === '加入这辆车')!.attributes('disabled')).toBeDefined()
expect(view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr button').every(row => row.attributes('disabled') !== undefined)).toBe(true)
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).not.toHaveBeenCalled(); expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
server = emptyDraft(); resolve(clone(server)); await flushPromises()
expect(api.createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
})
it('restores both existing cars without an extra placeholder and retains their allocations across real paging', async () => {
catalog = Array.from({ length: 21 }, (_, index) => source(index + 1))
server = { ...emptyDraft(), recordVersion: 4, sources: clone(catalog.slice(0, 2)), containers: [1, 2].map(index => ({ draftContainerId: `container-${index}`, sequenceNo: index, capacity: index === 1 ? 30 : 15, sourceKeys: [`source-${index}`] })) }
const dialog = await render()
dialog.findAllComponents({ name: 'ElSelect' }).find(row => row.props('placeholder') === '继续待完成排车')!.vm.$emit('change', 'server-token'); await flushPromises()
expect(view(dialog).findAll('article[aria-label^="第 "]')).toHaveLength(2)
expect(car(dialog, 1).text()).toContain('PALLET-1'); expect(car(dialog, 2).text()).toContain('PALLET-2')
expect(calls).toEqual([])
await view(dialog).get('[aria-label="候选货物分页"] button.btn-next').trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, draftToken: 'server-token' }))
expect(view(dialog).get('[aria-label="待排订单"]').text()).toContain('ORDER-21')
expect(view(dialog).get('[aria-label="托盘明细"]').text()).toContain('请选择左侧订单')
expect(car(dialog, 1).text()).toContain('PALLET-1'); expect(car(dialog, 2).text()).toContain('PALLET-2')
expect(calls).toEqual([])
})
it('locks a shared physical group once and converts all authoritative members without duplicate writes', async () => {
catalog = sharedPalletCandidates()
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
expect(server!.containers[0]!.sourceKeys).toEqual(catalog.map(row => row.sourceKey))
for (const source of catalog) {
await view(dialog).get('[aria-label="待排订单"]').findAll('tbody tr').find(row => row.text().includes(source.orderNo!) && row.text().includes(source.customerName!))!.get('button').trigger('click')
expect(cargoRow(dialog, 'PALLET-1').get('button').text()).toBe('已加入第 1 辆车')
expect(cargoRow(dialog, 'PALLET-1').get('button').attributes('disabled')).toBeDefined()
}
await button(dialog, '生成排车单').trigger('click'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual(catalog.map(row => row.selection))
expect(api.lockDispatchDraftPallet).toHaveBeenCalledTimes(1)
})
})
@@ -15,8 +15,34 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
vi.mock('./SmartDispatchExistingCargo.vue', () => ({ default: { name: 'SmartDispatchExistingCargo', emits: ['saved', 'busy'], template: '<div data-existing-cargo />' } }))
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() }))
const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
it('switches to existing cargo inside the same dialog without creating a draft or submitting a new dispatch', async () => {
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const dialog = wrapper.getComponent(AppDialog)
await wrapper.getComponent({ name: 'ElDialogContent' }).findAll('button').find(button => button.text() === '调整已有排车货物')!.trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
expect(dialog.props('showConfirm')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).not.toHaveBeenCalled()
expect(api.createDispatchDraft).not.toHaveBeenCalled()
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
wrapper.getComponent({ name: 'SmartDispatchExistingCargo' }).vm.$emit('saved'); await flushPromises()
expect(wrapper.emitted('created')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('opens %s recovery in existing cargo without reading creation drafts', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/1', action, { recordVersion: 0, idempotencyKey: 'existing-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
expect(wrapper.findComponent({ name: 'SmartDispatchExistingCargo' }).exists()).toBe(true)
expect(api.listDispatchDrafts).not.toHaveBeenCalled()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(recovery.pending.value?.body.idempotencyKey).toBe('existing-key')
wrapper.unmount()
})
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
const [first, second] = sharedPalletCandidates(), original = draft()
original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey]
@@ -100,19 +126,19 @@ it('accepts a partial candidate page without requiring the filtered whole set',
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 21, page: 1, size: 20, pages: 2, candidates: [source()], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('尚未完整返回'); wrapper.unmount()
})
it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => {
it('keeps planning remarks independent with unassigned resources and reuses a proven conversion after a detail read fails', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; activeContainerId: string; vehicle: { vehicleId: string | null }; form: { remark: string } }
state.restoreDraft('server-token'); await flushPromises(); state.vehicle.vehicleId = 'vehicle-A'; state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.vehicle.vehicleId = 'vehicle-B'; state.form.remark = '车乙'
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; activeContainerId: string; form: { remark: string } }
state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.form.remark = '车乙'
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
expect(groups.map(group => [group.dispatch.vehicle?.vehicleId, group.dispatch.remark])).toEqual([['vehicle-A', '车甲'], ['vehicle-B', '车乙']])
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('never sends an existing departure command through the empty creation transport', async () => {
@@ -2,7 +2,6 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -11,17 +10,17 @@ import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/mod
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, DraftSelection, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
import DispatchResourceFields from './DispatchResourceFields.vue'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
@@ -29,39 +28,65 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>
const router = useRouter(), auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[]; attempted: boolean; rejected: boolean; result?: ConvertedDraft }
const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
const workspace = ref<'create' | 'existing'>('create'), existingBusy = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const working = ref(false), saving = ref(false), preparing = ref(false), error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string } }>>({})
const currentSettings = computed(() => {
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } }
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' } }
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
const driver = computed({ get: () => currentSettings.value.driver, set: (value: Driver) => { currentSettings.value.driver = value } })
const frozen = computed(() => !canCreate.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
const workspaceFrozen = computed(() => loading.value || preparing.value || saving.value || existingBusy.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
const confirmDisabled = computed(() => loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
const confirmDisabled = computed(() => preparing.value || loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
function assignedGroup(row: Candidate) {
return draft.value?.containers.find(group => group.sourceKeys.some(key => row.selectionGroup.sources.some(source => source.sourceKey === key)))
}
async function startCargo() {
async function ensureFirstVehicle(sequence: number): Promise<string | null> {
if (!draft.value && !await draftAction('CREATE')) return null
if (sequence !== generation || !props.modelValue || !draft.value) return null
if (!draft.value.containers.length && !await draftAction('CONTAINER')) return null
if (sequence !== generation || !props.modelValue) return null
return currentContainer.value?.draftContainerId || null
}
async function addCargo(source: Candidate, targetContainerId = activeContainerId.value) {
if (frozen.value || loading.value || !warehouseId.value || assignedGroup(source)) return
const sequence = generation, target = targetContainerId
preparing.value = true
try {
requireSelectionGroup(source); candidatePage.assertCurrent([source])
const containerId = target || await ensureFirstVehicle(sequence)
if (!containerId || sequence !== generation || !props.modelValue) return
if (!draft.value?.containers.some(row => row.draftContainerId === containerId)) throw new DispatchInputError('所选车辆已变化,请重新选择')
activeContainerId.value = containerId
await draftAction('LOCK', source, containerId)
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '加入货物失败,请核对后重试') }
finally { preparing.value = false }
}
async function addVehicle() {
if (frozen.value || loading.value || !warehouseId.value) return
if (!draft.value) await draftAction('CREATE')
if (draft.value && !error.value && !pending.value && !draft.value.containers.length) await draftAction('CONTAINER')
const sequence = generation
preparing.value = true
try {
if (!await ensureFirstVehicle(sequence)) return
if (sequence === generation && props.modelValue) await draftAction('CONTAINER')
} finally { preparing.value = false }
}
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() }
@@ -78,6 +103,10 @@ async function init() {
const sequence = ++generation
working.value = true; error.value = null
try {
if (recovery.pending.value && ['ADD_CARGO', 'REMOVE_CARGO'].includes(recovery.pending.value.action)) workspace.value = 'existing'
else if (pending.value || recovery.pending.value?.action === 'CREATE') workspace.value = 'create'
else if (!canCreate.value && canEditCargo.value) workspace.value = 'existing'
if (workspace.value === 'existing' || !canCreate.value) return
if (recovery.pending.value?.action === 'CREATE') { mode.value = 'empty'; warehouseId.value = String(recovery.pending.value.body.warehouseId || '') }
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
@@ -92,12 +121,20 @@ async function init() {
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
finally { if (sequence === generation) working.value = false }
}
function switchWorkspace(value: 'create' | 'existing') {
if (workspaceFrozen.value || value === workspace.value || (value === 'create' ? !canCreate.value : !canEditCargo.value)) return
workspace.value = value
existingBusy.value = false
candidatePage.reset()
void init()
}
async function loadCandidates(sequence = generation) {
if (sequence !== generation) return
const filterKey = JSON.stringify([warehouseId.value, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: warehouseId.value, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
}
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
@@ -105,10 +142,10 @@ function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
const config = containerId ? settings[containerId] : currentSettings.value
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) {
if (!canCreate.value || loading.value || saving.value) return
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value) return false
error.value = null
try {
if (!pending.value) {
@@ -133,10 +170,11 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
body = { ...common, groups } satisfies ConvertDraft
}
}
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, attempted: false, rejected: false }; persist()
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}), attempted: false, rejected: false }; persist()
}
await retryDraft()
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留') }
return true
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '草稿操作结果未确认,原请求已保留'); return false }
}
async function retryDraft() {
if (!pending.value || saving.value) return
@@ -163,8 +201,15 @@ async function retryDraft() {
pending.value = null; persist(); emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
}
requireVersion((result as DispatchDraft).recordVersion)
let addedContainerId: string | undefined
if (command.action === 'CONTAINER') {
const updated = result as DispatchDraft, previous = command.priorContainerIds
const added = previous && updated.containers.filter(row => !previous.includes(row.draftContainerId))
if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果尚未核对,请保留原请求重试')
addedContainerId = added[0]!.draftContainerId
}
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
// No new source mutation before the original response has been validated.
pending.value = null; persist()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
@@ -172,7 +217,8 @@ async function retryDraft() {
finally { saving.value = false }
}
async function submit() {
if (!canCreate.value || saving.value || loading.value) return
if (workspace.value === 'existing') return
if (!canCreate.value || preparing.value || saving.value || loading.value) return
error.value = null
try {
if (pending.value) { await retryDraft(); return }
@@ -186,75 +232,59 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-status-tabs" role="tablist" aria-label="智能排车工作方式">
<button v-if="canCreate" type="button" role="tab" :aria-selected="workspace === 'create'" :class="{ 'is-active': workspace === 'create' }" :disabled="workspaceFrozen" @click="switchWorkspace('create')">新建排车</button>
<button v-if="canEditCargo" type="button" role="tab" :aria-selected="workspace === 'existing'" :class="{ 'is-active': workspace === 'existing' }" :disabled="workspaceFrozen" @click="switchWorkspace('existing')">调整已有排车货物</button>
</div>
<SmartDispatchExistingCargo v-if="workspace === 'existing' && modelValue" @saved="emit('created')" @busy="existingBusy = $event" />
<template v-else-if="workspace === 'create'">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<section class="density-dialog-section dispatch-setup" aria-label="排车设置">
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可加入装车组。预排只安排运输,实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可安排货物并生成排车单。车辆和司机在建单后维护;实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">选货排车</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货再建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item>
</el-form>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="整车草稿管理">
<h3 class="density-dialog-section__title">2. 选货分组<InlineHelp title="分组与草稿说明"><p>每个有货的装车组生成一张排车单,同仓可混装多个客户和目的地。草稿用于暂存选货并占用来源;退出后可继续已有草稿,放弃时释放草稿。</p></InlineHelp></h3>
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="车辆选货安排">
<h3 class="density-dialog-section__title">2. 为车辆选货<InlineHelp title="选货步骤说明"><p>点击左侧订单查看中间托盘,再将托盘拖入右侧车辆;也可选择车辆后点击“加入这辆车”。添加车辆时,之前的车与货物会保留。每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成排车单的选货会暂存并占用货物;可继续待完成排车,或放弃本次安排以解除占用。</p></InlineHelp></h3>
<div class="dispatch-draft-tools density-actions">
<el-button v-if="!draft || !draft.containers.length" type="primary" :disabled="frozen || loading || !warehouseId" @click="startCargo">开始选货</el-button>
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续已有草稿" aria-label="继续已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="新组计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
<el-button v-if="draft?.containers.length" :disabled="frozen || loading" @click="draftAction('CONTAINER')">添加装车组</el-button>
<InlineHelp title="计划托位说明"><p>此处只设置新组的预排容量,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">释放本草稿</el-button>
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
<InlineHelp title="计划托位说明"><p>此处设置新车辆的预排托位,默认空车尚未加货时也使用此值,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
</div>
</section>
<div class="cargo-workspace">
<section class="density-dialog-section" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</button></div>
<el-table :data="candidates" border :empty-text="!warehouseId ? '请先选择起运仓' : '暂无符合条件的可排货物;在途货物请核对离港节点、入库计划和目的地'">
<el-table-column label="操作" width="115" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !currentContainer || Boolean(selectionGroupBlockReason(row as Candidate)) || Boolean(assignedGroup(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">{{ assignedGroup(row as Candidate) ? '已加入第' + assignedGroup(row as Candidate)!.sequenceNo + '组' : '加入本组' }}</el-button></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ row.selection.sourceType === 'INBOUND_PLAN' ? (row.selection.warehousePalletId ? '已收货托盘' : '在途计划') : ({ RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="170" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary class="dispatch-group-summary"><ChevronRight class="dispatch-source-toggle" :size="14" aria-hidden="true" /><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}{{ row.selection.warehousePalletId ? '个实托' : '个计划托盘' }}</span></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
<el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
</el-table>
<el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="frozen || loading" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" />
</section>
<section class="density-dialog-section" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组 <small>{{ populatedGroupCount }} 组有货</small><InlineHelp title="装车分组说明"><p>新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></InlineHelp></h3>
<p v-if="!draft?.containers.length" class="density-dialog-note">暂无装车组</p>
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table" :aria-label="`装车组 ${container.sequenceNo}`"><tbody>
<tr><td><el-radio v-model="activeContainerId" :disabled="frozen || loading" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio></td><td><el-button link :disabled="frozen || loading" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button></td></tr>
<tr><td colspan="2"><p v-if="!container.sourceKeys.length" class="density-dialog-note">暂无货物</p><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
</tbody></table>
</section>
</div>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button><el-button :disabled="frozen || loading || !warehouseId" @click="resetSearch">重置</el-button></div>
</section>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全美' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</button></div>
<SmartDispatchCargoBoard :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="Boolean(warehouseId)" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize" />
</template>
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" aria-label="排车车辆与司机">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 车辆与时间' : '3. 车辆与时间 · 装车组 ' + currentContainer!.sequenceNo }}<small>可先保存,审核前补齐</small><InlineHelp title="车辆与时间说明"><p>车辆、司机、时间和备注分别保存在当前选中的装车组。切换右侧装车组,可分别填写每一趟的资料。</p></InlineHelp></h3>
<section class="density-dialog-section" aria-label="排车计划时间与备注">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 计划时间与备注' : '3. 第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆信息”补充。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading" />
</section>
</el-form>
</div>
</template>
</AppDialog>
</template>
<style scoped>
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--admin-space-5); align-items: start; }
.cargo-workspace > .density-dialog-section { margin: 0; }
.cargo-workspace > .density-dialog-section > .density-dialog-section__title { min-height: 24px; align-items: center; }
.dispatch-setup, .dispatch-draft-toolbar { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; gap: var(--admin-space-5); }
.dispatch-setup > .density-dialog-section__title, .dispatch-draft-toolbar > .density-dialog-section__title { align-items: center; margin: 0; }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
@@ -265,14 +295,5 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 100px; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-group-summary { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; list-style: none; align-items: center; gap: var(--admin-space-2); }
.dispatch-source-group > summary { cursor: pointer; }
.dispatch-source-group > summary::-webkit-details-marker { display: none; }
.dispatch-source-group[open] .dispatch-source-toggle { transform: rotate(90deg); }
.dispatch-source-list { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
.draft-group-table + .draft-group-table { margin-top: var(--admin-space-4); }
.dispatch-draft-sources { margin: 0; padding: 0; list-style: none; }
.dispatch-draft-source { display: flex; align-items: center; gap: var(--admin-space-3); }
.dispatch-draft-source :deep(.line-clamp-text) { flex: 1; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
</style>
@@ -0,0 +1,191 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import SmartDispatchExistingCargo from './SmartDispatchExistingCargo.vue'
import * as api from '../api/dispatches'
import { candidate, command, detail, listRow, page, sharedPalletCandidates } from '../dispatchTestFixtures'
import type { DispatchCargoItem, DispatchDetail, DispatchItems } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '', allowed: true }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => identity.allowed }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), listDispatchCandidates: vi.fn(), addDispatchCargo: vi.fn(), removeDispatchItem: vi.fn(), getDispatchCommand: vi.fn() }))
enableAutoUnmount(afterEach)
const allowed = { allowed: true, blockCode: null, blockReason: null }
const current = (overrides: Partial<DispatchDetail> = {}) => detail({ taskType: 'DELIVERY', actions: { ADD_CARGO: allowed, REMOVE_CARGO: allowed }, ...overrides })
function cargo(overrides: Partial<DispatchCargoItem> = {}): DispatchCargoItem {
const source = candidate()
return { ...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, ...overrides }
}
const manifest = (overrides: Partial<DispatchItems> = {}): DispatchItems => ({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [cargo()], ...overrides })
function button(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(row => row.text() === text); if (!found) throw new Error(`Missing button ${text}`); return found }
async function open(dispatchNo: string | null = 'PC/1') {
const wrapper = mount(SmartDispatchExistingCargo, { props: { dispatchNo: dispatchNo ?? undefined }, global: { plugins: [ElementPlus] } })
await flushPromises()
return wrapper
}
async function add(wrapper: VueWrapper) { await button(wrapper, '查看托盘').trigger('click'); await button(wrapper, '加入这辆车').trigger('click'); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); identity.allowed = true
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' })]))
vi.mocked(api.getDispatch).mockResolvedValue(current())
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest())
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: [candidate()], page: query.page || 1, size: query.size || 20, total: 1, pages: 1, complete: true, snapshotAt: '' }))
})
it('adds the complete hidden shared-pallet group through real board buttons, then reloads current versions', async () => {
const [first, second] = sharedPalletCandidates()
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size === 100 ? [first!, second!] : [first!], page: query.page!, size: query.size!, total: 2, pages: 1, complete: true, snapshotAt: '' }))
const wrapper = await open()
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
return command('ADD_CARGO', body.idempotencyKey)
})
await add(wrapper)
expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargo: [first!.selection, second!.selection] }))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(api.getDispatchItems).toHaveBeenCalledTimes(3)
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(3)
expect(wrapper.find('[aria-label="当前排车货物"]').text()).toContain('真实已拣 0 箱')
expect(wrapper.text()).not.toContain('添加车辆')
})
it.each(['complete', 'missing', 'changed'] as const)('reads appointments of a hidden second group source: %s', async scenario => {
const first = candidate()
const second = candidate({ sourceKey: 'source-2', customerId: 'C2', selection: { ...first.selection, planPalletId: '12' }, appointmentBatches: [{ batchKey: 'B2', batchNo: 'AP2', customerId: 'C2', recordVersion: '9007199254740993', sources: [{ sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }] })
const group = { ...first.selectionGroup, sources: [...first.selectionGroup.sources, ...second.selectionGroup.sources], pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['source-1', 'source-2'], packageCount: 4 }] }
first.selectionGroup = group; second.selectionGroup = group
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.size !== 100 ? [first] : query.page === 1 ? [first] : scenario === 'missing' ? [] : [{ ...second, selectionGroup: scenario === 'changed' ? { ...group, groupVersion: 'new-composition' } : group }], page: query.page!, size: query.size!, total: 101, pages: 2, complete: true, snapshotAt: '' }))
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
const wrapper = await open(); await add(wrapper)
if (scenario === 'complete') {
expect(api.listDispatchCandidates).toHaveBeenCalledWith({ warehouseId: '321', warehouseTab: 'ALL', page: 2, size: 100 })
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first.selection, second.selection], batchAllocations: [{ planPalletId: '12', batchKey: 'B2', customerId: 'C2', recordVersion: '9007199254740993', sourceKey: 'S2', destinationKey: 'D2', packageCount: 2 }] }))
expect(wrapper.emitted('busy')?.at(-1)).toEqual([false])
} else {
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(wrapper.text()).toContain(scenario === 'missing' ? '同托货物及预约资料未完整返回' : '选货组组成已变化')
}
})
it.each([0, '0', '9007199254740993'])('preserves precise appointment allocation version %s in additions', async recordVersion => {
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }] })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
vi.mocked(api.addDispatchCargo).mockRejectedValue(new Error('unknown'))
const wrapper = await open(); await add(wrapper)
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ batchAllocations: [{ planPalletId: '11', batchKey: 'B1', customerId: 'C1', recordVersion, sourceKey: 'S1', destinationKey: 'D1', packageCount: 2 }] }))
expect(button(wrapper, '加入这辆车').attributes('disabled')).toBeDefined()
})
it('requires a removal reason and submits the authoritative item ID and zero version', async () => {
const wrapper = await open()
await button(wrapper, '移除货物').trigger('click')
expect(button(wrapper, '确认移除').attributes('disabled')).toBeDefined()
await wrapper.get('textarea[aria-label="移除原因"]').setValue('本车计划调整')
vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => {
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] }))
return command('REMOVE_CARGO', body.idempotencyKey)
})
await button(wrapper, '确认移除').trigger('click'); await flushPromises()
expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' }))
expect(wrapper.text()).toContain('本车暂无货物')
})
it('rejects an addition whose authoritative appointment version is missing', async () => {
const source = candidate({ appointmentBatches: [{ batchKey: 'B1', batchNo: 'AP1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
const wrapper = await open(); await add(wrapper)
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('预约批次版本缺失或无效')
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
})
it('rejects a candidate page from another warehouse before exposing selectable cargo', async () => {
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [candidate({ warehouseId: 'OTHER' })], page: 1, size: 20, total: 1, pages: 1, complete: true, snapshotAt: '' })
const wrapper = await open()
expect(wrapper.text()).toContain('候选起运仓或来源版本不完整')
expect(wrapper.findAll('button').some(row => row.text() === '加入这辆车')).toBe(false)
expect(api.addDispatchCargo).not.toHaveBeenCalled()
})
it.each(['picked', 'item-gate', 'shared-pallet'] as const)('locks removal for %s instead of changing real picking facts', async scenario => {
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ items: scenario === 'shared-pallet' ? [cargo(), cargo({ dispatchItemId: 'other', sourceKey: 'other' })] : [cargo(scenario === 'picked' ? { pickedPackages: 1 } : { remove: { allowed: false, blockCode: 'LOCK', blockReason: '正在作业' } })] }))
const wrapper = await open()
for (const row of wrapper.findAll('button').filter(row => row.text() === '移除货物')) expect(row.attributes('disabled')).toBeDefined()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
if (scenario === 'shared-pallet') expect(wrapper.text()).toContain('同一实物托盘包含多个来源')
})
it.each(['loading', 'permission', 'task-type', 'version', 'read-failure'] as const)('fails closed when %s blocks editing', async scenario => {
if (scenario === 'loading') { vi.mocked(api.getDispatch).mockResolvedValue(current({ stage: 'LOADING' })); vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ stage: 'LOADING' })) }
if (scenario === 'permission') identity.allowed = false
if (scenario === 'task-type') vi.mocked(api.getDispatch).mockResolvedValue(current({ taskType: 'PICKUP' }))
if (scenario === 'version') vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 2 }))
if (scenario === 'read-failure') vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
const wrapper = await open()
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
expect(wrapper.findAll('button').filter(row => row.text() === '移除货物').every(row => row.attributes('disabled') !== undefined)).toBe(true)
expect(api.addDispatchCargo).not.toHaveBeenCalled()
if (scenario === 'version') expect(wrapper.text()).toContain('一致版本')
})
it('auto-opens the original removal command after remount and checks its result without resending', async () => {
const wrapper = await open()
await button(wrapper, '移除货物').trigger('click')
await wrapper.get('textarea[aria-label="移除原因"]').setValue('原移除原因')
vi.mocked(api.removeDispatchItem).mockRejectedValue(new Error('unknown result'))
await button(wrapper, '确认移除').trigger('click'); await flushPromises()
const original = vi.mocked(api.removeDispatchItem).mock.calls[0]!
wrapper.unmount()
const resumed = await open(null)
expect(resumed.text()).toContain('PC/1')
const recovery = useDispatchCommandRecovery(() => identity.id)
expect(recovery.pending.value).toMatchObject({ dispatchNo: 'PC/1', itemId: '9007199254740993', body: original[2] })
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key))
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1, items: [] }))
await button(resumed, '核对并重试原请求').trigger('click'); await flushPromises()
expect(api.removeDispatchItem).toHaveBeenCalledTimes(1)
expect(recovery.pending.value).toBeNull()
expect(resumed.emitted('saved')).toHaveLength(1)
})
it('keeps a confirmed result pending if the subsequent manifest cannot be read', async () => {
const wrapper = await open()
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => {
vi.mocked(api.getDispatch).mockResolvedValue(current({ recordVersion: 1 }))
vi.mocked(api.getDispatchItems).mockRejectedValue(new Error('read unavailable'))
return command('ADD_CARGO', body.idempotencyKey)
})
await add(wrapper)
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result?.action).toBe('ADD_CARGO')
vi.mocked(api.getDispatchItems).mockResolvedValue(manifest({ recordVersion: 1 }))
await button(wrapper, '刷新当前资料').trigger('click'); await flushPromises()
expect(api.addDispatchCargo).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('does not let a late previous dispatch read overwrite the selected dispatch', async () => {
let resolve!: (value: DispatchDetail) => void
vi.mocked(api.listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY' }), listRow({ dispatchNo: 'PC/2', taskType: 'DELIVERY' })]))
vi.mocked(api.getDispatch).mockImplementation(no => no === 'PC/1' ? new Promise(done => { resolve = done }) : Promise.resolve(current({ dispatchNo: 'PC/2' })))
vi.mocked(api.getDispatchItems).mockImplementation(async no => manifest({ dispatchNo: no }))
const wrapper = await open()
await wrapper.setProps({ dispatchNo: 'PC/2' }); await flushPromises()
resolve(current()); await flushPromises()
expect(wrapper.get('[aria-label="当前排车货物"]').text()).toContain('PC/2')
expect(wrapper.get('[aria-label="当前排车货物"]').text()).not.toContain('PC/1')
})
it('queries and paginates active before-loading dispatches using the entered business number', async () => {
const wrapper = await open(null)
vi.mocked(api.listDispatches).mockImplementation(async query => ({ ...page([listRow({ taskType: 'DELIVERY' })]), page: query.page, size: query.size, total: 22, pages: 3 }))
await wrapper.get('input[aria-label="查找已有排车"]').setValue('PC2026')
await button(wrapper, '查询排车').trigger('click'); await flushPromises()
await wrapper.get('[aria-label="已有排车分页"] button.btn-next').trigger('click'); await flushPromises()
expect(api.listDispatches).toHaveBeenLastCalledWith({ page: 2, size: 10, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: 'PC2026' })
})
@@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import { canDispatchAction } from '../dispatchPresentation'
import { DispatchInputError, expandSelectionGroups, requireBatchVersions, requireSelectionGroup, requireVersion, selectionIdentity } from '../dispatchContracts'
import type { AddCargo, Candidate, DispatchCargoItem, DispatchDetail, DispatchItems, DispatchListRow, ReasonCommand } from '../dispatchContracts'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
const props = defineProps<{ dispatchNo?: string }>()
const emit = defineEmits<{ saved: []; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const { pending, busy } = recovery
const error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
const { candidates, page: cargoPage, size: cargoSize, total: cargoTotal } = candidatePage
const rows = ref<DispatchListRow[]>([]), page = ref(1), size = ref(10), total = ref(0)
const keyword = ref(''), queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
const selectedNo = ref(''), warehouseId = ref(''), warehouseName = ref('')
const detail = ref<DispatchDetail | null>(null), items = ref<DispatchItems | null>(null)
const reading = ref(false), listing = ref(false), preparing = ref(false), stale = ref(true)
const removing = ref<DispatchCargoItem | null>(null), reason = ref('')
let generation = 0, listGeneration = 0, alive = true
const cargoPending = computed(() => pending.value?.action === 'ADD_CARGO' || pending.value?.action === 'REMOVE_CARGO')
const pendingMessage = computed(() => !cargoPending.value ? '另一个排车操作尚待核对,请返回原操作入口处理。' : pending.value?.result ? '货物调整已完成,请刷新当前资料;不会重复提交。' : '原货物调整结果尚待核对,保留原版本和原请求内容。')
const frozen = computed(() => busy.value || preparing.value || Boolean(pending.value))
const loading = computed(() => reading.value || candidatePage.loading.value)
const editable = computed(() => !stale.value && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.taskType === 'DELIVERY')
const canAdd = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'ADD_CARGO', access.can))
const canRemove = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'REMOVE_CARGO', access.can))
const locked = computed(() => frozen.value || loading.value)
const equalVersion = (left: unknown, right: unknown) => String(requireVersion(left)).replace(/^0+(?=\d)/, '') === String(requireVersion(right)).replace(/^0+(?=\d)/, '')
function report(cause: unknown, fallback: string) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback)
}
async function loadList() {
const sequence = ++listGeneration
listing.value = true
try {
const result = await api.listDispatches({ page: page.value, size: size.value, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: queriedKeyword.value || undefined })
if (!alive || sequence !== listGeneration) return
if (result.page !== page.value || result.size !== size.value) throw new DispatchInputError('排车列表页码不一致,请重新查询')
rows.value = result.records; total.value = result.total
} catch (cause) { if (alive && sequence === listGeneration) { rows.value = []; report(cause, '排车列表读取失败') } }
finally { if (alive && sequence === listGeneration) listing.value = false }
}
async function searchDispatches() {
if (frozen.value) return
queriedKeyword.value = keyword.value.trim(); page.value = 1
await loadList()
}
async function changePage(value: number) { if (!frozen.value) { page.value = value; await loadList() } }
async function readFacts(no: string) {
const [current, cargo] = await Promise.all([api.getDispatch(no), api.getDispatchItems(no)])
if (current.dispatchNo !== no || cargo.dispatchNo !== no || !equalVersion(current.recordVersion, cargo.recordVersion) || current.stage !== cargo.stage || current.status !== cargo.status) {
throw new DispatchInputError('排车与货物清单已变化,请重新读取一致版本后操作')
}
return { current, cargo }
}
async function loadCandidates() {
if (!warehouseId.value || !detail.value || !canAdd.value || frozen.value) return
await candidatePage.load({ warehouseId: warehouseId.value, warehouseTab: 'ALL', orderNo: queriedCargoKeyword.value || undefined, page: cargoPage.value, size: cargoSize.value })
}
async function selectDispatch(no: string, row?: DispatchListRow) {
if (!no || busy.value || pending.value && (!cargoPending.value || pending.value.dispatchNo !== no)) return
const sequence = ++generation
selectedNo.value = no; reading.value = true; stale.value = true; error.value = null
detail.value = null; items.value = null; removing.value = null; reason.value = ''
warehouseId.value = ''; warehouseName.value = ''; candidatePage.reset()
try {
const origin = row || (await api.listDispatches({ page: 1, size: 10, dispatchNo: no, status: 'ALL' })).records.find(value => value.dispatchNo === no)
if (!alive || sequence !== generation) return
if (!origin || origin.dispatchNo !== no || !origin.warehouseId) throw new DispatchInputError('排车起运仓未完整返回,请重新读取')
const facts = await readFacts(no)
if (!alive || sequence !== generation) return
warehouseId.value = origin.warehouseId; warehouseName.value = origin.warehouseName || origin.warehouseCode || '—'
detail.value = facts.current; items.value = facts.cargo; stale.value = false
if (!pending.value) await loadCandidates()
} catch (cause) { if (alive && sequence === generation) report(cause, '排车货物读取失败') }
finally { if (alive && sequence === generation) reading.value = false }
}
async function searchCargo() {
if (locked.value) return
queriedCargoKeyword.value = cargoKeyword.value.trim(); cargoPage.value = 1
await loadCandidates()
}
async function changeCargoPage(value: number) { if (!locked.value) { cargoPage.value = value; await loadCandidates() } }
async function changeCargoSize(value: number) { if (!locked.value) { cargoSize.value = value; cargoPage.value = 1; await loadCandidates() } }
async function completeGroup(row: Candidate): Promise<Candidate[]> {
const group = requireSelectionGroup(row)
const found = new Map<string, Candidate>()
function collect(values: Candidate[]) {
for (const value of values) {
const source = group.sources.find(source => source.sourceKey === value.sourceKey)
if (!source) continue
if (value.warehouseId !== warehouseId.value || value.customerId !== source.customerId || value.sourceVersion !== source.selection.sourceVersion || selectionIdentity(value.selection) !== selectionIdentity(source.selection) || value.selectionGroup.groupKey !== group.groupKey || value.selectionGroup.groupVersion !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请刷新后重试')
requireSelectionGroup(value)
found.set(value.sourceKey, value)
}
}
collect(candidates.value)
// A filtered page may hide another source on this physical pallet, including its appointments.
for (let next = 1; found.size < group.sources.length; next++) {
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.value, warehouseTab: 'ALL', page: next, size: 100 })
if (result.page !== next || result.size !== 100) throw new DispatchInputError('货物来源页码不一致,请重新读取')
collect(result.candidates)
if (next >= result.pages || result.candidates.length === 0) break
}
if (found.size !== group.sources.length) throw new DispatchInputError('同托货物及预约资料未完整返回,请刷新后重试')
return group.sources.map(source => found.get(source.sourceKey)!)
}
function buildAddition(row: Candidate, sources: Candidate[]): AddCargo {
if (!canAdd.value || !detail.value || !candidates.value.includes(row)) throw new DispatchInputError('当前货物或排车不允许调整,请刷新核对')
candidatePage.assertCurrent(sources); requireBatchVersions(sources.flatMap(source => source.appointmentBatches))
if (sources.some(source => source.warehouseId !== warehouseId.value || source.blockReasons.length || !source.sourceVersion || source.sourceVersion !== source.selection.sourceVersion || source.appointmentBatches.some(batch => batch.customerId !== source.customerId))) throw new DispatchInputError('请选择同仓、来源完整且预约客户一致的货物')
return {
recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID(), cargo: expandSelectionGroups(sources),
batchAllocations: sources.flatMap(member => member.appointmentBatches.flatMap(batch => batch.sources.map(source => {
if (!member.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
return { planPalletId: member.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion }
}))),
}
}
async function addCargo(row: Candidate) {
if (locked.value) return
error.value = null; preparing.value = true
const sequence = generation
try {
const sources = await completeGroup(row)
if (!alive || sequence !== generation) return
recovery.begin(selectedNo.value, 'ADD_CARGO', buildAddition(row, sources)); preparing.value = false; await execute()
}
catch (cause) { report(cause, '货物未加入,请核对来源') }
finally { preparing.value = false }
}
function removable(row: DispatchCargoItem) {
return canRemove.value && row.remove.allowed === true && row.pickedPackages === 0 && !sharedPallet(row) && Boolean(row.dispatchItemId) && items.value?.items.includes(row)
}
function sharedPallet(row: DispatchCargoItem) { return Boolean(row.warehousePalletId && (items.value?.items.filter(item => item.warehousePalletId === row.warehousePalletId).length || 0) > 1) }
function chooseRemoval(row: DispatchCargoItem) {
if (locked.value || !removable(row)) return
removing.value = row; reason.value = ''
}
async function removeCargo() {
if (locked.value) return
error.value = null
try {
const row = removing.value
if (!row || !removable(row) || !reason.value.trim() || !items.value) throw new DispatchInputError('请选择允许移除的未扫码货物并填写原因')
const body: ReasonCommand = { recordVersion: requireVersion(items.value.recordVersion), idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() }
recovery.begin(selectedNo.value, 'REMOVE_CARGO', body, { itemId: row.dispatchItemId })
await execute()
} catch (cause) { report(cause, '货物未移除,请核对当前作业情况') }
}
function send(command: PendingDispatchCommand) {
if (!access.can('platform:overseas-warehouse:dispatch:edit')) throw new DispatchInputError('当前账号没有调整排车货物权限')
if (command.action === 'ADD_CARGO') return api.addDispatchCargo(command.dispatchNo, command.body as unknown as AddCargo)
if (command.action === 'REMOVE_CARGO' && command.itemId) return api.removeDispatchItem(command.dispatchNo, command.itemId, command.body as unknown as ReasonCommand)
throw new DispatchInputError('请回到原操作入口核对该请求')
}
async function execute() {
if (busy.value || !cargoPending.value) return
const no = pending.value!.dispatchNo
error.value = null
try {
const result = await recovery.execute(send, async target => {
const facts = await readFacts(target)
return facts.current
}, api.getDispatchCommand)
if (!result || !alive) return
ElMessage.success(result.replayed ? '原货物调整结果已核对' : '本车货物已更新,请重新核对审核')
emit('saved')
await selectDispatch(no)
await loadList()
} catch (cause) {
if (!alive) return
stale.value = true
report(cause, pending.value?.result ? '货物已调整,当前资料待刷新' : pending.value ? '操作结果尚未确认,请核对原请求' : '本次货物调整未通过,请刷新当前资料')
}
}
async function retry() { if (cargoPending.value) await execute(); else if (selectedNo.value) await selectDispatch(selectedNo.value); else await loadList() }
watch(() => [loading.value, listing.value, busy.value, preparing.value], () => emit('busy', loading.value || listing.value || busy.value || preparing.value), { immediate: true })
watch(() => props.dispatchNo, no => {
const target = cargoPending.value ? pending.value!.dispatchNo : no
if (target) void selectDispatch(target)
}, { immediate: true })
void loadList()
onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidatePage.reset(); emit('busy', false) })
</script>
<template>
<div class="existing-cargo density-dialog-sections">
<el-alert v-if="pending" :title="pendingMessage" type="warning" :closable="false" show-icon />
<el-button v-if="cargoPending" :loading="busy" @click="execute">{{ pending?.result ? '刷新当前资料' : '核对并重试原请求' }}</el-button>
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div class="existing-cargo-query">
<el-input v-model="keyword" aria-label="查找已有排车" placeholder="排车单号" :disabled="frozen || listing" @keyup.enter="searchDispatches" />
<el-button :disabled="frozen || listing" @click="searchDispatches">查询排车</el-button>
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="选择装车前排车" :disabled="frozen || listing" @update:model-value="value => selectDispatch(String(value), rows.find(row => row.dispatchNo === value))">
<el-option v-for="row in rows" :key="row.dispatchNo" :value="row.dispatchNo" :label="`${row.dispatchNo} · ${row.warehouseName || row.warehouseCode || '—'}`" :disabled="row.taskType !== 'DELIVERY' || row.status !== 'ACTIVE' || row.stage !== 'BEFORE_LOADING'" />
<el-option v-if="selectedNo && !rows.some(row => row.dispatchNo === selectedNo)" :value="selectedNo" :label="selectedNo" disabled />
</el-select>
<el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" />
</div>
<p v-if="!selectedNo" class="density-dialog-note">选择已有排车后,在这里统一增减本车计划货物。</p>
<template v-if="selectedNo">
<div class="existing-cargo-query"><strong>{{ selectedNo }} · {{ warehouseName || '—' }}</strong><el-input v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></div>
<p v-if="detail && !editable" class="density-dialog-note">仅有效、装车前的仓库派送排车允许调整计划货物。真实拣货仍在 PDA 完成。</p>
<p v-else-if="detail && !canAdd" class="density-dialog-note">{{ detail.actions.ADD_CARGO?.blockReason || '当前状态或权限不允许添加货物' }}</p>
<SmartDispatchCargoBoard :candidates="candidates" :draft="null" :active-container-id="selectedNo" :capacity="0" :disabled="locked || !canAdd" :warehouse-selected="Boolean(warehouseId)" :page="cargoPage" :size="cargoSize" :total="cargoTotal" @add="addCargo" @change-page="changeCargoPage" @change-size="changeCargoSize">
<template #vehicles="{ dragOver, drop, dropTarget }">
<section class="existing-cargo-manifest" :class="{ 'is-drop-target': dropTarget === selectedNo }" aria-label="当前排车货物" @dragover="dragOver($event, selectedNo)" @drop="drop($event, selectedNo)">
<h3>本车货物 · {{ selectedNo }}</h3>
<p class="density-dialog-note">加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p>
<p v-if="!items" class="density-dialog-note">请读取完整货物清单</p>
<p v-else-if="!items.items.length" class="density-dialog-note">本车暂无货物,可从左侧选择托盘加入。</p>
<div v-for="row in items?.items || []" :key="row.dispatchItemId" class="existing-cargo-item">
<div><strong>{{ row.palletNo || row.inboundCode || row.orderNo || row.relabelNo || '—' }}</strong><p>{{ row.customerName || '—' }} · {{ row.orderNo || row.inboundCode || row.relabelNo || '—' }}</p><p>{{ row.scheduledPackages }} 箱 · 真实已拣 {{ row.pickedPackages }} 箱 · {{ row.destination.warehouseCode || row.destination.address || '—' }}</p><small v-if="!removable(row)">{{ sharedPallet(row) ? '同一实物托盘包含多个来源,不能单独移除此项' : row.remove.blockReason || detail?.actions.REMOVE_CARGO?.blockReason || '当前状态或权限不允许移除' }}</small></div>
<el-button link type="danger" :disabled="locked || !removable(row)" @click="chooseRemoval(row)">移除货物</el-button>
</div>
<div v-if="removing" class="existing-cargo-removal" aria-label="确认移除本车货物"><strong>移除 {{ removing.palletNo || removing.orderNo || removing.inboundCode || '所选货物' }}</strong><el-input v-model="reason" aria-label="移除原因" placeholder="请填写移除原因(必填)" type="textarea" :disabled="locked" /><el-button type="danger" :disabled="locked || !reason.trim() || !removable(removing)" @click="removeCargo">确认移除</el-button><el-button :disabled="locked" @click="removing = null">取消移除</el-button></div>
</section>
</template>
</SmartDispatchCargoBoard>
</template>
</div>
</template>
<style scoped>
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.existing-cargo-query > .el-input { width: 240px; }
.existing-cargo-query > .el-select { width: 350px; }
.existing-cargo-manifest { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); padding: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
.existing-cargo-manifest.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
.existing-cargo-manifest h3 { margin: 0; font-size: var(--admin-font-section); }
.existing-cargo-item { display: flex; gap: var(--admin-space-3); justify-content: space-between; padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.existing-cargo-item > div { min-width: 0; }
.existing-cargo-item > .el-button { flex: none; }
.existing-cargo-item p { margin: var(--admin-space-2) 0; }
.existing-cargo-item small { color: var(--cc-muted); }
.existing-cargo-removal { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
</style>