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