From cec1e2d77c895e61d2308492185f51565dc150e1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 11:48:53 +0800 Subject: [PATCH] fix(fleet): validate pallet destination identity before capacity confirmation --- .../components/TripLoadingPanel.vue | 29 ++++++++++++++----- .../pages/TripsBehavior.test.ts | 24 +++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/modules/overseas-master-data/components/TripLoadingPanel.vue b/src/modules/overseas-master-data/components/TripLoadingPanel.vue index 89cf1d2d..d1fc2a1f 100644 --- a/src/modules/overseas-master-data/components/TripLoadingPanel.vue +++ b/src/modules/overseas-master-data/components/TripLoadingPanel.vue @@ -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(null), rows = ref([]), busy = ref(false), loading = ref(false), error = ref(null) let sequence = 0 const columns = computed(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 }, @@ -20,17 +20,29 @@ const columns = computed(() => [{ 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() 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++ })

先核对本车自有货的预计载荷,查看每段剩余托位、载重和容积。实际装载完成后仍需按实测值确认。

+

{{ destinationIssue }}

货物明细已变化,请重新核对计划容量。

{{ info.blockReason }}

已确认实际装载;车辆、司机或计划调整后需要重新确认。

@@ -74,8 +89,8 @@ onBeforeUnmount(() => { sequence++ }) - - + +
行驶区间占用托位重量(kg)体积(m³)剩余托位 / kg / m³
{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}
diff --git a/src/modules/overseas-master-data/pages/TripsBehavior.test.ts b/src/modules/overseas-master-data/pages/TripsBehavior.test.ts index d74ded12..1949376e 100644 --- a/src/modules/overseas-master-data/pages/TripsBehavior.test.ts +++ b/src/modules/overseas-master-data/pages/TripsBehavior.test.ts @@ -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() +})