Compare commits

...
2 changed files with 46 additions and 7 deletions
@@ -10,7 +10,7 @@ import type { FleetTrip, TripLoading, TripCapacityPlanning, TripMeasurement } fr
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean }>()
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning] }>()
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
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 columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 },
@@ -20,17 +20,29 @@ const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'pal
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 (cargo.destinationLabel && !existing.destinations.split(' / ').includes(cargo.destinationLabel)) existing.destinations += ` / ${cargo.destinationLabel}`; continue }
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 || '—',
palletSpaces: saved?.palletSpaces ?? 1, weightKg: saved?.weightKg ?? null, volumeCbm: saved?.volumeCbm ?? null, deliveryIndex: saved?.deliveryIndex ?? (index > 0 ? index : null) })
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()]
}
@@ -41,7 +53,9 @@ async function load() {
finally { if (current === sequence) loading.value = false }
}
async function save() {
if (busy.value || !info.value) return
if (busy.value || loading.value || !props.editable || !info.value) 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,
@@ -66,6 +80,7 @@ onBeforeUnmount(() => { sequence++ })
<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>
@@ -74,8 +89,8 @@ onBeforeUnmount(() => { sequence++ })
<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 }"><el-select v-model="row.deliveryIndex" :disabled="!editable || busy" placeholder="选择卸货站"><el-option v-for="(stop, index) in trip.stops.slice(1)" :key="index" :value="index + 1" :label="stop.name" /></el-select></template>
<template #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="!!info?.blockReason || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
<template #stop="{ row }"><el-select 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 #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>
<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>
@@ -176,3 +176,27 @@ it('opens the loading review section from the dispatch list deep link', async ()
expect(wrapper.findComponent({ name: 'TripLoadingPanel' }).exists()).toBe(true)
expect(wrapper.text()).toContain('理货审核')
})
it.each([null, 'FBA:OTHER'])('blocks a same-name ACY2 stop with an unmatched identity %s before submitting', async (destinationKey) => {
const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'D0009-101-001', packages: 10, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }
const basis = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
vi.mocked(api.getTripCapacityPlan).mockResolvedValue(basis)
const currentTrip = { ...trip, stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey }] }
const wrapper = mount(TripLoadingPanel, { trip: currentTrip, editable: true, planning: true }); await flushPromises()
expect(wrapper.text()).toContain('站点同名不代表已匹配')
expect(wrapper.text()).toContain('按货物匹配卸货站')
expect(button(wrapper, '确认计划容量').attributes('disabled')).toBeDefined()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].deliveryIndex).toBeNull()
await button(wrapper, '确认计划容量').trigger('click')
expect(api.confirmTripCapacityPlan).not.toHaveBeenCalled()
await wrapper.setProps({ trip: { ...currentTrip, recordVersion: 4, stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey: 'FBA:ACY2' }] } }); await flushPromises()
expect(wrapper.text()).not.toContain('站点同名不代表已匹配')
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].deliveryIndex).toBe(1)
})
it('blocks a shared pallet with different identities even when both labels are ACY2', async () => {
const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }
vi.mocked(api.getTripCapacityPlan).mockResolvedValue({ tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo, { ...cargo, destinationKey: 'WAREHOUSE:ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] })
const wrapper = mount(TripLoadingPanel, { trip: { ...trip, stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey: 'FBA:ACY2' }] }, editable: true, planning: true }); await flushPromises()
expect(wrapper.text()).toContain('包含不同目的地,请先拆托')
expect(button(wrapper, '确认计划容量').attributes('disabled')).toBeDefined()
})