feat: flag mileage target coverage gaps
This commit is contained in:
@@ -171,6 +171,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
|
|
||||||
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
|
||||||
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
|
||||||
|
- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。
|
||||||
- 已完成电能数据截至时间和实际趋势月展示。
|
- 已完成电能数据截至时间和实际趋势月展示。
|
||||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||||
- 已完成氢能结构化故障状态与重试体验。
|
- 已完成氢能结构化故障状态与重试体验。
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Cell, LabelList,
|
Cell, LabelList,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import {
|
import {
|
||||||
Truck, ChevronDown, Maximize2, Minimize2,
|
AlertTriangle, Truck, ChevronDown, Maximize2, Minimize2,
|
||||||
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
Search, ArrowUpDown, X, RotateCcw, Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
|
||||||
@@ -14,6 +14,7 @@ import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
|||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
||||||
import { formatMileageSnapshotDateTime } from './data-freshness';
|
import { formatMileageSnapshotDateTime } from './data-freshness';
|
||||||
|
import { assessVehicleCoverage } from './assessment-coverage';
|
||||||
import MileageAttributionView from './MileageAttributionView';
|
import MileageAttributionView from './MileageAttributionView';
|
||||||
import {
|
import {
|
||||||
filterAttributionVehicles,
|
filterAttributionVehicles,
|
||||||
@@ -94,6 +95,9 @@ export default function StatisticsView() {
|
|||||||
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
const selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||||
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
||||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
||||||
|
const selectedCoverage = selectedTarget
|
||||||
|
? assessVehicleCoverage(selectedTarget.declaredVehicleCount, selectedTarget.vehicleCount)
|
||||||
|
: null;
|
||||||
const selectedAssessmentYear = selectedAssessment?.yearNumber;
|
const selectedAssessmentYear = selectedAssessment?.yearNumber;
|
||||||
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||||
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
||||||
@@ -361,6 +365,20 @@ export default function StatisticsView() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedTarget && selectedCoverage && selectedCoverage.status !== 'matched' && (
|
||||||
|
<div role="alert" className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-amber-900">
|
||||||
|
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-600" />
|
||||||
|
<div className="min-w-0 text-[11px] font-bold leading-5">
|
||||||
|
<span className="font-black">考核车辆配置不一致:</span>
|
||||||
|
目标 {selectedCoverage.declaredCount} 台,当前有效 {selectedCoverage.activeCount} 台,
|
||||||
|
{selectedCoverage.status === 'missing'
|
||||||
|
? `缺少 ${Math.abs(selectedCoverage.difference)} 台`
|
||||||
|
: `超出 ${selectedCoverage.difference} 台`}。
|
||||||
|
完成率、缺口和归因按当前有效车辆计算。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedTarget && (
|
{selectedTarget && (
|
||||||
<MileageAttributionView
|
<MileageAttributionView
|
||||||
vehicles={selectedTargetVehicles}
|
vehicles={selectedTargetVehicles}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { assessVehicleCoverage } from './assessment-coverage.js';
|
||||||
|
|
||||||
|
test('reports matched, missing, and excess assessment vehicles', () => {
|
||||||
|
assert.deepEqual(assessVehicleCoverage(50, 46), {
|
||||||
|
status: 'missing',
|
||||||
|
declaredCount: 50,
|
||||||
|
activeCount: 46,
|
||||||
|
difference: -4,
|
||||||
|
coverageRate: 92,
|
||||||
|
});
|
||||||
|
assert.equal(assessVehicleCoverage(40, 40).status, 'matched');
|
||||||
|
assert.equal(assessVehicleCoverage(40, 42).status, 'excess');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes invalid counts without inventing configured vehicles', () => {
|
||||||
|
assert.deepEqual(assessVehicleCoverage(Number.NaN, -2), {
|
||||||
|
status: 'matched',
|
||||||
|
declaredCount: 0,
|
||||||
|
activeCount: 0,
|
||||||
|
difference: 0,
|
||||||
|
coverageRate: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type AssessmentCoverageStatus = 'matched' | 'missing' | 'excess';
|
||||||
|
|
||||||
|
export interface AssessmentCoverage {
|
||||||
|
status: AssessmentCoverageStatus;
|
||||||
|
declaredCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
difference: number;
|
||||||
|
coverageRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(value: number): number {
|
||||||
|
return Math.max(0, Math.trunc(Number(value) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessVehicleCoverage(declaredCount: number, activeCount: number): AssessmentCoverage {
|
||||||
|
const declared = count(declaredCount);
|
||||||
|
const active = count(activeCount);
|
||||||
|
const difference = active - declared;
|
||||||
|
return {
|
||||||
|
status: difference === 0 ? 'matched' : difference < 0 ? 'missing' : 'excess',
|
||||||
|
declaredCount: declared,
|
||||||
|
activeCount: active,
|
||||||
|
difference,
|
||||||
|
coverageRate: declared > 0 ? active / declared * 100 : active === 0 ? 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ export interface TargetSummary {
|
|||||||
id: number;
|
id: number;
|
||||||
targetName: string;
|
targetName: string;
|
||||||
snapshotUpdatedAt: string | null;
|
snapshotUpdatedAt: string | null;
|
||||||
|
declaredVehicleCount: number;
|
||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
totalMileagePerVehicle: number;
|
totalMileagePerVehicle: number;
|
||||||
annualMileagePerVehicle: number;
|
annualMileagePerVehicle: number;
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ app.get('/', async (c) => {
|
|||||||
id: t.id,
|
id: t.id,
|
||||||
targetName: t.target_name,
|
targetName: t.target_name,
|
||||||
snapshotUpdatedAt: s.snapshot_updated_at || null,
|
snapshotUpdatedAt: s.snapshot_updated_at || null,
|
||||||
vehicleCount: Number(s.total) || t.vehicle_count,
|
declaredVehicleCount: Number(t.vehicle_count) || 0,
|
||||||
|
vehicleCount: s.total == null ? 0 : Number(s.total) || 0,
|
||||||
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
|
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
|
||||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||||
assessmentYears: t.assessment_years,
|
assessmentYears: t.assessment_years,
|
||||||
|
|||||||
Reference in New Issue
Block a user