2026-10-19 17:56:31 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-10-20 10:11:31 +08:00
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'
2026-10-20 10:11:31 +08:00
import LineClampText from '@/shared/components/LineClampText.vue'
2026-10-19 17:56:31 +08:00
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'
2026-10-20 10:11:31 +08:00
import { DispatchInputError , requireVersion , requireBatchVersions , requireSelectionGroup , expandSelectionGroups , selectionGroupBlockReason } from '../dispatchContracts'
2026-10-19 17:56:31 +08:00
import { canDispatchAction , dispatchActionLabels } from '../dispatchPresentation'
2026-10-20 14:25:01 +08:00
import { isDefiniteDispatchRejection , useDispatchCommandRecovery , type PendingDispatchCommand } from '../dispatchCommandRecovery'
2026-10-19 17:56:31 +08:00
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' } > ()
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 )
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 )
2026-10-20 14:25:01 +08:00
const factsStale = ref ( false )
2026-10-19 17:56:31 +08:00
const preview = ref < LoadingPreview | null >( null ), arrangement = ref < ArrangementPreview | null >( null ), groups = ref < ArrangeGroup [] >([])
const arrangeFields = ref < InstanceType < typeof DispatchArrangeFields > | null > ( null )
2026-10-20 10:11:31 +08:00
const candidates = ref < Candidate [] >([]), selected = ref < Array < { sourceKey : string ; groupKey : string ; groupVersion : string }> > ([]), candidateKeyword = ref ( '' )
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 ())
const form = reactive ({ plannedDepartureAt : '' , estimatedArrivalAt : '' , remark : '' , decision : 'APPROVED' as 'APPROVED' | 'REJECTED' , reason : '' })
let generation = 0
2026-10-20 10:11:31 +08:00
let editorRequestKey = ''
2026-10-20 14:25:01 +08:00
const allowed = computed (() => ! factsStale . value && ! pending . value && canDispatchAction ( detail . value ? . actions , currentAction . value , access . can ) && ( currentOperation . value !== 'ROUTE' || route . value ? . edit . allowed === true ))
2026-10-19 17:56:31 +08:00
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
2026-10-20 10:11:31 +08:00
if ( editorRequestKey && pending . value ? . body . idempotencyKey === editorRequestKey && detail . value ? . dispatchNo === no ) { loading . value = false ; return }
2026-10-20 14:25:01 +08:00
loading . value = true ; factsStale . value = false ; error . value = null ; detail . value = null ; preview . value = null ; arrangement . value = null ; selected . value = []
2026-10-19 17:56:31 +08:00
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
}
2026-10-20 10:11:31 +08:00
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 : '完整选货组无效' ) }
}
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 ) {
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' : {
2026-10-20 10:11:31 +08:00
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
})
2026-10-19 17:56:31 +08:00
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 ( '请选择来源完整、预约客户一致且可排的货物' )
2026-10-20 10:11:31 +08:00
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
2026-10-19 17:56:31 +08:00
}
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 ( '请从排车列表的原请求入口恢复新建排车' )
2026-10-20 10:11:31 +08:00
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 || '' )
}
2026-10-19 17:56:31 +08:00
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
2026-10-20 14:25:01 +08:00
} 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 ()
}
}
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
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 () }
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 ] ! ] }
watch ([ open , () => props . dispatchNo , () => props . action , () => props . operation ], ([ value ]) => { if ( value ) void load (); else generation ++ }, { immediate : true })
</ script >
< template >
2026-10-20 23:34:11 +08:00
< 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" >
2026-10-20 10:11:31 +08:00
< div class = "density-dialog-sections" >
2026-10-20 14:25:01 +08:00
< 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 >
2026-10-20 10:11:31 +08:00
< 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 >
2026-10-20 14:52:06 +08:00
< 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 >
2026-10-20 10:11:31 +08:00
</ el-form >
</ div >
2026-10-19 17:56:31 +08:00
</ AppDialog >
</ template >
< style scoped >
2026-10-20 10:11:31 +08:00
. workflow - editor - table { table - layout : fixed ; }
. workflow - editor - table td { width : 50 % ; }
. workflow - order - actions { width : 160 px ; 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 : 320 px ; }
. workflow - source - group > summary { cursor : pointer ; }
. workflow - source - group ul { margin : var ( -- admin - space - 2 ) 0 0 ; padding - left : 18 px ; }
2026-10-19 17:56:31 +08:00
</ style >