feat(admin): 实现排车候选分页并修正报关批次参数
This commit is contained in:
@@ -16,6 +16,13 @@
|
||||
|
||||
本次仅调整列表顶部插槽位置与公共类,不改变详情、新增、编辑、审核、文件预览或取消等操作承载,不修改业务脚本、API 或权限。
|
||||
|
||||
## 2026-10-20 候选服务端分页
|
||||
|
||||
| 页面/操作 | 已确认承载与依据 | 本批实现 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 智能排车/选货分组 | 本文“智能排车在途预排与操作整理”矩阵;现行排车契约第17项的服务端分页;`SmartDispatchDialog.vue` 居中弹框与候选/装车组布局 | 候选表下显示总数、页码和10/20/50条选择;筛选与区域页签交给服务端,变化后回第一页;草稿保留跨页完整组 | 自动检查单独记录;1920×1080、100%待人工截图确认 |
|
||||
| 既有排车/加货 | `DispatchWorkflowDialog.vue` 的既有1120px居中工作流弹框;同一候选分页契约 | 候选表下分页并显示已选完整组数;跨页保留选择,提交继续检查组版本和批次原始版本 | 自动检查单独记录;1920×1080、100%待人工截图确认 |
|
||||
|
||||
## 2026-10-20 智能排车紧凑布局复核
|
||||
|
||||
### 后续截图:标签、标题与底部操作区对齐
|
||||
|
||||
@@ -65,10 +65,10 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1', '/platform/v1/last-leg/dispatches/PC%2F1/loading', '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1'])
|
||||
})
|
||||
it('reads candidates with their exact sourceVersion and repeated plan query parameters', async () => {
|
||||
const data = { candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
|
||||
const data = { total: 1, page: 1, size: 20, pages: 1, candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data })
|
||||
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'] })).toEqual(data)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'] }, paramsSerializer: { indexes: null } })
|
||||
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' })).toEqual(data)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' }, paramsSerializer: { indexes: null } })
|
||||
})
|
||||
it('creates an empty real dispatch without an artificial customer or cargo', async () => {
|
||||
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
|
||||
|
||||
@@ -54,7 +54,7 @@ beforeEach(() => {
|
||||
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({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
|
||||
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' }))
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi
|
||||
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({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||
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)
|
||||
@@ -25,6 +25,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo
|
||||
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] }))
|
||||
@@ -32,7 +33,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo
|
||||
})
|
||||
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
|
||||
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||
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)
|
||||
@@ -43,7 +44,7 @@ it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s
|
||||
})
|
||||
it('blocks cargo addition when the authoritative batch version is absent', async () => {
|
||||
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
|
||||
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
|
||||
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()
|
||||
@@ -234,3 +235,30 @@ it('describes a proven success with a failed GET as completed and retries only i
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
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')
|
||||
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 }))
|
||||
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.listDispatchCandidates).not.toHaveBeenCalled()
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ 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'
|
||||
@@ -30,11 +31,16 @@ 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 detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
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 candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
|
||||
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 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 } })
|
||||
@@ -50,9 +56,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) { loading.value = false; return }
|
||||
loading.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.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 = ''
|
||||
try {
|
||||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||||
const value = await api.getDispatch(no)
|
||||
@@ -97,20 +104,32 @@ async function load() {
|
||||
}
|
||||
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) loading.value = false }
|
||||
finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
async function loadCandidates(sequence = generation) {
|
||||
// Detail has no warehouse field: resolve the authoritative list row by this business number.
|
||||
const page = await api.listDispatches({ page: 1, size: 1, dispatchNo: currentNo.value, status: 'ACTIVE' })
|
||||
const row = page.records.find(item => item.dispatchNo === currentNo.value)
|
||||
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
|
||||
const result = await api.listDispatchCandidates({ warehouseId: row.warehouseId, orderNo: candidateKeyword.value || undefined })
|
||||
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
|
||||
if (result.complete !== true) throw new DispatchInputError('候选来源未完整返回,请缩小范围重新查询')
|
||||
if (result.candidates.some(source => source.warehouseId !== row.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion)) throw new DispatchInputError('货物起运仓或来源版本不完整,请重新读取')
|
||||
candidates.value = result.candidates
|
||||
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; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
||||
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('批次分配缺少真实计划来源,请核对')
|
||||
@@ -122,7 +141,7 @@ function selectCargo(row: Candidate, checked: unknown) {
|
||||
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 })
|
||||
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 {
|
||||
@@ -142,12 +161,13 @@ function payload(): object {
|
||||
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 = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
|
||||
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.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
|
||||
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':
|
||||
@@ -221,7 +241,7 @@ async function submit() {
|
||||
}
|
||||
async function refreshFacts() {
|
||||
const sequence = ++generation, no = currentNo.value
|
||||
loading.value = true; factsStale.value = true
|
||||
working.value = true; factsStale.value = true
|
||||
try {
|
||||
const value = await api.getDispatch(no)
|
||||
if (sequence !== generation) return
|
||||
@@ -238,11 +258,11 @@ async function refreshFacts() {
|
||||
} catch (cause) {
|
||||
// Retain the original rejection and every editor value if its follow-up read also fails.
|
||||
if (sequence === generation && !error.value) error.value = getApiErrorDetails(cause, '当前操作资格读取失败,请重试刷新')
|
||||
} finally { if (sequence === generation) loading.value = false }
|
||||
} finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
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++ }, { immediate: true })
|
||||
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++; candidatePage.reset() } }, { 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">
|
||||
@@ -273,6 +293,8 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<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 />
|
||||
|
||||
@@ -37,7 +37,7 @@ beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: draft().sources, complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
|
||||
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
|
||||
@@ -69,7 +69,7 @@ describe('new dispatch native layout', () => {
|
||||
const planned = candidate({ selection: { ...candidate().selection, warehousePalletId: null } })
|
||||
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
|
||||
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue(grouped)
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [planned], complete: true, snapshotAt: '' })
|
||||
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() === '开始选货')!
|
||||
@@ -192,16 +192,16 @@ describe('new dispatch native layout', () => {
|
||||
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
|
||||
})
|
||||
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
|
||||
const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
|
||||
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({ candidates: rows, complete: true, snapshotAt: '' })
|
||||
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(25)
|
||||
expect(table.text()).toContain('PALLET-24')
|
||||
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(50)
|
||||
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)
|
||||
@@ -216,11 +216,12 @@ describe('new dispatch native layout', () => {
|
||||
await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8')
|
||||
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises()
|
||||
await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
|
||||
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
|
||||
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
|
||||
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
|
||||
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls + 1)
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC', orderNo: 'IN-QUERY', sourceStage }))
|
||||
})
|
||||
it('shows loading and initialization diagnostics without issuing create commands', async () => {
|
||||
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
|
||||
|
||||
@@ -20,7 +20,7 @@ const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 's
|
||||
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]
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [first!], complete: true, snapshotAt: '' })
|
||||
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original)
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
@@ -41,7 +41,7 @@ it('allows one atomic removal of a restored group that is no longer selectable',
|
||||
expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount()
|
||||
})
|
||||
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
|
||||
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
|
||||
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
|
||||
const original = draft()
|
||||
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
|
||||
@@ -96,9 +96,9 @@ it('does not discard draft selections when recovery fails and never submits an i
|
||||
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], 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()).toContain('尚未完整返回'); wrapper.unmount()
|
||||
it('accepts a partial candidate page without requiring the filtered whole set', async () => {
|
||||
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 () => {
|
||||
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' }))
|
||||
@@ -128,3 +128,22 @@ it('rejects a restored source whose batch customer differs from its real cargo o
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }; state.restoreDraft('server-token'); await flushPromises(); await state.submit()
|
||||
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约客户不一致'); wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps server draft cargo across pages, resets filters and sends regional tabs to the server', async () => {
|
||||
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.page === 2 ? [] : [source()], complete: false, snapshotAt: '', total: 21, page: query.page!, size: query.size!, pages: Math.ceil(21 / query.size!) }))
|
||||
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
|
||||
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
|
||||
try {
|
||||
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; changePage(page: number): void; changeTab(tab: string): void; changeSize(size: number): void; query: { orderNo: string }; search(): Promise<void>; submit(): Promise<void> }
|
||||
state.restoreDraft('server-token'); await flushPromises(); state.changePage(2); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20 }))
|
||||
state.query.orderNo = 'query'; await state.search()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'query' }))
|
||||
state.changeTab('NYC'); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC' }))
|
||||
state.changeSize(10); await flushPromises()
|
||||
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10 }))
|
||||
state.changePage(2); await flushPromises(); await state.submit()
|
||||
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ cargo: [source().selection] }) })] }))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
@@ -15,13 +15,14 @@ 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, assertSelectionGroupVersions, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
|
||||
import { dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||||
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
|
||||
@@ -33,10 +34,12 @@ const pending = ref<DraftCommand | null>(null)
|
||||
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
|
||||
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 loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), candidates = ref<Candidate[]>([])
|
||||
const working = ref(false), saving = 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 visibleCandidates = computed(() => candidates.value.filter(row => matchesSmartDispatchWarehouseTab(row, warehouseTab.value)))
|
||||
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
|
||||
const currentSettings = computed(() => {
|
||||
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
|
||||
@@ -60,16 +63,20 @@ async function startCargo() {
|
||||
if (!draft.value) await draftAction('CREATE')
|
||||
if (draft.value && !error.value && !pending.value && !draft.value.containers.length) await draftAction('CONTAINER')
|
||||
}
|
||||
function changeWarehouse() { candidates.value = []; warehouseTab.value = 'ALL'; void search() }
|
||||
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
|
||||
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() }
|
||||
function changePage(value: number) { page.value = value; void loadCandidates() }
|
||||
function changeSize(value: number) { size.value = value; void search() }
|
||||
|
||||
let generation = 0
|
||||
let candidateFilterKey = ''
|
||||
function persist() {
|
||||
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
|
||||
if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey)
|
||||
}
|
||||
async function init() {
|
||||
const sequence = ++generation
|
||||
loading.value = true; error.value = null
|
||||
working.value = true; error.value = null
|
||||
try {
|
||||
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()])
|
||||
@@ -83,20 +90,18 @@ async function init() {
|
||||
}
|
||||
if (warehouseId.value) await loadCandidates(sequence)
|
||||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
|
||||
finally { if (sequence === generation) loading.value = false }
|
||||
finally { if (sequence === generation) working.value = false }
|
||||
}
|
||||
async function loadCandidates(sequence = generation) {
|
||||
if (!warehouseId.value) return
|
||||
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.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 })
|
||||
if (sequence !== generation) return
|
||||
if (result.complete !== true) throw new DispatchInputError('候选来源尚未完整返回,请缩小筛选范围重试')
|
||||
if (result.candidates.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
|
||||
candidates.value = result.candidates
|
||||
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 })
|
||||
}
|
||||
async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
|
||||
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 {
|
||||
assertSelectionGroupVersions(cargo, candidates.value)
|
||||
candidatePage.assertCurrent(cargo)
|
||||
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
|
||||
@@ -115,7 +120,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
|
||||
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
|
||||
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
|
||||
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
|
||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') requireSelectionGroup(source); body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
|
||||
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
|
||||
else if (action === 'RELEASE') body = common
|
||||
else {
|
||||
@@ -178,7 +183,7 @@ async function submit() {
|
||||
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单结果尚未核对,请使用原请求重试') }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
|
||||
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">
|
||||
@@ -209,8 +214,8 @@ watch(() => props.modelValue, value => { if (value) void init(); else generation
|
||||
<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="warehouseTab = tab.value">{{ tab.label }}</button></div>
|
||||
<el-table :data="visibleCandidates" border :empty-text="!warehouseId ? '请先选择起运仓' : '暂无符合条件的可排货物;在途货物请核对离港节点、入库计划和目的地'">
|
||||
<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>
|
||||
@@ -219,6 +224,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else generation
|
||||
<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>
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface SelectionGroupSource { selection: CargoSelection; sourceKey: st
|
||||
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
|
||||
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
|
||||
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
|
||||
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
|
||||
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string; page?: number; size?: number; warehouseTab?: 'ALL' | 'LAX' | 'NYC' }
|
||||
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
|
||||
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
|
||||
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
|
||||
@@ -54,7 +54,7 @@ export interface DispatchRoute { dispatchNo: string; recordVersion: Version; sto
|
||||
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
|
||||
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
|
||||
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean }
|
||||
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
|
||||
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
|
||||
export interface DispatchDraft { draftToken: string; recordVersion: Version; status: string; warehouseId: string; containers: DraftContainer[]; sources: Candidate[]; expiresAt: string }
|
||||
export interface DraftSelection extends VersionCommand { draftToken: string; draftContainerId: string; source: CargoSelection }
|
||||
|
||||
@@ -35,9 +35,9 @@ it('rejects changed group composition and conflicting source versions before cre
|
||||
it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => {
|
||||
const row = sharedPalletCandidates()[0]!, id = '9007199254740993'
|
||||
const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } }
|
||||
const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
|
||||
const result = normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
|
||||
expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8)
|
||||
expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id)
|
||||
value.selectionGroup.sources[0]!.packages = '9007199254740993'
|
||||
expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
||||
expect(() => normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
|
||||
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
|
||||
const value = { ...source(), appointmentBatches: [reference] }
|
||||
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
expect(wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [value] })).sources[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
const existing = detail()
|
||||
expect(wire.normalizeDispatchDetail(parsed({ ...existing, dispatchInfo: { ...existing.dispatchInfo, appointmentBatches: [reference] } })).dispatchInfo.appointmentBatches[0]!.recordVersion).toBe(version)
|
||||
@@ -38,11 +38,11 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
it.each([undefined, null, -1, '', 'bad'])('rejects missing or invalid authoritative candidate batch version %s', version => {
|
||||
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [] }
|
||||
const value = { ...source(), appointmentBatches: [reference] } as unknown as wire.CandidateWire
|
||||
expect(() => wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
|
||||
expect(() => wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
|
||||
})
|
||||
it('preserves a raw large JSON integer that the shared parser safely converts into a version string', () => {
|
||||
const value = { ...source(), appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: 9007199254740992, sources: [] }] }
|
||||
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
|
||||
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
|
||||
})
|
||||
it.each([undefined, null, true, false, '', ' ', ' 1', '1 ', '01', '+1', '-1', '1.0', '1e2', -1, 1.5, NaN, Infinity, '9007199254740992', 9007199254740992])('rejects invalid or unsafe quantity %s instead of silently changing it', value => {
|
||||
expect(() => wire.dispatchQuantity(value, '箱数')).toThrow('排车响应')
|
||||
@@ -63,7 +63,7 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
expect(result.documents.outbounds[0]).toMatchObject({ customerId: id, packageCount: 12, palletCount: 2 }); expect(result.documents.deliveries[0]!.packageCount).toBe(12)
|
||||
})
|
||||
it('normalizes candidates and draft sources and keeps source versions and container IDs', () => {
|
||||
const candidates = wire.normalizeCandidateList(parsed({ candidates: [source()], snapshotAt: '', complete: true }))
|
||||
const candidates = wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], snapshotAt: '', complete: true }))
|
||||
expect(candidates.candidates[0]).toMatchObject({ availablePackages: 12, customerId: id, sourceVersion: 'source-hash' })
|
||||
const result = wire.normalizeDispatchDraft(parsed(draft()))
|
||||
expect(result.sources[0]!.availablePackages).toBe(12); expect(result.recordVersion).toBe(id); expect(result.containers[0]).toMatchObject({ draftContainerId: id, sequenceNo: 1, capacity: 30 })
|
||||
@@ -100,3 +100,12 @@ describe('native dispatch Long quantity boundary', () => {
|
||||
expect(() => wire.normalizeDispatchCommand({ ...command('EDIT'), counts: { ...counts, pickedPackageCount: undefined } } as unknown as wire.CommandResultWire)).toThrow('有效非负整数')
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes paginated long totals while keeping whole-group and page completeness independent', () => {
|
||||
const result = wire.normalizeCandidateList(parsed({ candidates: [source()], complete: false, snapshotAt: '', total: '41', pages: '3', page: 2, size: 20 }))
|
||||
expect(result).toMatchObject({ total: 41, pages: 3, page: 2, size: 20, complete: false })
|
||||
expect(result.candidates[0]!.selectionGroup.complete).toBe(true)
|
||||
})
|
||||
it.each([{ total: '9007199254740993' }, { pages: '9007199254740993' }, { total: undefined }, { size: 51 }, { page: 0 }, { pages: '2' }])('fails closed for invalid pagination metadata %j', overrides => {
|
||||
expect(() => wire.normalizeCandidateList(parsed({ candidates: [], complete: false, snapshotAt: '', total: '21', pages: '3', page: 2, size: 10, ...overrides } as unknown as wire.CandidateListWire))).toThrow(/候选分页信息无效|排车响应/)
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' |
|
||||
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
|
||||
type SelectionGroupWire = Omit<SelectionGroup, 'sources' | 'pallets'> & { sources: Array<LongFields<SelectionGroup['sources'][number], 'packages'>>; pallets: Array<LongFields<SelectionGroup['pallets'][number], 'packageCount'>> }
|
||||
export type CandidateWire = Omit<LongFields<Candidate, 'availablePackages'>, 'selectionGroup'> & { selectionGroup: SelectionGroupWire }
|
||||
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
|
||||
export type CandidateListWire = Omit<LongFields<CandidateList, 'total' | 'pages'>, 'candidates'> & { candidates: CandidateWire[] }
|
||||
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
|
||||
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
|
||||
@@ -60,7 +60,11 @@ function statistics(value: StatisticsWire): DispatchStatistics {
|
||||
}
|
||||
export function normalizeDispatchPage(value: DispatchPageWire): DispatchPage { return { ...value, total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages'), records: value.records.map(row => ({ ...row, counts: counts(row.counts) })), statistics: statistics(value.statistics) } }
|
||||
export function normalizeDispatchDetail(value: DispatchDetailWire): DispatchDetail { requireBatchVersions(value.dispatchInfo.appointmentBatches); return { ...value, counts: counts(value.counts), documents: { ...value.documents, ...references(value.documents) } } }
|
||||
export function normalizeCandidateList(value: CandidateListWire): CandidateList { return { ...value, candidates: value.candidates.map(candidate) } }
|
||||
export function normalizeCandidateList(value: CandidateListWire): CandidateList {
|
||||
const result = { ...value, candidates: value.candidates.map(candidate), total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages') }
|
||||
if (result.page < 1 || result.size < 1 || result.size > 50 || result.pages !== Math.ceil(result.total / result.size) || result.candidates.length > result.size) throw new DispatchInputError('候选分页信息无效,请重新读取')
|
||||
return result
|
||||
}
|
||||
export function normalizeDispatchDraft(value: DispatchDraftWire): DispatchDraft { return { ...value, sources: value.sources.map(candidate) } }
|
||||
export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems { return { ...value, items: value.items.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
|
||||
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
|
||||
|
||||
@@ -13,5 +13,6 @@ export { DispatchInputError, requireVersion, requireBatchVersions, requireSelect
|
||||
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
|
||||
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
|
||||
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
|
||||
export { useDispatchCandidates } from './useDispatchCandidates'
|
||||
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
|
||||
export type { ContainerPlanProgressDetailDto, ContainerInboundPlanPreviewDto, ConvertContainerInboundPlanPayload } from './api/lastMileOrders'
|
||||
|
||||
@@ -257,9 +257,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
|
||||
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
|
||||
const dialog = source('../components/SmartDispatchDialog.vue')
|
||||
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
|
||||
// A filtered candidate must still submit its complete server-owned selection group.
|
||||
expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)')
|
||||
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
|
||||
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
|
||||
expect(dialog).toContain('同仓可混装多个客户与目的仓')
|
||||
expect(dialog).not.toContain('一次排车只能装同一客户')
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listDispatchCandidates } from './api/dispatches'
|
||||
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
|
||||
import type { CandidateList } from './dispatchContracts'
|
||||
import { useDispatchCandidates } from './useDispatchCandidates'
|
||||
|
||||
vi.mock('./api/dispatches', () => ({ listDispatchCandidates: vi.fn() }))
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
const response = (overrides: Partial<CandidateList> = {}): CandidateList => ({ candidates: [candidate()], snapshotAt: '', complete: false, page: 1, size: 20, total: 41, pages: 3, ...overrides })
|
||||
function deferred() {
|
||||
let resolve!: (value: CandidateList) => void, reject!: (error: Error) => void
|
||||
const promise = new Promise<CandidateList>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function setup() {
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
return { ...useDispatchCandidates(error), error }
|
||||
}
|
||||
it('deduplicates concurrent identical requests and accepts complete groups on a partial page', async () => {
|
||||
const pending = deferred(), state = setup()
|
||||
vi.mocked(listDispatchCandidates).mockReturnValue(pending.promise)
|
||||
const first = state.load({ warehouseId: '321', page: 2, warehouseTab: 'LAX', orderNo: 'LM' })
|
||||
const second = state.load({ warehouseId: '321', page: 2, warehouseTab: 'LAX', orderNo: 'LM' })
|
||||
expect(first).toBe(second); expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(response({ candidates: [sharedPalletCandidates()[0]!], page: 2 }))
|
||||
await first
|
||||
expect(state.total.value).toBe(41); expect(state.page.value).toBe(2)
|
||||
expect(state.candidates.value[0]!.selectionGroup.sources).toHaveLength(2)
|
||||
expect(() => state.assertCurrent(state.candidates.value)).not.toThrow()
|
||||
})
|
||||
it.each(['success', 'failure'])('ignores stale %s while the latest page is still loading', async outcome => {
|
||||
const old = deferred(), latest = deferred(), state = setup()
|
||||
vi.mocked(listDispatchCandidates).mockReturnValueOnce(old.promise).mockReturnValueOnce(latest.promise)
|
||||
const first = state.load({ warehouseId: '321', page: 1 })
|
||||
const second = state.load({ warehouseId: '321', page: 2 })
|
||||
if (outcome === 'success') old.resolve(response()); else old.reject(new Error('stale failure'))
|
||||
await first
|
||||
expect(state.loading.value).toBe(true); expect(state.error.value).toBeNull(); expect(state.candidates.value).toEqual([])
|
||||
latest.resolve(response({ page: 2 })); await second
|
||||
expect(state.page.value).toBe(2); expect(state.loading.value).toBe(false)
|
||||
})
|
||||
it('ignores an old response after a new warehouse succeeds and invalidates requests on close', async () => {
|
||||
const old = deferred(), state = setup(), other = candidate({ warehouseId: 'other' })
|
||||
vi.mocked(listDispatchCandidates).mockReturnValueOnce(old.promise).mockResolvedValueOnce(response({ candidates: [other] }))
|
||||
const first = state.load({ warehouseId: '321' })
|
||||
await state.load({ warehouseId: 'other' })
|
||||
old.resolve(response()); await first
|
||||
expect(state.candidates.value[0]!.warehouseId).toBe('other')
|
||||
expect(() => state.assertCurrent([candidate()])).toThrow('组成已变化')
|
||||
const closing = deferred()
|
||||
vi.mocked(listDispatchCandidates).mockReturnValueOnce(closing.promise)
|
||||
const request = state.load({ warehouseId: 'other', page: 2 })
|
||||
state.reset(); closing.resolve(response({ page: 2, candidates: [other] })); await request
|
||||
expect(state.candidates.value).toEqual([]); expect(state.total.value).toBe(0); expect(state.loading.value).toBe(false)
|
||||
})
|
||||
it('remembers a group or source version change even after navigating to an unrelated page', async () => {
|
||||
const state = setup(), selected = candidate()
|
||||
const changed = candidate({ selectionGroup: { ...selected.selectionGroup, groupVersion: 'changed' } })
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response()).mockResolvedValueOnce(response({ page: 2, candidates: [changed] })).mockResolvedValueOnce(response({ page: 3, candidates: [] }))
|
||||
await state.load({ warehouseId: '321' }); await state.load({ warehouseId: '321', page: 2 }); await state.load({ warehouseId: '321', page: 3 })
|
||||
expect(() => state.assertCurrent([selected])).toThrow('组成已变化')
|
||||
expect(() => state.assertCurrent([changed])).not.toThrow()
|
||||
})
|
||||
it.each(['warehouse', 'page', 'version'])('rejects a mismatched %s response instead of exposing selectable stale rows', async mismatch => {
|
||||
const state = setup(), row = candidate()
|
||||
if (mismatch === 'warehouse') row.warehouseId = 'other'
|
||||
if (mismatch === 'version') row.sourceVersion = 'changed'
|
||||
vi.mocked(listDispatchCandidates).mockResolvedValue(response({ candidates: [row], page: mismatch === 'page' ? 2 : 1 }))
|
||||
await state.load({ warehouseId: '321' })
|
||||
expect(state.candidates.value).toEqual([]); expect(state.error.value).not.toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listDispatchCandidates } from './api/dispatches'
|
||||
import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type CandidateQuery } from './dispatchContracts'
|
||||
|
||||
/** One visible server page; selected cargo remains owned by the caller or server draft. */
|
||||
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) {
|
||||
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
|
||||
let generation = 0, warehouse = ''
|
||||
let pending: { key: string; promise: Promise<void> } | undefined
|
||||
const groupVersions = new Map<string, string>()
|
||||
const sourceGroups = new Map<string, { groupKey: string; sourceVersion: string }>()
|
||||
|
||||
function reset() {
|
||||
generation++; pending = undefined; warehouse = ''
|
||||
candidates.value = []; page.value = 1; total.value = 0; loading.value = false
|
||||
groupVersions.clear(); sourceGroups.clear()
|
||||
}
|
||||
function assertCurrent(rows: Candidate[]) {
|
||||
assertSelectionGroupVersions(rows, candidates.value)
|
||||
for (const row of rows) {
|
||||
const group = row.selectionGroup
|
||||
if (row.warehouseId !== warehouse || (groupVersions.has(group.groupKey) && groupVersions.get(group.groupKey) !== group.groupVersion)
|
||||
|| group.sources.some(source => {
|
||||
const latest = sourceGroups.get(source.sourceKey)
|
||||
return latest && (latest.groupKey !== group.groupKey || latest.sourceVersion !== source.selection.sourceVersion)
|
||||
})) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
|
||||
}
|
||||
}
|
||||
function load(query: CandidateQuery): Promise<void> {
|
||||
if (!query.warehouseId) { reset(); return Promise.resolve() }
|
||||
if (warehouse !== query.warehouseId) { reset(); warehouse = query.warehouseId }
|
||||
const request = { ...query, page: query.page ?? page.value, size: query.size ?? size.value, warehouseTab: query.warehouseTab ?? 'ALL' }
|
||||
const key = JSON.stringify(request)
|
||||
if (pending?.key === key) return pending.promise
|
||||
const sequence = ++generation
|
||||
// Preserve the last total while loading: zero would make the pager emit page 1.
|
||||
loading.value = true; error.value = null; candidates.value = []
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fetchCandidates(request)
|
||||
if (sequence !== generation) return
|
||||
if (result.page !== request.page || result.size !== request.size) throw new DispatchInputError('候选响应页码不一致,请重新读取')
|
||||
if (result.candidates.some(row => row.warehouseId !== request.warehouseId || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
|
||||
for (const row of result.candidates) {
|
||||
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
|
||||
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
|
||||
}
|
||||
candidates.value = result.candidates; page.value = result.page; size.value = result.size; total.value = result.total
|
||||
} catch (cause) {
|
||||
if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
|
||||
} finally {
|
||||
if (sequence === generation) { loading.value = false; pending = undefined }
|
||||
}
|
||||
})()
|
||||
pending = { key, promise }
|
||||
return promise
|
||||
}
|
||||
return { candidates, page, size, total, loading, load, reset, assertCurrent }
|
||||
}
|
||||
Reference in New Issue
Block a user