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,