Files
admin-web/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
T

291 lines
30 KiB
Vue
Raw Normal View History

2026-10-19 17:56:31 +08:00
<script setup lang="ts">
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
2026-10-19 17:56:31 +08:00
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 ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
2026-10-21 15:49:35 +08:00
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
2026-10-21 13:22:42 +08:00
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion } from '../dispatchContracts'
2026-10-21 17:28:04 +08:00
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
2026-10-20 14:25:01 +08:00
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
2026-10-22 14:38:11 +08:00
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
2026-10-19 17:56:31 +08:00
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string; action: DispatchAction; operation?: 'ROUTE' }>()
2026-10-20 14:25:01 +08:00
const emit = defineEmits<{ saved: []; rejected: [details: ApiErrorDetails] }>()
2026-10-19 17:56:31 +08:00
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)
2026-10-21 13:22:42 +08:00
const route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
2026-10-20 14:25:01 +08:00
const factsStale = ref(false)
2026-10-22 14:38:11 +08:00
const appointmentsOpen = ref(false)
const canLinkAppointments = computed(() => vehicleEditor.value && detail.value?.orderDetails.some(row => Boolean(row.destinationFbaWarehouse)) && canDispatchAction(detail.value?.actions, 'EDIT', access.can))
async function appointmentsSaved() { error.value = null; await refreshFacts(); emit('saved') }
2026-10-21 14:30:33 +08:00
const preview = ref<LoadingPreview | null>(null)
2026-10-21 13:22:42 +08:00
const loading = computed(() => working.value)
2026-10-19 17:56:31 +08:00
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())
2026-10-21 17:28:04 +08:00
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
2026-10-21 15:10:54 +08:00
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
2026-10-21 17:28:04 +08:00
const isReview = computed(() => ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction.value))
const approvalBlock = computed(() => isReview.value && detail.value?.actions[currentAction.value]?.blockCode === 'APPROVAL_BLOCKED' ? detail.value.actions[currentAction.value]?.blockReason : null)
const reviewTime = (value: string | null | undefined) => value ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', dateStyle: 'short', timeStyle: 'short', hour12: false }).format(new Date(value)) : '未填写'
2026-10-21 15:49:35 +08:00
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
const form = reactive({ publishLooseboardDemand: true, plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
2026-10-19 17:56:31 +08:00
let generation = 0
let editorRequestKey = ''
2026-10-21 13:22:42 +08:00
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
const cargoEntryMessage = '货物安排统一在智能排车中修改;如有未确认操作,请从智能排车恢复原请求。'
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
2026-10-21 17:49:04 +08:00
const vehicleEditor = computed(() => currentOperation.value !== 'ROUTE' && (currentAction.value === 'EDIT' || props.action === 'EDIT' && currentAction.value === 'SUBMIT_REVIEW'))
const submitting = ref(false)
const width = computed(() => vehicleEditor.value ? '1120px' : ['DISPATCH_REVIEW', 'LOADING_REVIEW', 'MARK_EXCEPTION'].includes(currentAction.value) ? '760px' : '560px')
2026-10-21 17:49:04 +08:00
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
2026-10-21 15:49:35 +08:00
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
2026-10-21 15:10:54 +08:00
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
2026-10-19 17:56:31 +08:00
async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
2026-10-21 14:30:33 +08:00
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null
2026-10-21 13:22:42 +08:00
route.value = null; stops.value = []
2026-10-19 17:56:31 +08:00
try {
2026-10-21 13:22:42 +08:00
if (cargoAction.value) return
2026-10-19 17:56:31 +08:00
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
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
2026-10-21 15:49:35 +08:00
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
2026-10-19 17:56:31 +08:00
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, { publishLooseboardDemand: value.publishLooseboardDemand, plannedDepartureAt: value.dispatchInfo.plannedDepartureAt || '', estimatedArrivalAt: value.dispatchInfo.estimatedArrivalAt || '', remark: value.documents.remark || '', reason: '', decision: 'APPROVED' })
2026-10-19 17:56:31 +08:00
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 === '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) }
})
}
2026-10-21 14:30:33 +08:00
2026-10-19 17:56:31 +08:00
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
finally { if (sequence === generation) working.value = false }
2026-10-19 17:56:31 +08:00
}
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) {
2026-10-21 15:49:35 +08:00
case 'MARK_EXCEPTION':
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
case 'EDIT': return { ...common, publishLooseboardDemand: form.publishLooseboardDemand, vehicle: vehicle.value, driver: driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
2026-10-19 17:56:31 +08:00
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
2026-10-19 17:56:31 +08:00
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
2026-10-21 17:28:04 +08:00
return { ...common, decision: form.decision, reason: form.reason.trim() || null } satisfies ReviewCommand
2026-10-19 17:56:31 +08:00
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.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new DispatchInputError('托盘卸货站尚未匹配,请调度核对路线')
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, remark: form.remark || null } satisfies LoadingComplete
2026-10-19 17:56:31 +08:00
}
2026-10-21 17:49:04 +08:00
case 'SUBMIT_REVIEW': return { ...common, remark: null }
2026-10-19 17:56:31 +08:00
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) {
2026-10-21 15:49:35 +08:00
case 'MARK_EXCEPTION': return api.markDispatchException(no, body as unknown as MarkDispatchException)
2026-10-19 17:56:31 +08:00
case 'EDIT': return api.updateDispatch(no, body as unknown as UpdateDispatch)
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 'SUBMIT_REVIEW': return api.submitDispatchReview(no, body as unknown as VersionCommand & { remark: string | null })
default: throw new DispatchInputError('此操作不属于当前后台作业入口')
}
}
2026-10-21 17:28:04 +08:00
async function submitReview(decision: 'APPROVED' | 'REJECTED') {
if (frozen.value || loading.value || uploadingEvidence.value || !allowed.value) return
form.decision = decision
await submit()
}
2026-10-21 17:49:04 +08:00
async function submit(submitReviewAfterSave = false) {
2026-10-22 14:38:11 +08:00
if (appointmentsOpen.value) return
2026-10-21 17:49:04 +08:00
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
submitting.value = true
2026-10-19 17:56:31 +08:00
error.value = null
try {
2026-10-21 13:22:42 +08:00
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
2026-10-19 17:56:31 +08:00
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
if (!pending.value) {
2026-10-21 17:49:04 +08:00
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
}
2026-10-19 17:56:31 +08:00
const routeCommand = pending.value?.operation === 'ROUTE'
2026-10-21 14:30:33 +08:00
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
2026-10-21 17:49:04 +08:00
const continueReview = pending.value?.action === 'EDIT' && pending.value.submitReviewAfterSave === true
const readCurrent = async (no: string) => {
const value = await api.getDispatch(no)
if (value.dispatchNo === currentNo.value) detail.value = value
return value
}
let result = await recovery.execute(send, readCurrent)
2026-10-19 17:56:31 +08:00
if (!result) return
2026-10-21 17:49:04 +08:00
if (continueReview) {
emit('saved')
if (!canDispatchAction(detail.value?.actions, 'SUBMIT_REVIEW', access.can)) throw new DispatchInputError(detail.value?.actions.SUBMIT_REVIEW?.blockReason || '车辆信息已保存,当前不能提交审核,请核对资料')
recovery.begin(result.dispatchNo, 'SUBMIT_REVIEW', { recordVersion: requireVersion(detail.value?.recordVersion), idempotencyKey: crypto.randomUUID(), remark: null })
editorRequestKey = String(pending.value?.body.idempotencyKey || '')
result = await recovery.execute(send, readCurrent)
2026-10-21 17:49:04 +08:00
if (!result) return
}
2026-10-21 17:22:25 +08:00
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
2026-10-19 17:56:31 +08:00
emit('saved'); open.value = false
2026-10-20 14:25:01 +08:00
} catch (cause) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次操作失败,请重试')
editorRequestKey = ''
if (isDefiniteDispatchRejection(cause)) emit('rejected', error.value)
if (!(cause instanceof DispatchInputError)) await refreshFacts()
2026-10-21 17:49:04 +08:00
} finally { submitting.value = false }
2026-10-19 17:56:31 +08:00
}
2026-10-20 14:25:01 +08:00
async function refreshFacts() {
const sequence = ++generation, no = currentNo.value
working.value = true; factsStale.value = true
2026-10-20 14:25:01 +08:00
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) working.value = false }
2026-10-20 14:25:01 +08:00
}
async function retryError() { if (pending.value) await submit(); else if (detail.value) await refreshFacts(); else await load() }
2026-10-19 17:56:31 +08:00
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]!] }
2026-10-21 13:22:42 +08:00
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++ } }, { immediate: true })
2026-10-19 17:56:31 +08:00
</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'" :show-cancel="!isReview" :loading="loading" :confirm-loading="submitting || busy || uploadingEvidence" :confirm-disabled="cargoAction || loading || uploadingEvidence || !pending && !allowed" :confirm-text="cargoAction ? '请从智能排车修改' : currentAction === 'MARK_EXCEPTION' ? '保存' : '确认提交'" @confirm="submit()">
<div class="density-dialog-sections">
2026-10-21 13:22:42 +08:00
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
2026-10-21 14:30:33 +08:00
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @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 />
2026-10-22 14:38:11 +08:00
<div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div>
2026-10-21 13:22:42 +08:00
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
<table class="density-table"><tbody><tr><th scope="row">起运仓</th><td>{{ route.stops[0]?.name || '—' }}</td><th scope="row">地址</th><td>{{ route.stops[0]?.address || '—' }}</td></tr></tbody></table>
<el-alert :title="route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。'" type="info" :closable="false" show-icon />
<table class="density-table"><thead><tr><th>操作</th><th>顺序</th><th>卸货点</th><th>地址</th></tr></thead><tbody><tr v-for="(stop, index) in stops" :key="stop.destinationKey || stop.index"><td class="workflow-order-actions"><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === 0" @click="moveStop(index, -1)">上移</el-button><el-button :disabled="frozen || loading || uploadingEvidence || !allowed || index === stops.length - 1" @click="moveStop(index, 1)">下移</el-button></td><td>{{ index + 1 }}</td><td>{{ stop.name }}</td><td>{{ stop.address || '—' }}</td></tr></tbody></table>
</section>
2026-10-21 15:49:35 +08:00
<template v-else-if="currentAction === 'MARK_EXCEPTION'">
<el-form-item label="异常类型" required><el-select v-model="exception.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in dispatchExceptionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="退货去向"><el-select v-model="exception.returnDirection" clearable placeholder="请选择退货去向"><el-option v-for="(label, value) in dispatchReturnDirectionLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="异常说明"><el-input v-model="exception.description" type="textarea" :rows="3" :maxlength="1000" show-word-limit /></el-form-item>
<DispatchEvidenceFiles v-model="exception.imageFileIds" :dispatch-no="currentNo" kind="EXCEPTION" label="异常图片" save-hint="上传后点击保存,图片与本次异常一起登记。" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.EXCEPTION = $event" />
</template>
2026-10-21 17:49:04 +08:00
<template v-else-if="vehicleEditor">
<DispatchVehicleMaintenanceForm :key="currentNo" :detail="detail" v-model:vehicle="vehicle" v-model:driver="driver" v-model:maintenance="form.maintenance" v-model:planned-departure-at="form.plannedDepartureAt" v-model:estimated-arrival-at="form.estimatedArrivalAt" v-model:publish-looseboard-demand="form.publishLooseboardDemand" :disabled="frozen || loading || uploadingEvidence || !allowed">
2026-10-22 14:38:11 +08:00
<template #appointments><div v-if="canLinkAppointments" class="density-actions"><el-button :disabled="loading || busy || submitting" @click="appointmentsOpen = true">关联正式预约</el-button></div></template>
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
<template #attachments>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</div>
</template>
</DispatchVehicleMaintenanceForm>
</template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
2026-10-21 17:28:04 +08:00
<section class="density-dialog-section" data-tone="source" aria-label="本车审核依据"><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 class="density-table"><tbody><tr><th>计划来车/发车(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td></tr><tr><th>预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
<table v-if="detail.dispatchInfo.fbaAppointments.length" class="density-table"><thead><tr><th>目的仓</th><th>预约单号</th><th>预约状态</th><th>确认送达(北京时间)</th></tr></thead><tbody><tr v-for="(appointment, index) in detail.dispatchInfo.fbaAppointments" :key="appointment.batchKey + '-' + index"><td>{{ appointment.destinationWarehouseCode }}</td><td>{{ appointment.appointmentNo || appointment.batchNo || '—' }}</td><td>{{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }}</td><td>{{ reviewTime(appointment.confirmedAt) }}</td></tr></tbody></table>
<el-alert v-if="approvalBlock" :title="approvalBlock" type="warning" :closable="false" show-icon />
</section>
</template>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
<el-alert :title="preview.loadingComplete.blockReason || 'PDA拣货已完成,请确认本车货物已全部装车。'" :type="preview.loadingComplete.allowed ? 'info' : 'warning'" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="确认装车完成"><h3 class="density-dialog-section__title">确认装车完成</h3><p>已完成 {{ preview.pallets.length }} 托拣货,共 {{ preview.pallets.reduce((sum, row) => sum + Number(row.pickedPackages), 0) }} 箱。请确认本车货物已全部装车。</p></section>
</template>
<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 />
<div v-if="isReview" class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
2026-10-21 17:28:04 +08:00
<DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" photo-card readonly :model-value="reviewEvidence[field.key]" :dispatch-no="currentNo" :kind="field.kind" :label="field.label" />
</div>
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">操作说明</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW'].includes(currentAction) ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
2026-10-21 17:49:04 +08:00
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_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="备注"><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>
2026-10-21 17:49:04 +08:00
<template v-if="vehicleEditor" #footer="{ close }">
<el-button :disabled="submitting || busy || uploadingEvidence" @click="close">取消</el-button>
<el-button v-if="!pending" :disabled="submitting || loading || uploadingEvidence || !allowed" @click="submit()">保存</el-button>
<el-button type="primary" :loading="submitting || busy" :disabled="loading || uploadingEvidence || !pending && !allowed" @click="submit(true)">提交审核</el-button>
2026-10-21 17:49:04 +08:00
</template>
<template v-else-if="isReview && !pending" #footer>
2026-10-21 17:28:04 +08:00
<div class="workflow-review-actions">
<el-button type="danger" :disabled="frozen || loading || uploadingEvidence || !allowed" @click="submitReview('REJECTED')">驳回</el-button>
<el-button type="primary" :disabled="frozen || loading || uploadingEvidence || !allowed || Boolean(approvalBlock)" @click="submitReview('APPROVED')">审核通过</el-button>
2026-10-21 17:28:04 +08:00
</div>
</template>
2026-10-19 17:56:31 +08:00
</AppDialog>
2026-10-22 14:38:11 +08:00
<DispatchAppointmentAllocationsDialog v-if="appointmentsOpen" v-model="appointmentsOpen" :dispatch-no="currentNo" @saved="appointmentsSaved" />
2026-10-19 17:56:31 +08:00
</template>
<style scoped>
.workflow-order-actions { width: 160px; white-space: nowrap; }
2026-10-21 17:28:04 +08:00
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
.workflow-review-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); }
.workflow-review-actions > .el-button { margin-left: 0; }
2026-10-19 17:56:31 +08:00
</style>