feat(admin): 实现排车候选分页并修正报关批次参数

This commit is contained in:
2026-10-21 07:19:57 +08:00
parent 1c771066ea
commit 2f9a3330e5
21 changed files with 411 additions and 102 deletions
@@ -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 />