feat: publish mileage assessment freshness

This commit is contained in:
kkfluous
2026-08-07 17:49:52 +08:00
parent 8a7ce7eccf
commit bca0f7fe82
6 changed files with 32 additions and 15 deletions
+1
View File
@@ -170,6 +170,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
### 阶段 C:数据可信度 ### 阶段 C:数据可信度
- 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。 - 里程年度归因使用考核台账 `current_mileage` 快照,按车辆目标封顶后加总完成量与缺口,并自动核对归因合计和目标汇总;带日期的 OneOS 里程只用于车辆实时明细。
- 里程考核目标接口发布车辆台账 `MAX(update_time)`,统计页使用真实日期和时间展示数据截至点,不再用页面访问日或考核结束日代替快照时间。
- 已完成电能数据截至时间和实际趋势月展示。 - 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。 - 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。 - 已完成氢能结构化故障状态与重试体验。
+2 -13
View File
@@ -13,6 +13,7 @@ import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint }
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api'; 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 MileageAttributionView from './MileageAttributionView'; import MileageAttributionView from './MileageAttributionView';
import { import {
filterAttributionVehicles, filterAttributionVehicles,
@@ -30,11 +31,6 @@ function getDefaultDate(): string {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
} }
function getCurrentDateLabel(): string {
const now = new Date();
return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
}
function fmtKm(value: number): string { function fmtKm(value: number): string {
if (value >= 10000) return (value / 10000).toFixed(2) + '万'; if (value >= 10000) return (value / 10000).toFixed(2) + '万';
return value.toLocaleString(); return value.toLocaleString();
@@ -53,12 +49,6 @@ function resolveAssessmentYear(target: TargetSummary, requestedYear?: number): n
return getTargetAssessment(target, requestedYear)?.yearNumber; return getTargetAssessment(target, requestedYear)?.yearNumber;
} }
function fmtDateLabel(date: string | null): string {
if (!date) return '';
const [year, month, day] = date.split('-');
return `${year}.${Number(month)}.${Number(day)}`;
}
function shortTargetName(name: string): string { function shortTargetName(name: string): string {
// Extract the number and a short description // Extract the number and a short description
const match = name.match(/(\d+)[辆台](.+)/); const match = name.match(/(\d+)[辆台](.+)/);
@@ -74,7 +64,6 @@ function shortTargetName(name: string): string {
} }
export default function StatisticsView() { export default function StatisticsView() {
const currentDateLabel = getCurrentDateLabel();
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => ( const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
parseMileageDrillContext(window.location.search) parseMileageDrillContext(window.location.search)
)); ));
@@ -642,7 +631,7 @@ export default function StatisticsView() {
<p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p> <p className="text-[8px] font-bold text-slate-400 uppercase tracking-wider">{assessment.label}</p>
<p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p> <p className="text-[10px] font-black text-emerald-600">{fmtKm(assessment.completed)} km</p>
<p className="text-[8px] font-bold text-slate-300"> <p className="text-[8px] font-bold text-slate-300">
{assessment.daysLeft === 0 ? fmtDateLabel(assessment.endDate) : currentDateLabel} {formatMileageSnapshotDateTime(target.snapshotUpdatedAt)}
</p> </p>
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
+7 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { formatMileageSnapshotTime } from './data-freshness.js'; import { formatMileageSnapshotDateTime, formatMileageSnapshotTime } from './data-freshness.js';
test('formats snapshot instants in Asia/Shanghai', () => { test('formats snapshot instants in Asia/Shanghai', () => {
assert.equal(formatMileageSnapshotTime('2026-08-07T07:45:06.000Z'), '15:45:06'); assert.equal(formatMileageSnapshotTime('2026-08-07T07:45:06.000Z'), '15:45:06');
@@ -10,3 +10,9 @@ test('keeps historical date snapshots explicit', () => {
assert.equal(formatMileageSnapshotTime('2026-08-06'), '2026-08-06'); assert.equal(formatMileageSnapshotTime('2026-08-06'), '2026-08-06');
assert.equal(formatMileageSnapshotTime(null), '--:--:--'); assert.equal(formatMileageSnapshotTime(null), '--:--:--');
}); });
test('formats assessment snapshot instants with the business date and minute', () => {
assert.equal(formatMileageSnapshotDateTime('2026-08-07T09:45:01.000Z'), '2026.8.7 17:45');
assert.equal(formatMileageSnapshotDateTime(null), '--');
assert.equal(formatMileageSnapshotDateTime('not-a-date'), 'not-a-date');
});
+18
View File
@@ -12,3 +12,21 @@ export function formatMileageSnapshotTime(value: string | null): string {
hourCycle: 'h23', hourCycle: 'h23',
}).format(date); }).format(date);
} }
export function formatMileageSnapshotDateTime(value: string | null): string {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
hourCycle: 'h23',
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find(item => item.type === type)?.value || '';
return `${part('year')}.${part('month')}.${part('day')} ${part('hour')}:${part('minute')}`;
}
+1
View File
@@ -64,6 +64,7 @@ export interface MonitoringData {
export interface TargetSummary { export interface TargetSummary {
id: number; id: number;
targetName: string; targetName: string;
snapshotUpdatedAt: string | null;
vehicleCount: number; vehicleCount: number;
totalMileagePerVehicle: number; totalMileagePerVehicle: number;
annualMileagePerVehicle: number; annualMileagePerVehicle: number;
+3 -1
View File
@@ -26,7 +26,8 @@ app.get('/', async (c) => {
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count, SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target, SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed, SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date MAX(current_year_assessment_end_date) as year_end_date,
DATE_FORMAT(MAX(update_time), '%Y-%m-%dT%H:%i:%s+08:00') as snapshot_updated_at
FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0 FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id GROUP BY target_id
`) as [any[], unknown]; `) as [any[], unknown];
@@ -199,6 +200,7 @@ app.get('/', async (c) => {
return { return {
id: t.id, id: t.id,
targetName: t.target_name, targetName: t.target_name,
snapshotUpdatedAt: s.snapshot_updated_at || null,
vehicleCount: Number(s.total) || t.vehicle_count, vehicleCount: Number(s.total) || t.vehicle_count,
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),