fix: reconcile mileage attribution totals

This commit is contained in:
kkfluous
2026-08-07 17:42:21 +08:00
parent 67a269651e
commit 2de417e109
5 changed files with 93 additions and 29 deletions
+1
View File
@@ -169,6 +169,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
### 阶段 C:数据可信度
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
+24 -4
View File
@@ -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 (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -36,11 +46,21 @@ export default function MileageAttributionView({
<div>
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-blue-600" />
<h2 className="text-sm font-black text-slate-800"></h2>
<h2 className="text-sm font-black text-slate-800"></h2>
</div>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{totalBehind} {fmtKm(totalShortfall)} km
{assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} {fmtKm(totalShortfall)} km
</p>
{!loading && reconciliation && (
<p
role={reconciliation.matches ? 'status' : 'alert'}
className={`mt-1 text-[10px] font-black ${reconciliation.matches ? 'text-emerald-600' : 'text-rose-600'}`}
>
{reconciliation.matches
? '与考核目标汇总一致'
: `与考核目标汇总相差 ${reconciliation.difference > 0 ? '+' : ''}${fmtKm(reconciliation.difference)} km`}
</p>
)}
</div>
<div className="flex rounded-lg bg-slate-100 p-1" aria-label="归因维度">
{([
@@ -97,7 +117,7 @@ export default function MileageAttributionView({
<span className={`block text-[11px] font-black ${group.shortfallMileage > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(group.shortfallMileage)}
</span>
<span className="block text-[9px] font-bold text-slate-400"> · {group.behindCount} </span>
<span className="block text-[9px] font-bold text-slate-400"> · {group.behindCount} </span>
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
+4 -6
View File
@@ -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();
+23 -6
View File
@@ -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 }),
+41 -13
View File
@@ -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<AttributionDimension, string> = {
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,