fix: reconcile mileage attribution totals
This commit is contained in:
@@ -169,6 +169,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
||||
|
||||
### 阶段 C:数据可信度
|
||||
|
||||
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
||||
- 已完成电能数据截至时间和实际趋势月展示。
|
||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||
- 已完成氢能结构化故障状态与重试体验。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user