refactor: define electric BI metric semantics

This commit is contained in:
kkfluous
2026-08-07 14:48:50 +08:00
parent 757ed1ebd6
commit cf9782562b
5 changed files with 114 additions and 10 deletions
+3 -4
View File
@@ -85,7 +85,6 @@ export default function ElectricDaily() {
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const hasFeeDetail = totalFee > 0;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
@@ -172,9 +171,9 @@ export default function ElectricDaily() {
<MetricTile
icon={Wallet}
label="充电费用"
value={hasFeeDetail ? `¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '待同步'}
helper={hasFeeDetail ? `均价 ${avgPrice.toFixed(2)} 元/度` : '当前明细仅返回充电量'}
tone={hasFeeDetail ? 'emerald' : 'slate'}
value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
helper={`综合费用强度 ${avgPrice.toFixed(2)} 元/度`}
tone="emerald"
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
+1 -1
View File
@@ -54,7 +54,7 @@ export default function ElectricOverview() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
<MetricTile icon={Gauge} label="综合费用强度" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度 · 含零费用订单`} tone="amber" />
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
</div>
+12 -1
View File
@@ -7,7 +7,7 @@ test('returns the published mileage metric contract', async () => {
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 2);
assert.equal(payload.catalogVersion, 3);
assert.deepEqual(payload.domains, ['mileage']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
});
@@ -22,6 +22,17 @@ test('returns the published hydrogen metric contract', async () => {
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'hydrogen.customer_order_gross_profit'));
});
test('returns the published electric metric contract', async () => {
const response = await app.request('/metrics?domain=electric');
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 3);
assert.deepEqual(payload.domains, ['electric']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.charge_total_fee'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.blended_cost_intensity'));
});
test('rejects unpublished metric domains', async () => {
const response = await app.request('/metrics?domain=energy');
+12
View File
@@ -31,8 +31,20 @@ test('publishes hydrogen cost, revenue, and gross profit as separate metrics', (
assert.equal(byId.get('hydrogen.customer_order_gross_profit')?.aggregation, 'derived');
});
test('publishes electric fee components and blended cost semantics', () => {
const metrics = listMetricDefinitions('electric');
const byId = new Map(metrics.map(metric => [metric.id, metric]));
assert.ok(metrics.length >= 6);
assert.equal(byId.get('electric.charge_total_fee')?.formula, 'SUM(fee)');
assert.equal(byId.get('electric.electricity_fee')?.formula, 'SUM(e_fee)');
assert.equal(byId.get('electric.service_fee')?.formula, 'SUM(service_fee)');
assert.equal(byId.get('electric.blended_cost_intensity')?.aggregation, 'weighted-ratio');
});
test('validates only published metric domains', () => {
assert.equal(isMetricDomain('mileage'), true);
assert.equal(isMetricDomain('hydrogen'), true);
assert.equal(isMetricDomain('electric'), true);
assert.equal(isMetricDomain('unknown'), false);
});
+86 -4
View File
@@ -1,5 +1,5 @@
export type MetricDomain = 'mileage' | 'hydrogen';
export type MetricUnit = 'km' | 'kg' | 'cny' | 'percent' | 'vehicle' | 'station' | 'order' | 'timestamp';
export type MetricDomain = 'mileage' | 'hydrogen' | 'electric';
export type MetricUnit = 'km' | 'kg' | 'kwh' | 'cny' | 'cny-per-kwh' | 'percent' | 'vehicle' | 'station' | 'order' | 'timestamp';
export type MetricAggregation = 'sum' | 'count' | 'derived' | 'weighted-ratio' | 'distinct-count' | 'latest';
export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness';
@@ -14,10 +14,10 @@ export interface MetricDefinition {
formula: string;
sources: readonly string[];
dimensions: readonly string[];
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order';
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order';
}
export const METRIC_CATALOG_VERSION = 2;
export const METRIC_CATALOG_VERSION = 3;
const MILEAGE_METRICS: readonly MetricDefinition[] = [
{
@@ -194,9 +194,91 @@ const HYDROGEN_METRICS: readonly MetricDefinition[] = [
},
];
const ELECTRIC_METRICS: readonly MetricDefinition[] = [
{
id: 'electric.charge_energy',
domain: 'electric',
label: '充电量',
description: '所选时间和车辆归属范围内,去重导入充电订单的电量之和。',
unit: 'kwh',
aggregation: 'sum',
timeSemantics: 'flow',
formula: 'SUM(kwh)',
sources: ['bi_ele_charge_record'],
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'],
drillEntity: 'electric-charge-order',
},
{
id: 'electric.charge_total_fee',
domain: 'electric',
label: '充电费用',
description: '导入账单的充电费用之和;当前数据中等于充电电费与充电服务费之和,零费用订单仍参与统计。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: 'SUM(fee)',
sources: ['bi_ele_charge_record'],
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'],
drillEntity: 'electric-charge-order',
},
{
id: 'electric.electricity_fee',
domain: 'electric',
label: '充电电费',
description: '导入账单中充电电费字段 e_fee 之和,不含服务费。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: 'SUM(e_fee)',
sources: ['bi_ele_charge_record'],
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate'],
drillEntity: 'electric-charge-order',
},
{
id: 'electric.service_fee',
domain: 'electric',
label: '充电服务费',
description: '导入账单中充电服务费字段 service_fee 之和。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: 'SUM(service_fee)',
sources: ['bi_ele_charge_record'],
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate'],
drillEntity: 'electric-charge-order',
},
{
id: 'electric.blended_cost_intensity',
domain: 'electric',
label: '综合费用强度',
description: '充电费用除以充电量;包含零费用订单,用于观察综合支出强度,不等同于合同电价或单笔订单单价。',
unit: 'cny-per-kwh',
aggregation: 'weighted-ratio',
timeSemantics: 'flow',
formula: 'SUM(fee) / NULLIF(SUM(kwh), 0)',
sources: ['bi_ele_charge_record'],
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer'],
drillEntity: 'electric-charge-order',
},
{
id: 'electric.charge_order_count',
domain: 'electric',
label: '充电订单数',
description: '导入充电账单中按订单编号去重后的订单数量。',
unit: 'order',
aggregation: 'distinct-count',
timeSemantics: 'flow',
formula: 'COUNT(DISTINCT order_no)',
sources: ['bi_ele_charge_record'],
dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'],
drillEntity: 'electric-charge-order',
},
];
export const METRIC_CATALOG: readonly MetricDefinition[] = [
...MILEAGE_METRICS,
...HYDROGEN_METRICS,
...ELECTRIC_METRICS,
];
export function isMetricDomain(value: string): value is MetricDomain {