From 2de417e109d278071e9092e4a4ad139396d1b851 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 17:42:21 +0800 Subject: [PATCH] fix: reconcile mileage attribution totals --- docs/bi-refactor-roadmap.md | 1 + .../mileage/MileageAttributionView.tsx | 28 ++++++++-- src/modules/mileage/StatisticsView.tsx | 10 ++-- src/modules/mileage/attribution.test.ts | 29 +++++++--- src/modules/mileage/attribution.ts | 54 ++++++++++++++----- 5 files changed, 93 insertions(+), 29 deletions(-) diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index fcb4477..bd0580d 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -169,6 +169,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) ### 阶段 C:数据可信度 +- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。 - 已完成电能数据截至时间和实际趋势月展示。 - 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。 - 已完成氢能结构化故障状态与重试体验。 diff --git a/src/modules/mileage/MileageAttributionView.tsx b/src/modules/mileage/MileageAttributionView.tsx index a349020..2bf0e2c 100644 --- a/src/modules/mileage/MileageAttributionView.tsx +++ b/src/modules/mileage/MileageAttributionView.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, Building2, ChevronRight, Users } from 'lucide-react'; import type { TargetVehicle } from './types'; import { buildMileageAttribution, + reconcileMileageAttribution, type AttributionDimension, } from './attribution'; @@ -10,6 +11,9 @@ interface Props { dimension: AttributionDimension; selectedValue?: string; loading?: boolean; + targetMileagePerVehicle: number; + expectedShortfallMileage?: number; + assessmentLabel?: string; onDimensionChange: (dimension: AttributionDimension) => void; onSelect: (value: string) => void; } @@ -23,12 +27,18 @@ export default function MileageAttributionView({ dimension, selectedValue, loading = false, + targetMileagePerVehicle, + expectedShortfallMileage, + assessmentLabel, onDimensionChange, onSelect, }: Props) { - const groups = buildMileageAttribution(vehicles, dimension); + const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle); const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0); const totalBehind = groups.reduce((sum, group) => sum + group.behindCount, 0); + const reconciliation = expectedShortfallMileage == null + ? null + : reconcileMileageAttribution(groups, expectedShortfallMileage); return (
@@ -36,11 +46,21 @@ export default function MileageAttributionView({
-

任务缺口归因

+

考核缺口归因

- {totalBehind} 台低于当日任务,缺口 {fmtKm(totalShortfall)} km + {assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} 台未达标,累计缺口 {fmtKm(totalShortfall)} km

+ {!loading && reconciliation && ( +

+ {reconciliation.matches + ? '与考核目标汇总一致' + : `与考核目标汇总相差 ${reconciliation.difference > 0 ? '+' : ''}${fmtKm(reconciliation.difference)} km`} +

+ )}
{([ @@ -97,7 +117,7 @@ export default function MileageAttributionView({ 0 ? 'text-rose-600' : 'text-emerald-600'}`}> {fmtKm(group.shortfallMileage)} - 缺口 · {group.behindCount} 台 + 累计缺口 · {group.behindCount} 台 diff --git a/src/modules/mileage/StatisticsView.tsx b/src/modules/mileage/StatisticsView.tsx index 1611749..094f545 100644 --- a/src/modules/mileage/StatisticsView.tsx +++ b/src/modules/mileage/StatisticsView.tsx @@ -134,7 +134,7 @@ export default function StatisticsView() { const loadCurrentTargetVehicles = useCallback((targetId: number) => { if (targetVehicleRequestsRef.current.has(targetId)) return; targetVehicleRequestsRef.current.add(targetId); - fetchTargetVehicles(targetId, getDefaultDate()).then(vehicles => { + fetchTargetVehicles(targetId).then(vehicles => { setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles })); }).catch(() => {}).finally(() => { targetVehicleRequestsRef.current.delete(targetId); @@ -266,11 +266,6 @@ export default function StatisticsView() { // Re-fetch target vehicles when viewAllDate changes useEffect(() => { if (viewAllTargetId === null) return; - if (viewAllDate === getDefaultDate()) { - setViewAllHistoricalVehicles(null); - setViewAllLoading(false); - return; - } setViewAllLoading(true); fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => { setViewAllHistoricalVehicles(data); @@ -383,6 +378,9 @@ export default function StatisticsView() { dimension={attributionDimension} selectedValue={selectedBreakdownValue} loading={!targetVehiclesMap[selectedTarget.id]} + targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)} + expectedShortfallMileage={selectedAssessment?.remaining} + assessmentLabel={selectedAssessment?.label} onDimensionChange={(dimension) => { setAttributionDimension(dimension); if (drillContext.level === 'breakdown') closeVehiclePanel(); diff --git a/src/modules/mileage/attribution.test.ts b/src/modules/mileage/attribution.test.ts index ed1a6fe..f2efbe4 100644 --- a/src/modules/mileage/attribution.test.ts +++ b/src/modules/mileage/attribution.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildMileageAttribution, filterAttributionVehicles, + reconcileMileageAttribution, } from './attribution.js'; import type { TargetVehicle } from './types.js'; @@ -28,7 +29,7 @@ test('groups vehicles and calculates additive attribution metrics', () => { vehicle({ plateNumber: '粤A1', department: '业务一部', todayMileage: 120, totalMileage: 1000, completionRate: 0.8, isOnline: true }), vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 40, totalMileage: 500, completionRate: 0.4 }), vehicle({ plateNumber: '粤A3', department: '业务二部', todayMileage: 40, totalMileage: 400, completionRate: 0.2 }), - ], 'department'); + ], 'department', 1000); const first = result.find(group => group.key === '业务一部'); assert.deepEqual(first, { @@ -40,9 +41,10 @@ test('groups vehicles and calculates additive attribution metrics', () => { behindCount: 1, todayMileage: 160, totalMileage: 1500, - requiredMileage: 200, - shortfallMileage: 60, - averageCompletionRate: 60.00000000000001, + targetMileage: 2000, + completedMileage: 1500, + shortfallMileage: 500, + completionRate: 75, contributionRate: 80, }); }); @@ -50,14 +52,29 @@ test('groups vehicles and calculates additive attribution metrics', () => { test('keeps unassigned vehicles visible and sorts largest shortfall first', () => { const result = buildMileageAttribution([ vehicle({ plateNumber: '粤A1', department: null, todayMileage: 0 }), - vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 80 }), - ], 'department'); + vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 80, totalMileage: 100 }), + ], 'department', 100); assert.equal(result[0].label, '未分配部门'); assert.equal(result[0].shortfallMileage, 100); assert.equal(result[0].zeroMileageCount, 1); }); +test('reconciles additive group shortfall with the target summary at display precision', () => { + const groups = buildMileageAttribution([ + vehicle({ plateNumber: '粤A1', department: '业务一部', totalMileage: 1200 }), + vehicle({ plateNumber: '粤A2', department: '业务二部', totalMileage: 499.96 }), + ], 'department', 1000); + + assert.deepEqual(reconcileMileageAttribution(groups, 500), { + matches: true, + attributedShortfall: 500, + expectedShortfall: 500, + difference: 0, + }); + assert.equal(reconcileMileageAttribution(groups, 450).difference, 50); +}); + test('filters a group using the same empty-value semantics as aggregation', () => { const vehicles = [ vehicle({ plateNumber: '粤A1', customer: null }), diff --git a/src/modules/mileage/attribution.ts b/src/modules/mileage/attribution.ts index 3aa5419..2d0c533 100644 --- a/src/modules/mileage/attribution.ts +++ b/src/modules/mileage/attribution.ts @@ -11,12 +11,20 @@ export interface MileageAttributionGroup { behindCount: number; todayMileage: number; totalMileage: number; - requiredMileage: number; + targetMileage: number; + completedMileage: number; shortfallMileage: number; - averageCompletionRate: number; + completionRate: number; contributionRate: number; } +export interface MileageAttributionReconciliation { + matches: boolean; + attributedShortfall: number; + expectedShortfall: number; + difference: number; +} + const EMPTY_LABELS: Record = { department: '未分配部门', customer: '未绑定客户', @@ -32,7 +40,9 @@ export function attributionValue( export function buildMileageAttribution( vehicles: TargetVehicle[], dimension: AttributionDimension, + targetMileagePerVehicle: number, ): MileageAttributionGroup[] { + const perVehicleTarget = Math.max(0, Number(targetMileagePerVehicle) || 0); const totalTodayMileage = vehicles.reduce( (sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0), 0, @@ -49,16 +59,14 @@ export function buildMileageAttribution( return Array.from(grouped, ([key, members]) => { const todayMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0), 0); const totalMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.totalMileage) || 0), 0); - const requiredMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.dailyRequiredMileage) || 0), 0); + const targetMileage = perVehicleTarget * members.length; + const completedMileage = members.reduce((sum, vehicle) => ( + sum + Math.min(Math.max(0, Number(vehicle.totalMileage) || 0), perVehicleTarget) + ), 0); const shortfallMileage = members.reduce((sum, vehicle) => { - const required = Math.max(0, Number(vehicle.dailyRequiredMileage) || 0); - const actual = Math.max(0, Number(vehicle.todayMileage) || 0); - return sum + Math.max(0, required - actual); + const completed = Math.max(0, Number(vehicle.totalMileage) || 0); + return sum + Math.max(0, perVehicleTarget - completed); }, 0); - const completionTotal = members.reduce( - (sum, vehicle) => sum + Math.max(0, Number(vehicle.completionRate) || 0), - 0, - ); return { key, @@ -67,13 +75,14 @@ export function buildMileageAttribution( onlineCount: members.filter(vehicle => vehicle.isOnline).length, zeroMileageCount: members.filter(vehicle => (Number(vehicle.todayMileage) || 0) <= 0).length, behindCount: members.filter(vehicle => ( - (Number(vehicle.todayMileage) || 0) < (Number(vehicle.dailyRequiredMileage) || 0) + (Number(vehicle.totalMileage) || 0) < perVehicleTarget )).length, todayMileage, totalMileage, - requiredMileage, + targetMileage, + completedMileage, shortfallMileage, - averageCompletionRate: members.length > 0 ? completionTotal / members.length * 100 : 0, + completionRate: targetMileage > 0 ? completedMileage / targetMileage * 100 : 0, contributionRate: totalTodayMileage > 0 ? todayMileage / totalTodayMileage * 100 : 0, }; }).sort((a, b) => ( @@ -84,6 +93,25 @@ export function buildMileageAttribution( )); } +function roundMileage(value: number): number { + return Math.round((Number(value) || 0) * 10) / 10; +} + +export function reconcileMileageAttribution( + groups: MileageAttributionGroup[], + expectedShortfall: number, +): MileageAttributionReconciliation { + const attributedShortfall = roundMileage(groups.reduce((sum, group) => sum + group.shortfallMileage, 0)); + const normalizedExpected = roundMileage(expectedShortfall); + const difference = roundMileage(attributedShortfall - normalizedExpected); + return { + matches: difference === 0, + attributedShortfall, + expectedShortfall: normalizedExpected, + difference, + }; +} + export function filterAttributionVehicles( vehicles: TargetVehicle[], dimension: AttributionDimension,