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:数据可信度 ### 阶段 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 type { TargetVehicle } from './types';
import { import {
buildMileageAttribution, buildMileageAttribution,
reconcileMileageAttribution,
type AttributionDimension, type AttributionDimension,
} from './attribution'; } from './attribution';
@@ -10,6 +11,9 @@ interface Props {
dimension: AttributionDimension; dimension: AttributionDimension;
selectedValue?: string; selectedValue?: string;
loading?: boolean; loading?: boolean;
targetMileagePerVehicle: number;
expectedShortfallMileage?: number;
assessmentLabel?: string;
onDimensionChange: (dimension: AttributionDimension) => void; onDimensionChange: (dimension: AttributionDimension) => void;
onSelect: (value: string) => void; onSelect: (value: string) => void;
} }
@@ -23,12 +27,18 @@ export default function MileageAttributionView({
dimension, dimension,
selectedValue, selectedValue,
loading = false, loading = false,
targetMileagePerVehicle,
expectedShortfallMileage,
assessmentLabel,
onDimensionChange, onDimensionChange,
onSelect, onSelect,
}: Props) { }: Props) {
const groups = buildMileageAttribution(vehicles, dimension); const groups = buildMileageAttribution(vehicles, dimension, targetMileagePerVehicle);
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0); const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
const totalBehind = groups.reduce((sum, group) => sum + group.behindCount, 0); const totalBehind = groups.reduce((sum, group) => sum + group.behindCount, 0);
const reconciliation = expectedShortfallMileage == null
? null
: reconcileMileageAttribution(groups, expectedShortfallMileage);
return ( return (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -36,11 +46,21 @@ export default function MileageAttributionView({
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-blue-600" /> <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> </div>
<p className="mt-1 text-[10px] font-bold text-slate-400"> <p className="mt-1 text-[10px] font-bold text-slate-400">
{totalBehind} {fmtKm(totalShortfall)} km {assessmentLabel ? `${assessmentLabel} · ` : ''}{totalBehind} {fmtKm(totalShortfall)} km
</p> </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>
<div className="flex rounded-lg bg-slate-100 p-1" aria-label="归因维度"> <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'}`}> <span className={`block text-[11px] font-black ${group.shortfallMileage > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(group.shortfallMileage)} {fmtKm(group.shortfallMileage)}
</span> </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> </span>
<ChevronRight size={14} className="text-slate-300" /> <ChevronRight size={14} className="text-slate-300" />
</button> </button>
+4 -6
View File
@@ -134,7 +134,7 @@ export default function StatisticsView() {
const loadCurrentTargetVehicles = useCallback((targetId: number) => { const loadCurrentTargetVehicles = useCallback((targetId: number) => {
if (targetVehicleRequestsRef.current.has(targetId)) return; if (targetVehicleRequestsRef.current.has(targetId)) return;
targetVehicleRequestsRef.current.add(targetId); targetVehicleRequestsRef.current.add(targetId);
fetchTargetVehicles(targetId, getDefaultDate()).then(vehicles => { fetchTargetVehicles(targetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles })); setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
}).catch(() => {}).finally(() => { }).catch(() => {}).finally(() => {
targetVehicleRequestsRef.current.delete(targetId); targetVehicleRequestsRef.current.delete(targetId);
@@ -266,11 +266,6 @@ export default function StatisticsView() {
// Re-fetch target vehicles when viewAllDate changes // Re-fetch target vehicles when viewAllDate changes
useEffect(() => { useEffect(() => {
if (viewAllTargetId === null) return; if (viewAllTargetId === null) return;
if (viewAllDate === getDefaultDate()) {
setViewAllHistoricalVehicles(null);
setViewAllLoading(false);
return;
}
setViewAllLoading(true); setViewAllLoading(true);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => { fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
setViewAllHistoricalVehicles(data); setViewAllHistoricalVehicles(data);
@@ -383,6 +378,9 @@ export default function StatisticsView() {
dimension={attributionDimension} dimension={attributionDimension}
selectedValue={selectedBreakdownValue} selectedValue={selectedBreakdownValue}
loading={!targetVehiclesMap[selectedTarget.id]} loading={!targetVehiclesMap[selectedTarget.id]}
targetMileagePerVehicle={selectedTarget.annualMileagePerVehicle * (selectedAssessmentYear || 1)}
expectedShortfallMileage={selectedAssessment?.remaining}
assessmentLabel={selectedAssessment?.label}
onDimensionChange={(dimension) => { onDimensionChange={(dimension) => {
setAttributionDimension(dimension); setAttributionDimension(dimension);
if (drillContext.level === 'breakdown') closeVehiclePanel(); if (drillContext.level === 'breakdown') closeVehiclePanel();
+23 -6
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { import {
buildMileageAttribution, buildMileageAttribution,
filterAttributionVehicles, filterAttributionVehicles,
reconcileMileageAttribution,
} from './attribution.js'; } from './attribution.js';
import type { TargetVehicle } from './types.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: '粤A1', department: '业务一部', todayMileage: 120, totalMileage: 1000, completionRate: 0.8, isOnline: true }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 40, totalMileage: 500, completionRate: 0.4 }), vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 40, totalMileage: 500, completionRate: 0.4 }),
vehicle({ plateNumber: '粤A3', department: '业务二部', todayMileage: 40, totalMileage: 400, completionRate: 0.2 }), vehicle({ plateNumber: '粤A3', department: '业务二部', todayMileage: 40, totalMileage: 400, completionRate: 0.2 }),
], 'department'); ], 'department', 1000);
const first = result.find(group => group.key === '业务一部'); const first = result.find(group => group.key === '业务一部');
assert.deepEqual(first, { assert.deepEqual(first, {
@@ -40,9 +41,10 @@ test('groups vehicles and calculates additive attribution metrics', () => {
behindCount: 1, behindCount: 1,
todayMileage: 160, todayMileage: 160,
totalMileage: 1500, totalMileage: 1500,
requiredMileage: 200, targetMileage: 2000,
shortfallMileage: 60, completedMileage: 1500,
averageCompletionRate: 60.00000000000001, shortfallMileage: 500,
completionRate: 75,
contributionRate: 80, contributionRate: 80,
}); });
}); });
@@ -50,14 +52,29 @@ test('groups vehicles and calculates additive attribution metrics', () => {
test('keeps unassigned vehicles visible and sorts largest shortfall first', () => { test('keeps unassigned vehicles visible and sorts largest shortfall first', () => {
const result = buildMileageAttribution([ const result = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: null, todayMileage: 0 }), vehicle({ plateNumber: '粤A1', department: null, todayMileage: 0 }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 80 }), vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 80, totalMileage: 100 }),
], 'department'); ], 'department', 100);
assert.equal(result[0].label, '未分配部门'); assert.equal(result[0].label, '未分配部门');
assert.equal(result[0].shortfallMileage, 100); assert.equal(result[0].shortfallMileage, 100);
assert.equal(result[0].zeroMileageCount, 1); 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', () => { test('filters a group using the same empty-value semantics as aggregation', () => {
const vehicles = [ const vehicles = [
vehicle({ plateNumber: '粤A1', customer: null }), vehicle({ plateNumber: '粤A1', customer: null }),
+41 -13
View File
@@ -11,12 +11,20 @@ export interface MileageAttributionGroup {
behindCount: number; behindCount: number;
todayMileage: number; todayMileage: number;
totalMileage: number; totalMileage: number;
requiredMileage: number; targetMileage: number;
completedMileage: number;
shortfallMileage: number; shortfallMileage: number;
averageCompletionRate: number; completionRate: number;
contributionRate: number; contributionRate: number;
} }
export interface MileageAttributionReconciliation {
matches: boolean;
attributedShortfall: number;
expectedShortfall: number;
difference: number;
}
const EMPTY_LABELS: Record<AttributionDimension, string> = { const EMPTY_LABELS: Record<AttributionDimension, string> = {
department: '未分配部门', department: '未分配部门',
customer: '未绑定客户', customer: '未绑定客户',
@@ -32,7 +40,9 @@ export function attributionValue(
export function buildMileageAttribution( export function buildMileageAttribution(
vehicles: TargetVehicle[], vehicles: TargetVehicle[],
dimension: AttributionDimension, dimension: AttributionDimension,
targetMileagePerVehicle: number,
): MileageAttributionGroup[] { ): MileageAttributionGroup[] {
const perVehicleTarget = Math.max(0, Number(targetMileagePerVehicle) || 0);
const totalTodayMileage = vehicles.reduce( const totalTodayMileage = vehicles.reduce(
(sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0), (sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0),
0, 0,
@@ -49,16 +59,14 @@ export function buildMileageAttribution(
return Array.from(grouped, ([key, members]) => { return Array.from(grouped, ([key, members]) => {
const todayMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0), 0); 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 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 shortfallMileage = members.reduce((sum, vehicle) => {
const required = Math.max(0, Number(vehicle.dailyRequiredMileage) || 0); const completed = Math.max(0, Number(vehicle.totalMileage) || 0);
const actual = Math.max(0, Number(vehicle.todayMileage) || 0); return sum + Math.max(0, perVehicleTarget - completed);
return sum + Math.max(0, required - actual);
}, 0); }, 0);
const completionTotal = members.reduce(
(sum, vehicle) => sum + Math.max(0, Number(vehicle.completionRate) || 0),
0,
);
return { return {
key, key,
@@ -67,13 +75,14 @@ export function buildMileageAttribution(
onlineCount: members.filter(vehicle => vehicle.isOnline).length, onlineCount: members.filter(vehicle => vehicle.isOnline).length,
zeroMileageCount: members.filter(vehicle => (Number(vehicle.todayMileage) || 0) <= 0).length, zeroMileageCount: members.filter(vehicle => (Number(vehicle.todayMileage) || 0) <= 0).length,
behindCount: members.filter(vehicle => ( behindCount: members.filter(vehicle => (
(Number(vehicle.todayMileage) || 0) < (Number(vehicle.dailyRequiredMileage) || 0) (Number(vehicle.totalMileage) || 0) < perVehicleTarget
)).length, )).length,
todayMileage, todayMileage,
totalMileage, totalMileage,
requiredMileage, targetMileage,
completedMileage,
shortfallMileage, shortfallMileage,
averageCompletionRate: members.length > 0 ? completionTotal / members.length * 100 : 0, completionRate: targetMileage > 0 ? completedMileage / targetMileage * 100 : 0,
contributionRate: totalTodayMileage > 0 ? todayMileage / totalTodayMileage * 100 : 0, contributionRate: totalTodayMileage > 0 ? todayMileage / totalTodayMileage * 100 : 0,
}; };
}).sort((a, b) => ( }).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( export function filterAttributionVehicles(
vehicles: TargetVehicle[], vehicles: TargetVehicle[],
dimension: AttributionDimension, dimension: AttributionDimension,