feat: flag mileage target coverage gaps

This commit is contained in:
kkfluous
2026-08-07 17:52:49 +08:00
parent bca0f7fe82
commit d1ed3903fe
6 changed files with 74 additions and 2 deletions
+19 -1
View File
@@ -6,7 +6,7 @@ import {
Cell, LabelList,
} from 'recharts';
import {
Truck, ChevronDown, Maximize2, Minimize2,
AlertTriangle, Truck, ChevronDown, Maximize2, Minimize2,
Search, ArrowUpDown, X, RotateCcw, Calendar,
} from 'lucide-react';
import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types';
@@ -14,6 +14,7 @@ import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
import { weightedCompletionRate } from '../../shared/analytics/metrics';
import { formatMileageSnapshotDateTime } from './data-freshness';
import { assessVehicleCoverage } from './assessment-coverage';
import MileageAttributionView from './MileageAttributionView';
import {
filterAttributionVehicles,
@@ -94,6 +95,9 @@ export default function StatisticsView() {
const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCoverage = selectedTarget
? assessVehicleCoverage(selectedTarget.declaredVehicleCount, selectedTarget.vehicleCount)
: null;
const selectedAssessmentYear = selectedAssessment?.yearNumber;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
@@ -361,6 +365,20 @@ export default function StatisticsView() {
</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 && (
<MileageAttributionView
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,
};
}
+1
View File
@@ -65,6 +65,7 @@ export interface TargetSummary {
id: number;
targetName: string;
snapshotUpdatedAt: string | null;
declaredVehicleCount: number;
vehicleCount: number;
totalMileagePerVehicle: number;
annualMileagePerVehicle: number;
+2 -1
View File
@@ -201,7 +201,8 @@ app.get('/', async (c) => {
id: t.id,
targetName: t.target_name,
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),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,