0 ? 'rose' : 'slate'} />
diff --git a/src/modules/energy/ElectricOverview.tsx b/src/modules/energy/ElectricOverview.tsx
index db9609a..2264374 100644
--- a/src/modules/energy/ElectricOverview.tsx
+++ b/src/modules/energy/ElectricOverview.tsx
@@ -54,7 +54,7 @@ export default function ElectricOverview() {
-
+
= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
diff --git a/src/server/routes/analytics/index.test.ts b/src/server/routes/analytics/index.test.ts
index d2e4527..485382a 100644
--- a/src/server/routes/analytics/index.test.ts
+++ b/src/server/routes/analytics/index.test.ts
@@ -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');
diff --git a/src/shared/analytics/catalog.test.ts b/src/shared/analytics/catalog.test.ts
index f3efabd..ba1ea97 100644
--- a/src/shared/analytics/catalog.test.ts
+++ b/src/shared/analytics/catalog.test.ts
@@ -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);
});
diff --git a/src/shared/analytics/catalog.ts b/src/shared/analytics/catalog.ts
index c886772..6e6073e 100644
--- a/src/shared/analytics/catalog.ts
+++ b/src/shared/analytics/catalog.ts
@@ -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 {