From d1ed3903fe5f6b370d87f13974f28dcb53c449bd Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 17:52:49 +0800 Subject: [PATCH] feat: flag mileage target coverage gaps --- docs/bi-refactor-roadmap.md | 1 + src/modules/mileage/StatisticsView.tsx | 20 +++++++++++++- .../mileage/assessment-coverage.test.ts | 25 ++++++++++++++++++ src/modules/mileage/assessment-coverage.ts | 26 +++++++++++++++++++ src/modules/mileage/types.ts | 1 + src/server/routes/mileage/targets.ts | 3 ++- 6 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/modules/mileage/assessment-coverage.test.ts create mode 100644 src/modules/mileage/assessment-coverage.ts diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index 253b799..d6e8477 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -171,6 +171,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。 - 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。 +- 里程考核接口区分目标声明台数与当前有效车辆数;两者不一致时页面明确标注缺少或超配数量,并说明完成率、缺口和归因的实际计算范围。 - 已完成电能数据截至时间和实际趋势月展示。 - 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。 - 已完成氢能结构化故障状态与重试体验。 diff --git a/src/modules/mileage/StatisticsView.tsx b/src/modules/mileage/StatisticsView.tsx index 06f3129..80640e3 100644 --- a/src/modules/mileage/StatisticsView.tsx +++ b/src/modules/mileage/StatisticsView.tsx @@ -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() { )} + {selectedTarget && selectedCoverage && selectedCoverage.status !== 'matched' && ( +
+ +
+ 考核车辆配置不一致: + 目标 {selectedCoverage.declaredCount} 台,当前有效 {selectedCoverage.activeCount} 台, + {selectedCoverage.status === 'missing' + ? `缺少 ${Math.abs(selectedCoverage.difference)} 台` + : `超出 ${selectedCoverage.difference} 台`}。 + 完成率、缺口和归因按当前有效车辆计算。 +
+
+ )} + {selectedTarget && ( { + 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, + }); +}); diff --git a/src/modules/mileage/assessment-coverage.ts b/src/modules/mileage/assessment-coverage.ts new file mode 100644 index 0000000..f85c334 --- /dev/null +++ b/src/modules/mileage/assessment-coverage.ts @@ -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, + }; +} diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index f629677..123c4b7 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -65,6 +65,7 @@ export interface TargetSummary { id: number; targetName: string; snapshotUpdatedAt: string | null; + declaredVehicleCount: number; vehicleCount: number; totalMileagePerVehicle: number; annualMileagePerVehicle: number; diff --git a/src/server/routes/mileage/targets.ts b/src/server/routes/mileage/targets.ts index 0e80115..1863d81 100644 --- a/src/server/routes/mileage/targets.ts +++ b/src/server/routes/mileage/targets.ts @@ -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,