305 lines
36 KiB
Vue
305 lines
36 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useAuthStore } from '@/core/auth/auth'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import * as api from '../api/dispatches'
|
||
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
|
||
import { canDispatchAction, dispatchActionLabels } from '../dispatchPresentation'
|
||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||
import DispatchResourceFields from './DispatchResourceFields.vue'
|
||
import DispatchArrangeFields from './DispatchArrangeFields.vue'
|
||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||
|
||
const open = defineModel<boolean>({ default: false })
|
||
const props = defineProps<{ dispatchNo: string; action: DispatchAction; operation?: 'ROUTE' }>()
|
||
const emit = defineEmits<{ saved: []; rejected: [details: ApiErrorDetails] }>()
|
||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||
const { pending, busy } = recovery
|
||
const currentAction = computed(() => pending.value?.action || props.action)
|
||
const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
|
||
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
|
||
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||
const detail = ref<DispatchDetail | null>(null), loading = 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 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 } })
|
||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||
let generation = 0
|
||
let editorRequestKey = ''
|
||
const allowed = computed(() => !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||
const width = computed(() => ['EDIT', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value) ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value) ? '760px' : '560px')
|
||
const frozen = computed(() => busy.value || Boolean(pending.value))
|
||
const evidenceBusy = reactive({ VEHICLE: false, DRIVER: false })
|
||
const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DRIVER)
|
||
|
||
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 = []
|
||
items.value = null; itemId.value = ''; route.value = null; stops.value = []
|
||
try {
|
||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||
const value = await api.getDispatch(no)
|
||
if (sequence !== generation) return
|
||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||
detail.value = value
|
||
const oldVehicle = value.vehicleInfo.vehicle
|
||
vehicle.value = oldVehicle ? { mode: oldVehicle.mode, vehicleId: oldVehicle.vehicleId, plate: oldVehicle.plate, carrierName: oldVehicle.carrierName, capacity: oldVehicle.capacity ? { ...oldVehicle.capacity } : null, evidenceFileIds: [...oldVehicle.evidenceFileIds] } : blankVehicle()
|
||
driver.value = value.vehicleInfo.driver ? structuredClone(value.vehicleInfo.driver) : blankDriver()
|
||
if (!driver.value.evidence) driver.value.evidence = blankDriver().evidence
|
||
Object.assign(form, { plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
|
||
if (currentOperation.value === 'ROUTE') {
|
||
const data = await api.getDispatchRoute(no)
|
||
if (sequence !== generation) return
|
||
if (data.dispatchNo !== no || !data.stops.length || data.stops[0]?.index !== 0) throw new DispatchInputError('本车真实起点及路线未完整返回')
|
||
route.value = data; stops.value = data.stops.slice(1).map(stop => ({ ...stop }))
|
||
}
|
||
if (action === 'REMOVE_CARGO') {
|
||
const data = await api.getDispatchItems(no)
|
||
if (sequence !== generation) return
|
||
if (data.dispatchNo !== no) throw new DispatchInputError('移货清单与原排车单不一致')
|
||
items.value = data
|
||
}
|
||
if (action === 'LOADING_COMPLETE') {
|
||
const data = await api.getDispatchLoading(no)
|
||
if (sequence !== generation) return
|
||
if (data.dispatchNo !== no) throw new DispatchInputError('实载预览与排车单不一致')
|
||
preview.value = data
|
||
measurements.value = data.pallets.map(row => {
|
||
const confirmed = data.confirmedLoad?.manifestHash === data.manifestHash ? data.confirmedLoad.pallets.find(item => item.warehousePalletId === row.warehousePalletId) : null
|
||
const actual = (field: string) => row.evidence.some(item => item.field === field && item.actual === true)
|
||
return { warehousePalletId: row.warehousePalletId, palletNo: row.palletNo, palletSpaces: row.palletSpaces, deliveryIndex: row.deliveryIndex,
|
||
weightKg: confirmed?.weightKg ?? (actual('weightKg') ? row.weightKg : null), volumeCbm: confirmed?.volumeCbm ?? (actual('volumeCbm') ? row.volumeCbm : null) }
|
||
})
|
||
}
|
||
if (action === 'ARRANGE_DELIVERY') {
|
||
const data = await api.getDispatchArrangement(no)
|
||
if (sequence !== generation) return
|
||
if (data.dispatchNo !== no) throw new DispatchInputError('派送预览与排车单不一致')
|
||
arrangement.value = data
|
||
groups.value = data.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: '', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: ({ DELIVERY_FEE: '派送费', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费' })[feeType] || '服务费用', direction: 'RECEIVABLE', currency: 'USD', amount: null })) }))
|
||
}
|
||
if (action === 'ADD_CARGO') await loadCandidates(sequence)
|
||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
|
||
finally { if (sequence === generation) loading.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 (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
|
||
}
|
||
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 } }
|
||
|
||
function requirePlanPallet(row: Candidate): string {
|
||
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
|
||
return row.selection.planPalletId
|
||
}
|
||
function isCargoSelected(row: Candidate): boolean { return selected.value.some(value => value.groupKey === row.selectionGroup?.groupKey) }
|
||
function selectCargo(row: Candidate, checked: unknown) {
|
||
try {
|
||
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
|
||
const group = requireSelectionGroup(row)
|
||
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
|
||
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion })
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
|
||
}
|
||
function payload(): object {
|
||
if (!detail.value || !allowed.value) throw new DispatchInputError(detail.value?.actions[currentAction.value]?.blockReason || '当前操作未获得服务端允许,请刷新核对')
|
||
const common = { recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID() }
|
||
if (currentOperation.value === 'ROUTE') {
|
||
const data = route.value, keys = stops.value.map(stop => stop.destinationKey)
|
||
if (!data || data.edit.allowed !== true || !form.reason.trim() || !keys.length || keys.some(key => !key) || new Set(keys).size !== keys.length || keys.length !== data.stops.length - 1 || data.stops.slice(1).some(stop => !keys.includes(stop.destinationKey))) throw new DispatchInputError('请完整保留本车既有卸货点并填写调整原因')
|
||
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, destinationKeys: keys as string[], reason: form.reason.trim() } satisfies DispatchRouteCommand
|
||
}
|
||
switch (currentAction.value) {
|
||
case 'REMOVE_CARGO': {
|
||
const row = items.value?.items.find(value => value.dispatchItemId === itemId.value)
|
||
if (!row || row.remove.allowed !== true || row.pickedPackages !== 0 || !form.reason.trim()) throw new DispatchInputError(row?.remove.blockReason || '请选择允许移除的未扫码货物并填写原因')
|
||
return { recordVersion: requireVersion(items.value!.recordVersion), idempotencyKey: common.idempotencyKey, reason: form.reason.trim() } satisfies ReasonCommand
|
||
}
|
||
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||
case 'ADD_CARGO': {
|
||
const chosen = selected.value.map(value => {
|
||
const row = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
|
||
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
|
||
return row
|
||
})
|
||
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('请选择来源完整、预约客户一致且可排的货物')
|
||
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
|
||
}
|
||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
|
||
case 'CANCEL': case 'RETURN_TO_REVIEW':
|
||
if (!form.reason.trim()) throw new DispatchInputError('请填写操作原因')
|
||
return { ...common, reason: form.reason.trim() } satisfies ReasonCommand
|
||
case 'LOADING_COMPLETE': {
|
||
if (!preview.value || preview.value.pickingReconciled !== true || preview.value.loadingComplete.allowed !== true) throw new DispatchInputError(preview.value?.loadingComplete.blockReason || 'PDA真实拣货尚未核平')
|
||
if (!measurements.value.length || measurements.value.some(row => row.weightKg == null || row.volumeCbm == null || row.palletSpaces == null || row.deliveryIndex == null || row.weightKg <= 0 || row.volumeCbm <= 0 || row.palletSpaces <= 0 || !Number.isInteger(row.palletSpaces) || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 0)) throw new DispatchInputError('请核对每托真实托位、重量、体积和卸货站;不能以参考值代替实测')
|
||
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, measurements: measurements.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces!, weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! })), remark: form.remark || null } satisfies LoadingComplete
|
||
}
|
||
case 'ARRANGE_DELIVERY': {
|
||
const data = arrangement.value
|
||
if (!data || data.arrange.allowed !== true || !data.groups.length || data.groups.some(row => row.blockReasons.length)) throw new DispatchInputError(data?.arrange.blockReason || '本车派送资料未就绪')
|
||
const prepared = groups.value.map((group, index) => {
|
||
if (!group.plannedDeliveryAt || !group.deliveryMethod || !group.pickupMethod || !group.loadingType) throw new DispatchInputError('请完整填写每个客户及目的仓的派送信息')
|
||
if (group.appointmentAccount === 'BUY_APPOINTMENT' && !group.sellerName?.trim()) throw new DispatchInputError('买约时卖家姓名必填')
|
||
const required = [...new Set([...(data.groups[index]?.requiredServices || []), ...(['FLOOR', 'MIXED'].includes(group.loadingType) ? ['LOADING_FEE'] : []), ...(group.appointmentAccount === 'BUY_APPOINTMENT' ? ['APPOINTMENT_FEE'] : [])])]
|
||
const fees = group.fees.filter(fee => required.includes(fee.feeType) && !arrangeFields.value?.covered(index, data.groups[index]!, fee.feeType))
|
||
if (fees.some(fee => fee.amount == null || !Number.isFinite(fee.amount) || fee.amount <= 0)) throw new DispatchInputError('请填写本组未覆盖服务的正数新增费用')
|
||
return { ...group, fees }
|
||
})
|
||
return { recordVersion: requireVersion(data.recordVersion), idempotencyKey: common.idempotencyKey, cargoHash: data.cargoHash, loadingCompletionHash: data.loadingCompletionHash, groups: prepared } satisfies ArrangeDelivery
|
||
}
|
||
default: return { ...common, remark: form.remark || null }
|
||
}
|
||
}
|
||
async function send(command: PendingDispatchCommand): Promise<CommandResult> {
|
||
const no = command.dispatchNo, body = command.body
|
||
if (command.operation === 'ROUTE') return api.reorderDispatchRoute(no, body as unknown as DispatchRouteCommand)
|
||
switch (command.action) {
|
||
case 'REMOVE_CARGO': if (!command.itemId) throw new DispatchInputError('原移货请求缺少真实明细,请保留现场核对'); return api.removeDispatchItem(no, command.itemId, body as unknown as ReasonCommand)
|
||
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
|
||
case 'ADD_CARGO': return api.addDispatchCargo(no, body as unknown as AddCargo)
|
||
case 'DISPATCH_REVIEW': return api.reviewDispatch(no, body as unknown as ReviewCommand)
|
||
case 'LOADING_REVIEW': return api.reviewDispatchLoading(no, body as unknown as ReviewCommand)
|
||
case 'RETURN_TO_REVIEW': return api.returnDispatchToReview(no, body as unknown as ReasonCommand)
|
||
case 'CANCEL': return api.cancelDispatch(no, body as unknown as ReasonCommand)
|
||
case 'LOADING_COMPLETE': return api.completeDispatchLoading(no, body as unknown as LoadingComplete)
|
||
case 'ARRANGE_DELIVERY': return api.transferDispatchToDelivery(no, body as unknown as ArrangeDelivery)
|
||
case 'SUBMIT_REVIEW': return api.submitDispatchReview(no, body as unknown as VersionCommand & { remark: string | null })
|
||
case 'DEPART': return api.departDispatch(no, body as unknown as VersionCommand & { remark: string | null })
|
||
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
|
||
}
|
||
}
|
||
async function submit() {
|
||
if (busy.value || loading.value || uploadingEvidence.value) return
|
||
error.value = null
|
||
try {
|
||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
|
||
if (!pending.value) {
|
||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(props.action === 'REMOVE_CARGO' ? { itemId: itemId.value } : {}) })
|
||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||
}
|
||
const routeCommand = pending.value?.operation === 'ROUTE'
|
||
const result = await recovery.execute(send, api.getDispatch, api.getDispatchCommand)
|
||
if (!result) return
|
||
ElMessage.success(routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
||
emit('saved'); open.value = false
|
||
} catch (cause) {
|
||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : pending.value?.result ? '操作已完成,页面资料待刷新' : pending.value ? '操作结果尚未确认,请保留原请求核对' : '本次操作未通过,请核对后再提交')
|
||
// Recovery clears only a fresh, definite rejection. A later refusal must not erase an unknown write.
|
||
if (!pending.value && isDefiniteDispatchRejection(cause)) {
|
||
editorRequestKey = ''
|
||
emit('rejected', error.value)
|
||
await refreshFacts()
|
||
}
|
||
}
|
||
}
|
||
async function refreshFacts() {
|
||
const sequence = ++generation, no = currentNo.value
|
||
loading.value = true; factsStale.value = true
|
||
try {
|
||
const value = await api.getDispatch(no)
|
||
if (sequence !== generation) return
|
||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||
detail.value = value
|
||
if (currentOperation.value === 'ROUTE') {
|
||
const data = await api.getDispatchRoute(no)
|
||
if (sequence !== generation) return
|
||
if (data.dispatchNo !== no) throw new DispatchInputError('路线与原排车单不一致')
|
||
route.value = data
|
||
}
|
||
factsStale.value = false
|
||
if (!pending.value && !allowed.value) open.value = false
|
||
} 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 }
|
||
}
|
||
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 })
|
||
</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">
|
||
<div class="density-dialog-sections">
|
||
<el-alert v-if="pending" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
|
||
<LoadErrorState v-if="error" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||
<el-form v-if="detail" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
|
||
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
|
||
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
|
||
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
|
||
</section>
|
||
<section v-else-if="currentAction === 'REMOVE_CARGO' && items" class="density-dialog-section" data-tone="source" aria-label="本车可移除货物"><h3 class="density-dialog-section__title">本车货物</h3>
|
||
<el-table :data="items.items" border><el-table-column label="操作" width="140" fixed="left"><template #default="{ row }"><el-radio v-model="itemId" :value="row.dispatchItemId" :disabled="frozen || loading || uploadingEvidence || !allowed || row.remove.allowed !== true || row.pickedPackages > 0">移除此项</el-radio></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="来源订单" min-width="150" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="150" show-overflow-tooltip /><el-table-column prop="palletNo" label="托盘码" min-width="150" show-overflow-tooltip /><el-table-column prop="scheduledPackages" label="本次箱数" width="100" /><el-table-column prop="pickedPackages" label="真实已拣" width="100" /><el-table-column label="移货资格" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.remove.blockReason || '可移除' }}</template></el-table-column></el-table>
|
||
</section>
|
||
<template v-else-if="currentAction === 'EDIT'">
|
||
<section class="density-dialog-section" data-tone="work" aria-label="本车资源登记"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || uploadingEvidence || !allowed" /></section>
|
||
<section class="density-dialog-section" data-tone="work" aria-label="本车时间登记"><h3 class="density-dialog-section__title">排车时间</h3><table class="density-table density-table--editable workflow-editor-table"><tbody><tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择计划发车时间" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || uploadingEvidence || !allowed" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间" /></el-form-item></td></tr></tbody></table></section>
|
||
</template>
|
||
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
|
||
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><h3 class="density-dialog-section__title">审核依据</h3><table class="density-table"><tbody><tr><th scope="row">车牌</th><td>{{ detail.vehicleInfo.vehicle?.plate || '—' }}</td><th scope="row">司机</th><td>{{ detail.vehicleInfo.driver?.name || '—' }}</td></tr><tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td></tr></tbody></table>
|
||
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th></tr></thead><tbody><tr v-for="appointment in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || '—' }}</td><td>{{ appointment.status }}</td></tr></tbody></table>
|
||
<el-alert title="请核对本车资源与本趟证据;审核通过不代替后续现场扫码。" type="info" :closable="false" show-icon />
|
||
</section>
|
||
<section class="density-dialog-section" data-tone="work" aria-label="审核结果登记"><h3 class="density-dialog-section__title">审核结果</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="审核结果"><el-radio-group v-model="form.decision" :disabled="frozen || loading || uploadingEvidence || !allowed"><el-radio value="APPROVED">审核通过</el-radio><el-radio value="REJECTED">驳回</el-radio></el-radio-group></el-form-item></td></tr></tbody></table></section>
|
||
</template>
|
||
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
|
||
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
|
||
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
|
||
</section>
|
||
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
|
||
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
|
||
<section class="density-dialog-section" data-tone="work" aria-label="真实实载登记"><h3 class="density-dialog-section__title">逐托实载</h3><el-table class="density-table--editable" :data="measurements" border><el-table-column prop="palletNo" label="实物托盘码" min-width="170" show-overflow-tooltip /><el-table-column label="实际托位" min-width="140"><template #default="{ row }"><el-input-number v-model="row.palletSpaces" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="1" placeholder="请确认" controls-position="right" /></template></el-table-column><el-table-column label="实测重量(kg)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.weightKg" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="实测体积(m³)" min-width="170"><template #default="{ row }"><el-input-number v-model="row.volumeCbm" :disabled="frozen || loading || uploadingEvidence || !allowed" :min="0.001" placeholder="请核对真实实测" controls-position="right" /></template></el-table-column><el-table-column label="已匹配卸货顺序" min-width="170"><template #default="{ row }">{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}</template></el-table-column></el-table></section>
|
||
<section class="density-dialog-section" data-tone="source" aria-label="实载与参考依据"><h3 class="density-dialog-section__title">测量依据</h3><table class="density-table"><thead><tr><th>托盘码</th><th>依据</th><th>来源业务号</th><th>值</th></tr></thead><tbody><template v-for="pallet in preview.pallets" :key="pallet.warehousePalletId"><tr v-for="evidence in pallet.evidence" :key="evidence.field + evidence.sourceType"><td>{{ pallet.palletNo }}</td><td>{{ evidence.actual ? '实测依据' : '仅供参考' }}</td><td>{{ evidence.sourceBusinessNo || '—' }}</td><td>{{ evidence.value ?? '—' }}</td></tr></template></tbody></table></section>
|
||
</template>
|
||
<DispatchArrangeFields v-else-if="currentAction === 'ARRANGE_DELIVERY' && arrangement" ref="arrangeFields" v-model="groups" :preview="arrangement.groups" :disabled="frozen || loading || uploadingEvidence || !allowed" />
|
||
<el-alert v-else-if="currentAction === 'DEPART'" title="确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。" type="warning" :closable="false" show-icon />
|
||
<el-alert v-else-if="currentAction === 'RETURN_TO_REVIEW'" title="仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。" type="warning" :closable="false" show-icon />
|
||
<el-alert v-else-if="currentAction === 'CANCEL'" title="确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。" type="warning" :closable="false" show-icon />
|
||
<template v-if="currentOperation !== 'ROUTE' && ['EDIT', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)">
|
||
<DispatchEvidenceFiles v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
|
||
<DispatchEvidenceFiles v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" :readonly="currentAction !== 'EDIT'" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||
</template>
|
||
<section v-if="currentOperation === 'ROUTE' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="currentOperation === 'ROUTE' || form.decision === 'REJECTED' || ['REMOVE_CARGO', 'CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||
<section v-else-if="currentAction !== 'ARRANGE_DELIVERY' && currentAction !== 'ADD_CARGO'" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||
</el-form>
|
||
</div>
|
||
</AppDialog>
|
||
</template>
|
||
<style scoped>
|
||
.workflow-editor-table { table-layout: fixed; }
|
||
.workflow-editor-table td { width: 50%; }
|
||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||
.workflow-cargo-query { display: flex; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
|
||
.workflow-cargo-query > .el-input { max-width: 320px; }
|
||
.workflow-source-group > summary { cursor: pointer; }
|
||
.workflow-source-group ul { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
|
||
</style>
|