refactor: publish mileage metric catalog

This commit is contained in:
kkfluous
2026-08-07 13:54:51 +08:00
parent 13e720c2c3
commit ce50265771
5 changed files with 177 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import eleRouter from './routes/ele/index.js';
import feedbackRouter from './routes/feedback/index.js'; import feedbackRouter from './routes/feedback/index.js';
import vehicleHeatmapRouter from './routes/vehicle-heatmap.js'; import vehicleHeatmapRouter from './routes/vehicle-heatmap.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js'; import hydrogenHeatmapRouter from './routes/hydrogen-heatmap.js';
import analyticsRouter from './routes/analytics/index.js';
import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import { ensureSchedulingTables } from './routes/scheduling/db-schema.js';
import authRouter from './auth/login.js'; import authRouter from './auth/login.js';
import { authMiddleware } from './auth/middleware.js'; import { authMiddleware } from './auth/middleware.js';
@@ -35,6 +36,7 @@ app.route('/api/ele', eleRouter);
app.route('/api/feedback', feedbackRouter); app.route('/api/feedback', feedbackRouter);
app.route('/api/vehicle-heatmap', vehicleHeatmapRouter); app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter); app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
app.route('/api/analytics', analyticsRouter);
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import app from './index.js';
test('returns the published mileage metric contract', async () => {
const response = await app.request('/metrics?domain=mileage');
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 1);
assert.deepEqual(payload.domains, ['mileage']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
});
test('rejects unpublished metric domains', async () => {
const response = await app.request('/metrics?domain=energy');
assert.equal(response.status, 400);
});
+29
View File
@@ -0,0 +1,29 @@
import { Hono } from 'hono';
import {
METRIC_CATALOG_VERSION,
isMetricDomain,
listMetricDefinitions,
type MetricDomain,
} from '../../../shared/analytics/catalog.js';
const app = new Hono();
app.get('/metrics', (c) => {
const requestedDomain = c.req.query('domain');
let domain: MetricDomain | undefined;
if (requestedDomain) {
if (!isMetricDomain(requestedDomain)) {
return c.json({ error: 'Unsupported metric domain', domain: requestedDomain }, 400);
}
domain = requestedDomain;
}
const metrics = listMetricDefinitions(domain);
return c.json({
catalogVersion: METRIC_CATALOG_VERSION,
domains: Array.from(new Set(metrics.map(metric => metric.domain))),
metrics,
});
});
export default app;
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
METRIC_CATALOG,
isMetricDomain,
listMetricDefinitions,
} from './catalog.js';
test('metric ids are unique and namespaced by domain', () => {
const ids = METRIC_CATALOG.map(metric => metric.id);
assert.equal(new Set(ids).size, ids.length);
assert.ok(METRIC_CATALOG.every(metric => metric.id.startsWith(`${metric.domain}.`)));
});
test('lists mileage metrics without exposing the catalog array', () => {
const metrics = listMetricDefinitions('mileage');
assert.ok(metrics.length >= 5);
assert.notEqual(metrics, METRIC_CATALOG);
assert.ok(metrics.every(metric => metric.domain === 'mileage'));
});
test('validates only published metric domains', () => {
assert.equal(isMetricDomain('mileage'), true);
assert.equal(isMetricDomain('unknown'), false);
});
+100
View File
@@ -0,0 +1,100 @@
export type MetricDomain = 'mileage';
export type MetricUnit = 'km' | 'percent' | 'vehicle' | 'timestamp';
export type MetricAggregation = 'sum' | 'weighted-ratio' | 'distinct-count' | 'latest';
export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness';
export interface MetricDefinition {
id: string;
domain: MetricDomain;
label: string;
description: string;
unit: MetricUnit;
aggregation: MetricAggregation;
timeSemantics: MetricTimeSemantics;
formula: string;
sources: readonly string[];
dimensions: readonly string[];
drillEntity: 'vehicle' | 'assessment-target';
}
export const METRIC_CATALOG_VERSION = 1;
const MILEAGE_METRICS: readonly MetricDefinition[] = [
{
id: 'mileage.daily_total',
domain: 'mileage',
label: '当日总里程',
description: '所选自然日内有效车辆日里程之和。',
unit: 'km',
aggregation: 'sum',
timeSemantics: 'flow',
formula: 'SUM(vehicle_daily_mileage_km)',
sources: ['OneOS mileage API'],
dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model', 'plate'],
drillEntity: 'vehicle',
},
{
id: 'mileage.period_total',
domain: 'mileage',
label: '区间总里程',
description: '所选自然日区间内有效车辆日里程之和。',
unit: 'km',
aggregation: 'sum',
timeSemantics: 'flow',
formula: 'SUM(vehicle_daily_mileage_km) OVER selected_date_range',
sources: ['OneOS mileage range API'],
dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model', 'plate'],
drillEntity: 'vehicle',
},
{
id: 'mileage.vehicle_count',
domain: 'mileage',
label: '监控车辆数',
description: '当前权限和筛选范围内的去重车辆数量。',
unit: 'vehicle',
aggregation: 'distinct-count',
timeSemantics: 'snapshot',
formula: 'COUNT(DISTINCT vehicle_id)',
sources: ['Asset database'],
dimensions: ['department', 'region', 'customer', 'vehicle-model'],
drillEntity: 'vehicle',
},
{
id: 'mileage.assessment_completion_rate',
domain: 'mileage',
label: '考核完成率',
description: '按目标里程加权汇总,不平均车辆或考核组的完成率。',
unit: 'percent',
aggregation: 'weighted-ratio',
timeSemantics: 'snapshot',
formula: 'SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100',
sources: ['Mileage assessment database'],
dimensions: ['assessment-year', 'assessment-target', 'department', 'customer', 'plate'],
drillEntity: 'assessment-target',
},
{
id: 'mileage.data_freshness',
domain: 'mileage',
label: '数据统计时间',
description: '当前选源协议最后一条有效里程数据的业务时间。',
unit: 'timestamp',
aggregation: 'latest',
timeSemantics: 'freshness',
formula: 'COALESCE(data_time, updated_at, calculated_at)',
sources: ['OneOS mileage API'],
dimensions: ['source-protocol', 'plate'],
drillEntity: 'vehicle',
},
];
export const METRIC_CATALOG: readonly MetricDefinition[] = MILEAGE_METRICS;
export function isMetricDomain(value: string): value is MetricDomain {
return METRIC_CATALOG.some(metric => metric.domain === value);
}
export function listMetricDefinitions(domain?: MetricDomain): MetricDefinition[] {
return METRIC_CATALOG
.filter(metric => !domain || metric.domain === domain)
.map(metric => ({ ...metric }));
}