Files
admin-web/src/modules/overseas-master-data/components/TripHandoverPanel.vue
T

194 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import { confirmTripHandover, previewTripHandover, listTripHandovers, releaseHandoverResource, handoverNumber, handoverVersion,
type HandoverCommand, type HandoverPreview, type HandoverRecord, type HandoverRelease, type HandoverSelection } from '../api/tripHandovers'
import type { TripDetail } from '../tripTypes'
import type { VehicleDetail } from '../types'
import type { DriverDetail } from '../driverTypes'
const props = defineProps<{ detail: TripDetail }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const readable = computed(() => access.can('platform:fleet:trip:list'))
const writable = computed(() => readable.value && access.can('platform:fleet:trip:handover'))
const releasable = computed(() => readable.value && access.can('platform:fleet:trip:release'))
const rows = ref<HandoverRecord[]>([]), page = ref(1), size = ref(10), total = ref(0), loading = ref(false), busy = ref(false)
const error = ref<ApiErrorDetails | null>(null), commandError = ref<ApiErrorDetails | null>(null)
const preview = ref<HandoverPreview | null>(null), selected = ref<HandoverRecord | null>(null)
const pending = ref<HandoverCommand | null>(null), pendingRelease = ref<{ number: string; command: HandoverRelease } | null>(null)
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const vehicleLoading = ref(false), driverLoading = ref(false)
const form = reactive({ vehicleId: '', driverId: '', handedOverAt: '', expectedDriverEndAt: '', location: '', reason: '' })
const ending = ref<'VEHICLE' | 'DRIVER' | null>(null)
const endForm = reactive({ actualEndAt: '', location: '', vehicleStatus: 'IN_SERVICE' })
let scope = 0, query = 0, vehicleQuery = 0, driverQuery = 0, previewQuery = 0
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'handoverNo', prop: 'handoverNo', label: '交接编号', minWidth: 220 },
{ key: 'time', label: '实际交接时间', minWidth: 180, slotName: 'time' },
{ key: 'vehicles', label: '交接前后车辆', minWidth: 200, slotName: 'vehicles' },
{ key: 'drivers', label: '交接前后司机', minWidth: 180, slotName: 'drivers' },
{ key: 'location', label: '交接地点', minWidth: 200, slotName: 'location' },
]
function canEnd(row: HandoverRecord, kind: 'VEHICLE' | 'DRIVER') {
const vehicle = kind === 'VEHICLE', before = row.snapshot.previous, after = row.snapshot.replacement
return releasable.value && (vehicle ? before.vehicleId !== after.vehicleId && !row.vehicleRelease && before.vehicleId !== props.detail.trip.vehicleId
: before.driverId !== after.driverId && !row.driverRelease && before.driverId !== props.detail.trip.driverId)
}
function actions(row: HandoverRecord): RowActionItem[] {
return [{ key: 'detail', label: '查看交接' }, ...(canEnd(row, 'VEHICLE') ? [{ key: 'VEHICLE', label: '结束原车辆任务' }] : []),
...(canEnd(row, 'DRIVER') ? [{ key: 'DRIVER', label: '结束原司机任务' }] : [])]
}
function show(row: HandoverRecord, action: string) {
if (busy.value || pending.value || pendingRelease.value) return
selected.value = row; ending.value = action === 'VEHICLE' || action === 'DRIVER' ? action : null
endForm.actualEndAt = new Date().toISOString(); endForm.location = ''; endForm.vehicleStatus = 'IN_SERVICE'; commandError.value = null
}
async function load() {
const token = ++query
if (!readable.value) { rows.value = []; total.value = 0; return }
loading.value = true; error.value = null
try { const result = await listTripHandovers(props.detail.trip.tripNo, page.value, size.value); if (token === query) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (token === query) error.value = getApiErrorDetails(cause, '交接记录加载失败') }
finally { if (token === query) loading.value = false }
}
watch(() => props.detail.trip.tripNo, () => {
scope++; query++; previewQuery++; vehicleQuery++; driverQuery++; pending.value = null; pendingRelease.value = null
busy.value = false; preview.value = null; selected.value = null; ending.value = null; commandError.value = null
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
Object.assign(form, { vehicleId: props.detail.trip.vehicleId || '', driverId: props.detail.trip.driverId || '', handedOverAt: new Date().toISOString(), expectedDriverEndAt: props.detail.trip.expectedDriverEndAt, location: '', reason: '' })
page.value = 1
}, { immediate: true })
watch([() => props.detail.trip.tripNo, page, size, readable], load, { immediate: true })
watch([() => props.detail.trip.recordVersion, () => form.vehicleId, () => form.driverId, () => form.handedOverAt, () => form.expectedDriverEndAt], () => { previewQuery++; preview.value = null })
async function searchVehicles(keyword = '') {
const token = ++vehicleQuery; vehicleLoading.value = true
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (token === vehicleQuery) vehicles.value = result.records }
catch (cause) { if (token === vehicleQuery) commandError.value = getApiErrorDetails(cause, '车辆候选读取失败') }
finally { if (token === vehicleQuery) vehicleLoading.value = false }
}
async function searchDrivers(keyword = '') {
const token = ++driverQuery; driverLoading.value = true
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.expectedDriverEndAt }); if (token === driverQuery) drivers.value = result.records }
catch (cause) { if (token === driverQuery) commandError.value = getApiErrorDetails(cause, '司机候选读取失败') }
finally { if (token === driverQuery) driverLoading.value = false }
}
function selection(): HandoverSelection {
return { recordVersion: props.detail.trip.recordVersion, vehicleId: form.vehicleId, driverId: form.driverId,
handedOverAt: new Date(form.handedOverAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString() }
}
async function inspect() {
if (busy.value || pending.value || !writable.value) return
if (!form.vehicleId || !form.driverId || !form.handedOverAt || !form.expectedDriverEndAt) { ElMessage.warning('请选择接替资源并填写交接及预计任务结束时间'); return }
const token = ++previewQuery, generation = scope; busy.value = true; commandError.value = null; preview.value = null
try { const result = await previewTripHandover(props.detail.trip.tripNo, selection()); if (token === previewQuery && generation === scope) preview.value = result }
catch (cause) { if (generation === scope) commandError.value = getApiErrorDetails(cause, '交接核对未通过') }
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
}
async function confirm() {
if (busy.value || !writable.value || (!pending.value && !preview.value)) return
if (!pending.value && !form.location.trim()) { ElMessage.warning('请填写实际交接地点'); return }
const generation = scope; busy.value = true; commandError.value = null
try {
pending.value ??= { selection: selection(), cargoHash: preview.value!.cargoHash, location: form.location.trim(), reason: form.reason.trim() || undefined, idempotencyKey: crypto.randomUUID() }
const result = await confirmTripHandover(props.detail.trip.tripNo, pending.value)
if (generation !== scope) return
pending.value = null; preview.value = null; selected.value = result; emit('saved'); ElMessage.success('交接已记录,请分别核对原资源任务结束')
await load()
} catch (cause) { if (generation === scope) { if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pending.value = null; preview.value = null } commandError.value = getApiErrorDetails(cause, '交接未确认,请核对或重试') } }
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
}
async function release() {
if (busy.value || !releasable.value || (!pendingRelease.value && (!selected.value || !ending.value))) return
if (!pendingRelease.value && (!endForm.actualEndAt || !endForm.location.trim())) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
const generation = scope; busy.value = true; commandError.value = null
try {
pendingRelease.value ??= { number: selected.value!.handoverNo, command: { recordVersion: handoverVersion(selected.value!.recordVersion), resourceKind: ending.value!, actualEndAt: new Date(endForm.actualEndAt).toISOString(), location: endForm.location.trim(), vehicleStatus: ending.value === 'VEHICLE' ? endForm.vehicleStatus : undefined, idempotencyKey: crypto.randomUUID() } }
const result = await releaseHandoverResource(props.detail.trip.tripNo, pendingRelease.value.number, pendingRelease.value.command)
if (generation !== scope) return
pendingRelease.value = null; selected.value = result; ending.value = null; emit('saved'); ElMessage.success('原资源任务已结束'); await load()
} catch (cause) {
if (generation === scope) {
commandError.value = getApiErrorDetails(cause, '任务结束未确认,请核对或重试')
}
}
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
}
onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driverQuery++ })
</script>
<template>
<section aria-label="在途交接" class="handover-panel">
<p v-if="!readable">暂无车次交接查看权限。</p>
<template v-else>
<p>交接后原车辆和司机分别结束实际任务。已经签收或完成退回的货物保留原运输记录。</p>
<LoadErrorState v-if="error" title="交接记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="writable && (detail.trip.status === 'IN_TRANSIT' || pending)">
<el-form label-position="top" :disabled="busy || !!pending || !!pendingRelease" class="density-form handover-form">
<el-form-item label="接替车辆" required><el-select v-model="form.vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" @focus="searchVehicles()">
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="vehicle.plateNumber" />
</el-select></el-form-item>
<el-form-item label="接替司机" required><el-select v-model="form.driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" @focus="searchDrivers()">
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone}`" />
</el-select></el-form-item>
<el-form-item label="实际交接时间" required><el-date-picker v-model="form.handedOverAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="实际交接地点" required><el-input v-model="form.location" maxlength="500" placeholder="请输入实际交接地点" /></el-form-item>
<el-form-item label="交接说明(选填)"><el-input v-model="form.reason" maxlength="1000" placeholder="如车辆故障或司机替班(选填)" /></el-form-item>
</el-form>
<el-button :loading="busy" :disabled="!!pending || !!pendingRelease" @click="inspect">核对交接货物</el-button>
<el-button :disabled="busy || !!pending || !!pendingRelease" @click="emit('saved')">刷新车次</el-button>
<template v-if="preview">
<p v-if="preview.driverQualification.expiringSoon">驾驶证将在30天内到期,当前预计任务结束仍在有效期内。</p>
<p>本次交接 {{ preview.cargo.remainingPalletIds.length }} 个实托,占 {{ handoverNumber(preview.cargo.occupied.palletSpaces) }} 托位、{{ handoverNumber(preview.cargo.occupied.weightKg) }} kg、{{ handoverNumber(preview.cargo.occupied.volumeCbm) }} m³。</p>
<table class="density-table"><thead><tr><th>客户排车单</th><th>交接箱数</th></tr></thead><tbody><tr v-for="(cargo, index) in preview.cargo.remainingCargo" :key="index"><td>{{ cargo.dispatchNo }}</td><td>{{ cargo.packages }}</td></tr></tbody></table>
</template>
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ '确认实际交接' }}</el-button>
</div>
<div v-if="commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></div>
<div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
<template #actions="{ row }"><RowActions :actions="actions(row as HandoverRecord)" @action="show(row as HandoverRecord, $event)" /></template>
<template #time="{ row }">{{ time(row.snapshot.handedOverAt) }}</template>
<template #vehicles="{ row }">{{ row.snapshot.previous.vehiclePlate || '—' }} → {{ row.snapshot.replacement.vehiclePlate || '—' }}</template>
<template #drivers="{ row }">{{ row.snapshot.previous.driverName || '—' }} → {{ row.snapshot.replacement.driverName || '—' }}</template>
<template #location="{ row }">{{ row.snapshot.location }}</template>
</DataTable></div>
<div v-if="selected" class="handover-detail">
<h4>交接记录 {{ selected.handoverNo }}</h4>
<p>{{ selected.snapshot.reason }} · {{ selected.snapshot.location }}</p>
<p>原车辆任务:{{ selected.snapshot.previous.vehicleId === selected.snapshot.replacement.vehicleId ? '车辆继续执行' : selected.vehicleRelease ? `已结束 · ${time(selected.vehicleRelease.actualEndAt)} · ${selected.vehicleRelease.location}` : '尚未结束' }}</p>
<p>原司机任务:{{ selected.snapshot.previous.driverId === selected.snapshot.replacement.driverId ? '司机继续执行' : selected.driverRelease ? `已结束 · ${time(selected.driverRelease.actualEndAt)} · ${selected.driverRelease.location}` : '尚未结束' }}</p>
<p>交接前已完成派送:{{ selected.snapshot.cargo.completedDeliveries.map(row => row.deliveryNo).join('、') || '—' }}</p>
<template v-if="ending || pendingRelease">
<el-form label-position="top" class="density-form handover-form" :disabled="busy || !!pendingRelease">
<el-form-item label="实际任务结束时间" required><el-date-picker v-model="endForm.actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="实际任务结束地点" required><el-input v-model="endForm.location" maxlength="500" placeholder="请输入原车辆或司机实际结束任务的地点" /></el-form-item>
<el-form-item v-if="ending === 'VEHICLE'" label="车辆实际状态" required><el-select v-model="endForm.vehicleStatus"><el-option value="IN_SERVICE" label="在役" /><el-option value="MAINTENANCE" label="维修中" /><el-option value="INACTIVE" label="停用" /><el-option value="RETURNED" label="已归还" /></el-select></el-form-item>
</el-form>
<el-button v-if="releasable" type="primary" :loading="busy" @click="release">{{ '确认原资源任务结束' }}</el-button>
</template>
</div>
</template>
</section>
</template>
<style scoped>
.handover-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
.handover-form :deep(.el-select){width:100%}.handover-detail{margin-top:var(--admin-space-6)}
</style>