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

115 lines
11 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, 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'
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean; embedded?: boolean }>()
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning]; changed: [value: string] }>()
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; destinationKeys: string[]; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<PalletRow[]>([]), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let sequence = 0
const baseline = ref('')
watch(rows, value => emit('changed', JSON.stringify(value)), { deep: true })
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 },
])
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)
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 ''
})
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)
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 }
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)
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) })
}
rows.value = [...pallets.values()]
baseline.value = JSON.stringify(rows.value)
}
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() {
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 }
if (destinationIssue.value) { ElMessage.warning(destinationIssue.value); return }
if (rows.value.some(row => !matchingStops(row).some(stop => stop.index === row.deliveryIndex))) { ElMessage.warning('请选择与货物目的地匹配的卸货站'); return }
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)
if (current === sequence) { apply(result); emit('planned', result); if (!props.embedded) ElMessage.success('计划容量已核对'); return result }
} 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 })
defineExpose({ save, destinationIssue })
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>
<p v-if="destinationIssue" role="alert">{{ destinationIssue }}</p>
<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>
<div class="workbench-table"><DataTable class="trip-loading-table" :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
<template #palletNo="{ row }"><span class="trip-pallet-code">{{ row.palletNo }}</span></template>
<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>
<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>
</DataTable></div>
<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>
</div>
</template>
<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>