refactor: publish mileage metric catalog
This commit is contained in:
@@ -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() }));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user