@@ -0,0 +1,126 @@
< script setup lang = "ts" >
import { computed , ref , watch } from 'vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError , getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { listVehicles , listDrivers , pickupStatusLabels , type VehicleDetail , type DriverDetail , type PickupTask , type PickupEvidence } from '@/modules/overseas-master-data'
import { getTransport , assignTransport , transportOccupancy , cancelTransport , releaseTransport , commandTransport , uploadTransport , downloadTransportEvidence , transportLabels , type TransportDetail } from '../api/transportTasks'
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
const props = defineProps < { dispatchNo : string } > ( ) , emit = defineEmits < { saved : [ ] } > ( )
const access = useAccessPreviewStore ( ) , detail = ref < TransportDetail | null > ( null ) , error = ref < ApiErrorDetails | null > ( null ) , busy = ref ( false )
const vehicles = ref < VehicleDetail [ ] > ( [ ] ) , drivers = ref < DriverDetail [ ] > ( [ ] ) , vehicleId = ref ( '' ) , driverId = ref ( '' ) , reason = ref ( '' )
const occupancy = ref < Record < string , boolean > > ( { } ) , candidateBusy = ref ( false )
const selected = ref < PickupTask | null > ( null ) , pickupOpen = ref ( false ) , actual = ref ( 1 ) , evidence = ref < PickupEvidence [ ] > ( [ ] ) , truckEvidence = ref < PickupEvidence [ ] > ( [ ] )
const releaseOpen = ref ( false ) , releaseKind = ref < 'VEHICLE' | 'DRIVER' > ( 'VEHICLE' ) , endLocation = ref ( '' ) , actualEnd = ref ( '' )
const pending = ref < { suffix : string ; body : object ; kind ? : 'cancel' | 'release' } | null > ( null )
const trip = computed ( ( ) => detail . value ? . execution . trip )
const editable = computed ( ( ) => access . can ( permission . EDIT ) )
const locked = computed ( ( ) => busy . value || ! ! pending . value )
async function load ( ) {
error . value = null
try { detail . value = await getTransport ( props . dispatchNo ) ; vehicleId . value = trip . value ? . vehicleId || '' ; driverId . value = trip . value ? . driverId || '' }
catch ( cause ) { error . value = getApiErrorDetails ( cause , '运输任务加载失败' ) }
}
watch ( ( ) => props . dispatchNo , ( ) => { detail . value = null ; pending . value = null ; void load ( ) } , { immediate : true } )
/** 资源候选与占用分开校验;保存由后端锁定复验。 */
async function candidates ( ) {
if ( ! trip . value || candidateBusy . value ) return
candidateBusy . value = true ; error . value = null
try {
const [ v , d ] = await Promise . all ( [ listVehicles ( { page : 1 , size : 100 , region : 'OVERSEAS' , status : 'IN_SERVICE' } ) , listDrivers ( { page : 1 , size : 100 , region : 'OVERSEAS' , active : true , reviewStatus : 'APPROVED' , expectedEnd : trip . value . expectedDriverEndAt } ) ] )
const [ vo , dr ] = await Promise . all ( [ transportOccupancy ( props . dispatchNo , 'VEHICLE' , v . records . map ( row => row . id ) ) , transportOccupancy ( props . dispatchNo , 'DRIVER' , d . records . map ( row => row . id ) ) ] )
occupancy . value = Object . fromEntries ( [ ... vo . map ( row => [ ` V: ${ row . resourceId } ` , row . available ] ) , ... dr . map ( row => [ ` D: ${ row . resourceId } ` , row . available ] ) ] ) ; vehicles . value = v . records ; drivers . value = d . records
} catch ( cause ) { error . value = getApiErrorDetails ( cause , '资源候选加载失败' ) }
finally { candidateBusy . value = false }
}
async function assign ( ) {
if ( ! trip . value || locked . value || ! vehicleId . value || ! driverId . value ) return
busy . value = true ; error . value = null
try { detail . value = await assignTransport ( props . dispatchNo , { recordVersion : trip . value . recordVersion , vehicleId : vehicleId . value , driverId : driverId . value , reason : reason . value . trim ( ) || '分配运输车辆及司机' } ) ; emit ( 'saved' ) ; ElMessage . success ( '车辆司机已分配' ) }
catch ( cause ) { await load ( ) ; error . value = getApiErrorDetails ( cause , '分配未完成' ) }
finally { busy . value = false }
}
/** 每次业务命令保留原版本与幂等键,结果未知只重试原请求。 */
async function execute ( ) {
if ( ! pending . value || busy . value ) return
const request = pending . value ; busy . value = true ; error . value = null
try {
if ( request . kind === 'cancel' ) await cancelTransport ( props . dispatchNo , request . body )
else if ( request . kind === 'release' ) await releaseTransport ( props . dispatchNo , request . body as Parameters < typeof releaseTransport > [ 1 ] )
else await commandTransport ( props . dispatchNo , request . suffix , request . body )
pending . value = null ; pickupOpen . value = false ; releaseOpen . value = false ; await load ( ) ; emit ( 'saved' ) ; ElMessage . success ( '运输操作已保存' )
} catch ( cause ) { if ( cause instanceof ApiResponseError ) { pending . value = null ; await load ( ) } ; error . value = getApiErrorDetails ( cause , '运输操作未完成' ) }
finally { busy . value = false }
}
async function command ( action : 'depart' | 'complete' | 'arrive' | 'cancel' , row ? : PickupTask ) {
if ( ! trip . value || locked . value ) return
const message = { depart : '确认车辆司机已出发执行运输任务?' , complete : '确认运输已实际完成?' , arrive : '确认货物已实际送达仓库?确认后仓库可开始收货。' , cancel : '确认取消未发车排车并释放车辆、司机和原单安排?' } [ action ]
try { await ElMessageBox . confirm ( message , '确认运输操作' , { customClass : 'admin-density-confirm' } ) } catch { return }
const prefix = detail . value ? . taskType === 'PICKUP' ? 'pickup' : 'truck'
const suffix = row ? ` pickup/ ${ encodeURIComponent ( row . customerId ) } / ${ encodeURIComponent ( row . transferNo ) } / ${ action } ` : ` ${ prefix } / ${ action } `
pending . value = { suffix , kind : action === 'cancel' ? 'cancel' : undefined , body : { recordVersion : trip . value . recordVersion , idempotencyKey : crypto . randomUUID ( ) , reason : reason . value . trim ( ) || message , ... ( action === 'complete' && prefix === 'truck' ? { evidenceFileIds : truckEvidence . value . map ( file => file . fileId ) } : { } ) } }
await execute ( )
}
function editPickup ( row : PickupTask ) { selected . value = row ; actual . value = Number ( row . packageCount ) ; evidence . value = [ ] ; pickupOpen . value = true }
async function download ( file : PickupEvidence , row ? : PickupTask ) {
try { const blob = await downloadTransportEvidence ( props . dispatchNo , file . fileId , row ) ; const url = URL . createObjectURL ( blob ) ; const link = document . createElement ( 'a' ) ; link . href = url ; link . download = file . fileName ; link . click ( ) ; setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 1000 ) }
catch ( cause ) { error . value = getApiErrorDetails ( cause , '凭证下载失败' ) }
}
async function upload ( event : Event , pickup : boolean ) {
const input = event . target as HTMLInputElement , file = input . files ? . [ 0 ] ; if ( ! file || locked . value ) return
busy . value = true ; error . value = null
try {
const row = selected . value , suffix = pickup && row ? ` pickup/ ${ encodeURIComponent ( row . customerId ) } / ${ encodeURIComponent ( row . transferNo ) } ` : 'truck'
const result = await uploadTransport ( props . dispatchNo , suffix , file ) ; ( pickup ? evidence . value : truckEvidence . value ) . push ( result )
} catch ( cause ) { error . value = getApiErrorDetails ( cause , '凭证上传失败' ) }
finally { busy . value = false ; input . value = '' }
}
async function picked ( ) {
if ( ! selected . value || ! trip . value || ! Number . isInteger ( actual . value ) || actual . value <= 0 || ! evidence . value . length || locked . value ) return
const row = selected . value
pending . value = { suffix : ` pickup/ ${ encodeURIComponent ( row . customerId ) } / ${ encodeURIComponent ( row . transferNo ) } /picked ` , body : { recordVersion : trip . value . recordVersion , idempotencyKey : crypto . randomUUID ( ) , actualPackages : actual . value , evidenceFileIds : evidence . value . map ( file => file . fileId ) , remark : reason . value . trim ( ) } } ; await execute ( )
}
function openRelease ( kind : 'VEHICLE' | 'DRIVER' ) { releaseKind . value = kind ; endLocation . value = '' ; actualEnd . value = '' ; releaseOpen . value = true }
async function release ( ) {
if ( ! trip . value || ! actualEnd . value || ! endLocation . value . trim ( ) || locked . value ) return
pending . value = { suffix : 'release' , kind : 'release' , body : { recordVersion : trip . value . recordVersion , resourceKind : releaseKind . value , actualEndAt : actualEnd . value , endLocation : endLocation . value . trim ( ) , vehicleStatus : releaseKind . value === 'VEHICLE' ? 'IN_SERVICE' : null , idempotencyKey : crypto . randomUUID ( ) , reason : reason . value . trim ( ) || '确认资源实际任务结束' } } ; await execute ( )
}
onBeforeRouteLeave ( ( ) => ! locked . value || window . confirm ( '运输请求尚待核对,离开会丢失原请求。确认离开?' ) )
< / script >
< template >
< section class = "detail-section" aria -label = " 运输任务执行 " >
< LoadErrorState v-if = "error" title="运输操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" / > < / template > < / LoadErrorState >
< el-button v-if = "pending" :loading="busy" @click="execute" > 按原请求重试 < / el -button >
< template v-if = "detail && trip" >
< h2 > { { transportLabels [ detail . taskType ] } } · { { { PLANNED : '待发车' , IN _TRANSIT : '运输中' , COMPLETED : '已完成' , CANCELLED : '已取消' } [ trip . status ] } } < / h2 >
< p v-if = "detail.orderNo" > 本土卡车原单 : {{ detail.orderNo }} · 客户 : {{ detail.customerId }} < / p >
< p > 调度仓库 : { { trip . warehouseName } } · 车辆 : { { trip . vehiclePlate || '待分配' } } · 司机 : { { trip . driverName || '待分配' } } { { trip . driverPhone } } < / p >
< p v-for = "(stop, index) in trip.stops" :key="index" > {{ index + 1 }} . {{ stop.name }} {{ stop.address }} < / p >
< el -input v-if = "editable" v-model="reason" :disabled="locked" placeholder="本次操作说明" / >
< el-form v-if = "trip.status === 'PLANNED' && editable" label-position="top" :disabled="locked" class="density-form" >
< el -button :loading = "candidateBusy" @click ="candidates" > 加载车辆司机候选 < / el -button >
< el-form-item label = "车辆" > < el-select v-model = "vehicleId" filterable><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位${occupancy[`V:${row.id}`] ? '' : ' · 已占用'}`" / > < / el-select > < / el-form-item >
< el-form-item label = "司机" > < el-select v-model = "driverId" filterable><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}${occupancy[`D:${row.id}`] ? '' : ' · 已占用'}`" / > < / el-select > < / el-form-item >
< el-button :loading = "busy" : disabled = "candidateBusy || !vehicleId || !driverId" @click ="assign" > 保存车辆司机 < / el -button >
< / el-form >
< div class = "density-actions" >
< el-button v-if = "trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')" > 确认发车 < / el -button >
< el-button v-if = "trip.status === 'PLANNED' && access.can(permission.CANCEL)" :disabled="locked" @click="command('cancel')" > 取消排车 < / el -button >
< el-button v-if = "trip.status === 'IN_TRANSIT' && editable" :disabled="locked || (detail.taskType === 'PICKUP' ? !detail.pickups.length || detail.pickups.some(row => row.status !== 'ARRIVED') : !truckEvidence.length)" @click="command('complete')" > 确认运输完成 < / el -button >
< template v-if = "trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')" > 结束司机任务 < / el -button > < / template >
< el-button :disabled = "locked" @click ="load" > 刷新运输进度 < / el -button >
< / div >
< p v-if = "detail.taskType === 'PICKUP'" > 送仓后由仓库独立清点收货 , 取货数量保留为运输事实 。 < / p >
< el -table v-if = "detail.taskType === 'PICKUP'" :data="detail.pickups"><el-table-column prop="transferNo" label="转运单号" min-width="190" /><el-table-column label="进度"><template #default="{ row }">{{ pickupStatusLabels[row.status] }}</template></el-table-column><el-table-column prop="packageCount" label="预报箱数" /><el-table-column prop="actualPackages" label="实提箱数" /><el-table-column label="凭证"><template #default="{ row }"><el-button v-for="file in row.evidence" :key="file.fileId" link @click="download(file, row as PickupTask)">{{ file.fileName }}</el-button></template></el-table-column><el-table-column label="操作" width="180"><template #default="{ row }"><template v-if="trip.status === 'IN_TRANSIT' && editable"><el-button v-if="row.status === 'ASSIGNED'" link :disabled="locked" @click="editPickup(row as PickupTask)">确认取货</el-button><el-button v-if="row.status === 'PICKED'" link :disabled="locked" @click="command('arrive', row as PickupTask)" > 确认送仓 < / el -button > < / template > < / template > < / el-table-column > < / el-table >
< div v-if = "detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>运输完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId" > {{ file.fileName }} < / p > < / div >
< div v-if = "detail.completionEvidence?.length"><p>已确认的运输完成凭证</p><el-button v-for="file in detail.completionEvidence" :key="file.fileId" link @click="download(file)" > {{ file.fileName }} < / el -button > < / div >
< / template >
< AppDialog class = "admin-density-dialog" v-model = "pickupOpen" title="确认原单取货" width="650px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked"><p>{{ selected?.transferNo }}</p><el-form label-position="top" :disabled="locked"><el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="取货凭证" required><input type="file" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked" > 确认实提 < / el -button > < / template > < / AppDialog >
< AppDialog class = "admin-density-dialog" v-model = "releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="650px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="top" :disabled="locked"><el-form-item label="实际任务结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release" > 确认任务结束并释放占用 < / el -button > < / template > < / AppDialog >
< / section >
< / template >