2026-10-03 04:35:55 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-10-20 23:34:11 +08:00
|
|
|
|
import '@/styles/adminWorkbench.css'
|
2026-10-03 04:35:55 +08:00
|
|
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import { getTripLoading, confirmTripLoading, getTripCapacityPlan, confirmTripCapacityPlan } from '../api/trips'
|
|
|
|
|
|
import type { FleetTrip, TripLoading, TripCapacityPlanning, TripMeasurement } from '../tripTypes'
|
|
|
|
|
|
|
2026-10-09 15:31:57 +08:00
|
|
|
|
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean; embedded?: boolean }>()
|
|
|
|
|
|
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning]; changed: [value: string] }>()
|
2026-10-09 11:43:38 +08:00
|
|
|
|
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; destinationKeys: string[]; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
|
2026-10-03 04:35:55 +08:00
|
|
|
|
const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<PalletRow[]>([]), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
let sequence = 0
|
2026-10-09 15:31:57 +08:00
|
|
|
|
const baseline = ref('')
|
|
|
|
|
|
watch(rows, value => emit('changed', JSON.stringify(value)), { deep: true })
|
2026-10-11 11:17:08 +08:00
|
|
|
|
const columns = computed<DataTableColumn[]>(() => [
|
|
|
|
|
|
{ key: 'palletNo', label: '实物托盘', slotName: 'palletNo', minWidth: 210, singleLine: false },
|
|
|
|
|
|
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 140, singleLine: false },
|
|
|
|
|
|
{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 160, singleLine: false },
|
|
|
|
|
|
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 180, singleLine: false },
|
|
|
|
|
|
{ key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 180, singleLine: false },
|
|
|
|
|
|
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 160, singleLine: false },
|
|
|
|
|
|
])
|
2026-10-03 04:35:55 +08:00
|
|
|
|
const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad))
|
|
|
|
|
|
const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || [])
|
|
|
|
|
|
const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid)
|
2026-10-09 11:43:38 +08:00
|
|
|
|
function matchingStops(row: PalletRow) {
|
|
|
|
|
|
if (row.destinationKeys.length !== 1 || !row.destinationKeys[0]) return []
|
|
|
|
|
|
return props.trip.stops.map((stop, index) => ({ ...stop, index }))
|
|
|
|
|
|
.filter(stop => stop.index > 0 && stop.destinationKey === row.destinationKeys[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
const destinationIssue = computed(() => {
|
|
|
|
|
|
for (const row of rows.value) {
|
|
|
|
|
|
if (row.destinationKeys.length > 1) return `托盘 ${row.palletNo} 包含不同目的地,请先拆托。`
|
|
|
|
|
|
if (!matchingStops(row).length) return `托盘 ${row.palletNo} 的目的地 ${row.destinations} 尚未匹配车次卸货站;站点同名不代表已匹配。请到“分配与货物”点击“按货物匹配卸货站”,再重新核对容量。`
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
})
|
2026-10-03 04:35:55 +08:00
|
|
|
|
function apply(value: TripLoading | TripCapacityPlanning) {
|
|
|
|
|
|
info.value = value
|
|
|
|
|
|
const pallets = new Map<string, PalletRow>()
|
|
|
|
|
|
for (const cargo of value.cargo) {
|
|
|
|
|
|
if (!cargo.warehousePalletId) continue
|
|
|
|
|
|
const existing = pallets.get(cargo.warehousePalletId)
|
2026-10-09 11:43:38 +08:00
|
|
|
|
if (existing) { if (!existing.destinationKeys.includes(cargo.destinationKey || '')) existing.destinationKeys.push(cargo.destinationKey || ''); if (cargo.destinationLabel && !existing.destinations.split(' / ').includes(cargo.destinationLabel)) existing.destinations += ` / ${cargo.destinationLabel}`; continue }
|
2026-10-03 04:35:55 +08:00
|
|
|
|
const saved = ('capacityPlan' in value ? value.capacityPlan : value.confirmedLoad)?.pallets.find(row => row.warehousePalletId === cargo.warehousePalletId)
|
|
|
|
|
|
const index = props.trip.stops.findIndex(stop => !!cargo.destinationKey && stop.destinationKey === cargo.destinationKey)
|
2026-10-09 11:43:38 +08:00
|
|
|
|
pallets.set(cargo.warehousePalletId, { warehousePalletId: cargo.warehousePalletId, palletNo: cargo.palletNo || '—', destinations: cargo.destinationLabel || '—', destinationKeys: [cargo.destinationKey || ''],
|
|
|
|
|
|
palletSpaces: saved?.palletSpaces ?? 1, weightKg: saved?.weightKg ?? null, volumeCbm: saved?.volumeCbm ?? null, deliveryIndex: saved && saved.deliveryIndex > 0 && props.trip.stops[saved.deliveryIndex]?.destinationKey === cargo.destinationKey ? saved.deliveryIndex : (index > 0 ? index : null) })
|
2026-10-03 04:35:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
rows.value = [...pallets.values()]
|
2026-10-09 15:31:57 +08:00
|
|
|
|
baseline.value = JSON.stringify(rows.value)
|
2026-10-03 04:35:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
const current = ++sequence; loading.value = true; error.value = null
|
|
|
|
|
|
try { const result = await (props.planning ? getTripCapacityPlan(props.trip.tripNo) : getTripLoading(props.trip.tripNo)); if (current === sequence) apply(result) }
|
|
|
|
|
|
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量加载失败' : '装载信息加载失败') }
|
|
|
|
|
|
finally { if (current === sequence) loading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function save() {
|
2026-10-09 15:31:57 +08:00
|
|
|
|
if (busy.value || loading.value || !info.value) return
|
|
|
|
|
|
if (props.planning && 'planValid' in info.value && info.value.planValid && baseline.value === JSON.stringify(rows.value)) return info.value
|
|
|
|
|
|
if (!props.editable) { ElMessage.warning('计划容量需重新核对,请由有车次编辑权限的调度操作'); return }
|
2026-10-09 11:43:38 +08:00
|
|
|
|
if (destinationIssue.value) { ElMessage.warning(destinationIssue.value); return }
|
|
|
|
|
|
if (rows.value.some(row => !matchingStops(row).some(stop => stop.index === row.deliveryIndex))) { ElMessage.warning('请选择与货物目的地匹配的卸货站'); return }
|
2026-10-03 04:35:55 +08:00
|
|
|
|
if (!rows.value.length || rows.value.some(row => row.weightKg == null || row.volumeCbm == null || row.deliveryIndex == null)) { ElMessage.warning(props.planning ? '请填写每托预计重量、体积和卸货站' : '请填写每托实测重量、体积和卸货站'); return }
|
|
|
|
|
|
const current = sequence, snapshot = info.value
|
|
|
|
|
|
const pallets: TripMeasurement[] = rows.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces,
|
|
|
|
|
|
weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! }))
|
|
|
|
|
|
busy.value = true; error.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const body = { recordVersion: snapshot.recordVersion, manifestHash: snapshot.manifestHash, pallets, reason: props.planning ? '核对本车自有货计划载荷' : '核对实物托盘及实际装载' }
|
|
|
|
|
|
if (props.planning) {
|
|
|
|
|
|
const result = await confirmTripCapacityPlan(props.trip.tripNo, body)
|
2026-10-09 15:31:57 +08:00
|
|
|
|
if (current === sequence) { apply(result); emit('planned', result); if (!props.embedded) ElMessage.success('计划容量已核对'); return result }
|
2026-10-03 04:35:55 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
const result = await confirmTripLoading(props.trip.tripNo, body)
|
|
|
|
|
|
if (current === sequence) { apply(result); emit('saved', result); ElMessage.success('实际装载已确认') }
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量确认失败' : '装载确认失败') }
|
|
|
|
|
|
finally { busy.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
watch(() => [props.trip.tripNo, props.trip.recordVersion, props.planning], () => { if (info.value?.tripNo !== props.trip.tripNo || info.value.recordVersion !== props.trip.recordVersion || !!props.planning !== ('capacityPlan' in info.value)) void load() }, { immediate: true })
|
2026-10-09 15:31:57 +08:00
|
|
|
|
defineExpose({ save, destinationIssue })
|
2026-10-03 04:35:55 +08:00
|
|
|
|
onBeforeUnmount(() => { sequence++ })
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div v-loading="loading">
|
|
|
|
|
|
<LoadErrorState v-if="error" :title="planning ? '计划容量操作未完成' : '装载操作未完成'" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
|
|
|
|
|
<p v-if="planning">先核对本车自有货的预计载荷,查看每段剩余托位、载重和容积。实际装载完成后仍需按实测值确认。</p>
|
2026-10-09 11:43:38 +08:00
|
|
|
|
<p v-if="destinationIssue" role="alert">{{ destinationIssue }}</p>
|
2026-10-03 04:35:55 +08:00
|
|
|
|
<p v-if="stalePlan" role="status">货物明细已变化,请重新核对计划容量。</p>
|
|
|
|
|
|
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
|
|
|
|
|
|
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
|
|
|
|
|
|
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<div class="workbench-table"><DataTable class="trip-loading-table" :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
|
2026-10-11 11:17:08 +08:00
|
|
|
|
<template #palletNo="{ row }"><span class="trip-pallet-code">{{ row.palletNo }}</span></template>
|
2026-10-03 04:35:55 +08:00
|
|
|
|
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
|
|
|
|
|
|
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
|
|
|
|
|
|
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
|
2026-10-09 15:31:57 +08:00
|
|
|
|
<template #stop="{ row }"><span v-if="matchingStops(row as PalletRow).length === 1">{{ matchingStops(row as PalletRow)[0]?.name }}</span><el-select v-else v-model="row.deliveryIndex" :disabled="!editable || busy || loading || !matchingStops(row as PalletRow).length" placeholder="请先匹配货物目的地"><el-option v-for="stop in matchingStops(row as PalletRow)" :key="stop.index" :value="stop.index" :label="stop.name" /></el-select></template>
|
|
|
|
|
|
<template v-if="!embedded" #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="loading || !!info?.blockReason || !!destinationIssue || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
|
2026-10-20 23:34:11 +08:00
|
|
|
|
</DataTable></div>
|
2026-10-21 19:36:58 +08:00
|
|
|
|
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg ?? '—' }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm ?? '—' }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
|
2026-10-03 04:35:55 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-10-11 11:17:08 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.trip-pallet-code{white-space:normal;overflow-wrap:anywhere}
|
|
|
|
|
|
.trip-loading-table :deep(.el-input-number),.trip-loading-table :deep(.el-select){width:100%;min-width:0;max-width:100%}
|
|
|
|
|
|
</style>
|