310 lines
31 KiB
Vue
310 lines
31 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 ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import * as api from '../api/dispatches'
|
||
import type { MarkDispatchException, DispatchExceptionType, DispatchReturnDirection, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
|
||
import type { DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
|
||
import { DispatchInputError, requireVersion } from '../dispatchContracts'
|
||
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
|
||
import { isDefiniteDispatchRejection, useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
|
||
import DispatchVehicleMaintenanceForm from './DispatchVehicleMaintenanceForm.vue'
|
||
import DispatchAppointments from './DispatchAppointments.vue'
|
||
import DispatchVehicleMaintenanceFees from './DispatchVehicleMaintenanceFees.vue'
|
||
import { blankVehicleMaintenance, vehicleMaintenancePayload } from '../dispatchVehicleMaintenance'
|
||
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 route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
|
||
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||
const factsStale = ref(false)
|
||
const preview = ref<LoadingPreview | null>(null)
|
||
const loading = computed(() => working.value)
|
||
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
|
||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
|
||
// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
|
||
const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
|
||
const unsavedMasterLicense = computed(() => {
|
||
const fileId = masterLicenseReference.value, driverId = driver.value.driverId
|
||
const saved = detail.value?.vehicleInfo.driver
|
||
if (!fileId || !driverId || saved?.driverId === driverId && saved.evidence?.masterLicenseFileId === fileId) return null
|
||
return { driverId, fileId }
|
||
})
|
||
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||
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)) : '未填写'
|
||
const exception = reactive({ exceptionType: '' as DispatchExceptionType | '', returnDirection: '' as DispatchReturnDirection | '', description: '', imageFileIds: [] as string[] })
|
||
const form = reactive({ plannedDepartureAt: '', estimatedArrivalAt: '', maintenance: blankVehicleMaintenance(), remark: '', decision: 'APPROVED' as 'APPROVED' | 'REJECTED', reason: '' })
|
||
/** 并发加货后补入新目的仓,保留客服已填写的同仓编号。 */
|
||
function fillDestinationAppointments(value: DispatchDetail, keepInput = false) {
|
||
const entered = new Map(keepInput ? form.maintenance.destinationAppointments.map(row => [row.destinationKey, row.appointmentNo]) : [])
|
||
form.maintenance.destinationAppointments = value.dispatchInfo.fbaAppointments.map(row => ({ destinationKey: row.destinationKey, appointmentNo: entered.has(row.destinationKey) ? entered.get(row.destinationKey)! : row.appointmentNo }))
|
||
}
|
||
let generation = 0
|
||
let editorRequestKey = ''
|
||
const cargoAction = computed(() => ['ADD_CARGO', 'REMOVE_CARGO'].includes(currentAction.value))
|
||
const cargoEntryMessage = '当前入口不支持调整已建排车货物。'
|
||
const allowed = computed(() => !cargoAction.value && !factsStale.value && !pending.value && canDispatchAction(detail.value?.actions, currentAction.value, access.can) && (currentOperation.value !== 'ROUTE' || route.value?.edit.allowed === true))
|
||
const 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' : isReview.value ? '1100px' : currentAction.value === 'MARK_EXCEPTION' ? '760px' : '560px')
|
||
const frozen = computed(() => submitting.value || busy.value || Boolean(pending.value))
|
||
const evidenceBusy = reactive({ EXCEPTION: false, VEHICLE: false, DRIVER_PHOTO: false, DRIVER: false })
|
||
const uploadingEvidence = computed(() => Object.values(evidenceBusy).some(Boolean))
|
||
|
||
async function load() {
|
||
const sequence = ++generation, no = currentNo.value, action = currentAction.value
|
||
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
|
||
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null
|
||
route.value = null; stops.value = []
|
||
try {
|
||
if (cargoAction.value) return
|
||
if (action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||
const value = await api.getDispatch(no)
|
||
if (sequence !== generation) return
|
||
if (value.dispatchNo !== no) throw new DispatchInputError('排车业务号不一致,请重新打开')
|
||
detail.value = value
|
||
form.maintenance = value.vehicleInfo.maintenance ? { ...value.vehicleInfo.maintenance } : blankVehicleMaintenance()
|
||
fillDestinationAppointments(value)
|
||
Object.assign(exception, { exceptionType: '', returnDirection: '', description: '', imageFileIds: [] })
|
||
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 === '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) }
|
||
})
|
||
}
|
||
|
||
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
|
||
finally { if (sequence === generation) working.value = false }
|
||
}
|
||
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 '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, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||
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.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
|
||
}
|
||
case 'SUBMIT_REVIEW': return { ...common, remark: null }
|
||
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 'MARK_EXCEPTION': return api.markDispatchException(no, body as unknown as MarkDispatchException)
|
||
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('此操作不属于当前后台作业入口')
|
||
}
|
||
}
|
||
async function submitReview(decision: 'APPROVED' | 'REJECTED') {
|
||
if (frozen.value || loading.value || uploadingEvidence.value || !allowed.value) return
|
||
form.decision = decision
|
||
await submit()
|
||
}
|
||
async function submit(submitReviewAfterSave = false) {
|
||
if (submitting.value || busy.value || loading.value || uploadingEvidence.value) return
|
||
submitting.value = true
|
||
error.value = null
|
||
try {
|
||
if (cargoAction.value) throw new DispatchInputError(cargoEntryMessage)
|
||
if (pending.value?.action === 'CREATE') throw new DispatchInputError('请从排车列表进入新建排车')
|
||
if (!pending.value) {
|
||
recovery.begin(props.dispatchNo, props.action, payload(), { operation: currentOperation.value, ...(submitReviewAfterSave && vehicleEditor.value ? { submitReviewAfterSave: true } : {}) })
|
||
editorRequestKey = String(recovery.pending.value?.body.idempotencyKey || '')
|
||
}
|
||
const routeCommand = pending.value?.operation === 'ROUTE'
|
||
const reviewReturned = ['DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(pending.value?.action || '') && pending.value?.body.decision === 'REJECTED'
|
||
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; fillDestinationAppointments(value, true) }
|
||
return value
|
||
}
|
||
let result = await recovery.execute(send, readCurrent)
|
||
if (!result) return
|
||
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)
|
||
if (!result) return
|
||
}
|
||
ElMessage.success(reviewReturned ? '已退回客服处理(车辆信息),修改后需重新提交调度审核' : result.action === 'LOADING_COMPLETE' ? result.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成,请在装车后补齐装车资料' : routeCommand ? '卸货顺序调整已确认,请重新核对两级审核' : result.alreadyArranged ? '已引用本车原派送安排,未重复生成单据和费用' : result.replayed ? '原操作结果已核对' : `${dispatchActionLabels[result.action]}已确认`)
|
||
emit('saved'); open.value = false
|
||
} catch (cause) {
|
||
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次操作失败,请重试')
|
||
editorRequestKey = ''
|
||
if (isDefiniteDispatchRejection(cause)) emit('rejected', error.value)
|
||
|
||
} finally { submitting.value = false }
|
||
}
|
||
async function refreshFacts() {
|
||
const sequence = ++generation, no = currentNo.value
|
||
working.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
|
||
fillDestinationAppointments(value, true)
|
||
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 }
|
||
}
|
||
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'" :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">
|
||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||
<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 />
|
||
<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>
|
||
<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>
|
||
<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" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||
<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="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
|
||
</div>
|
||
</template>
|
||
</DispatchVehicleMaintenanceForm>
|
||
</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><th scope="row">联系电话</th><td>{{ detail.vehicleInfo.driver?.phone || '—' }}</td></tr>
|
||
<tr><th scope="row">托盘数</th><td>{{ detail.counts.palletCount }} 托</td><th scope="row">箱数</th><td>{{ detail.counts.packageCount }} 箱</td><th scope="row">路线</th><td>{{ detail.dispatchInfo.route || '—' }}</td></tr>
|
||
</tbody></table>
|
||
</section>
|
||
<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>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达(北京时间)</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td></tr></tbody></table>
|
||
<DispatchAppointments :appointments="detail.dispatchInfo.fbaAppointments" :show-shipment-numbers="currentAction !== 'DISPATCH_REVIEW'" />
|
||
<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 />
|
||
<section v-if="isReview" class="density-dialog-section" data-tone="source" aria-label="审核照片">
|
||
<h3 class="density-dialog-section__title">车辆司机照片</h3>
|
||
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
|
||
<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>
|
||
<section v-if="currentOperation === 'ROUTE' || ['CANCEL', 'RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</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>
|
||
<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>
|
||
<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>
|
||
</template>
|
||
<template v-else-if="isReview && !pending" #footer>
|
||
<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>
|
||
</div>
|
||
</template>
|
||
</AppDialog>
|
||
</template>
|
||
<style scoped>
|
||
.workflow-order-actions { width: 160px; white-space: nowrap; }
|
||
.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; }
|
||
</style>
|