diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index 93b0725..b1d0060 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -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() { 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 {