diff --git a/src/modules/energy/ElectricOverview.tsx b/src/modules/energy/ElectricOverview.tsx index 89aad4a..00e48fb 100644 --- a/src/modules/energy/ElectricOverview.tsx +++ b/src/modules/energy/ElectricOverview.tsx @@ -12,6 +12,11 @@ function fmtYuan(yuan: number) { function fmtKwh(kwh: number) { return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`; } +function fmtMonth(month: string | undefined) { + if (!month) return '当前月'; + const [year, monthNumber] = month.split('-'); + return `${year}年${Number(monthNumber)}月`; +} export default function ElectricOverview() { const [data, setData] = useState(null); @@ -35,16 +40,20 @@ export default function ElectricOverview() { const trendData = data.trend; // 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份 const trendMonthLabel = trendData[0]?.date.slice(0, 7); - const currentMonth = new Date().toISOString().slice(0, 7); + const trendMonthText = fmtMonth(trendMonthLabel); + const now = new Date(); + const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth ? `${trendMonthLabel} 每日充电` : '本月每日充电'; const activeDays = trendData.filter(item => item.kwh > 0).length; - const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0; - const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0; + const trendKwh = trendData.reduce((sum, item) => sum + item.kwh, 0); + const trendFee = trendData.reduce((sum, item) => sum + item.fee, 0); + const avgDailyKwh = activeDays > 0 ? trendKwh / activeDays : 0; + const avgDailyFee = activeDays > 0 ? trendFee / activeDays : 0; const peakDay = trendData.reduce((best, item) => (!best || item.kwh > best.kwh ? item : best), null); const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0; - const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0; + const trendPrice = trendKwh > 0 ? trendFee / trendKwh : 0; const openDay = (date: string) => { const url = buildElectricDrillUrl( { pathname: window.location.pathname, search: window.location.search, hash: '#electric' }, @@ -62,19 +71,19 @@ export default function ElectricOverview() { return (
- 龙王路停车场充电站,期初 2025-01-01,手工导入每日更新 + 数据截至 {data.latestRecordAt ?? '暂无记录'} · 龙王路停车场充电站 · 手工导入
{/* 横向 mini KPI 头 */}
- - + + = 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
-
有效充电日
+
{trendMonthText}有效充电日
{activeDays}
日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度
@@ -85,12 +94,12 @@ export default function ElectricOverview() {
月度占比
-
{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%
-
本月费用 / 累计费用
+
{k.totalFee > 0 ? (trendFee / k.totalFee * 100).toFixed(1) : '0.0'}%
+
{trendMonthText}费用 / 累计费用
- {/* 本月每日充电柱图 */} + {/* 当前月无数据时展示最近一个有数据的自然月 */}
{chartTitle} diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 1f4ab56..48ee79f 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -50,6 +50,7 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: Customer export interface ElectricOverviewResponse { kpi: ElectricKpi; trend: ElectricDailyRow[]; + latestRecordAt: string | null; } export function fetchElectricOverview(): Promise { diff --git a/src/server/routes/analytics/index.test.ts b/src/server/routes/analytics/index.test.ts index a58affb..b336bcb 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, 4); + assert.equal(payload.catalogVersion, 5); assert.deepEqual(payload.domains, ['mileage']); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate')); }); @@ -27,10 +27,11 @@ test('returns the published electric metric contract', async () => { const payload = await response.json(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 4); + assert.equal(payload.catalogVersion, 5); 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')); + assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.data_freshness')); }); test('returns the published ETC metric contract', async () => { @@ -38,7 +39,7 @@ test('returns the published ETC metric contract', async () => { const payload = await response.json(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 4); + assert.equal(payload.catalogVersion, 5); assert.deepEqual(payload.domains, ['etc']); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.total_amount')); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable')); diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index acaca78..d6f73ae 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -527,7 +527,8 @@ app.get('/electric/overview', async (c) => { SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') THEN fee ELSE 0 END) AS monthFee, SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh, - SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee + SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee, + DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestRecordAt FROM bi_ele_charge_record`, ); const k = kpiRows[0] ?? {}; @@ -537,6 +538,7 @@ app.get('/electric/overview', async (c) => { const monthFee = Number(k.monthFee) || 0; const todayKwh = Number(k.todayKwh) || 0; const todayFee = Number(k.todayFee) || 0; + const latestRecordAt = k.latestRecordAt ? String(k.latestRecordAt) : null; // 本月每日(用于柱图) const [trendRows] = await pool.query( @@ -589,6 +591,7 @@ app.get('/electric/overview', async (c) => { return { kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct }, trend: trendArr, + latestRecordAt, }; }, { force }); return c.json(data); diff --git a/src/shared/analytics/catalog.test.ts b/src/shared/analytics/catalog.test.ts index 8d86d09..661139b 100644 --- a/src/shared/analytics/catalog.test.ts +++ b/src/shared/analytics/catalog.test.ts @@ -31,15 +31,16 @@ 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', () => { +test('publishes electric fee components, blended cost, and freshness semantics', () => { const metrics = listMetricDefinitions('electric'); const byId = new Map(metrics.map(metric => [metric.id, metric])); - assert.ok(metrics.length >= 6); + assert.ok(metrics.length >= 7); 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'); + assert.equal(byId.get('electric.data_freshness')?.timeSemantics, 'freshness'); }); test('publishes ETC passage and billing metrics as separate grains', () => { diff --git a/src/shared/analytics/catalog.ts b/src/shared/analytics/catalog.ts index 8e2b1b4..c0c4e14 100644 --- a/src/shared/analytics/catalog.ts +++ b/src/shared/analytics/catalog.ts @@ -17,7 +17,7 @@ export interface MetricDefinition { drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill'; } -export const METRIC_CATALOG_VERSION = 4; +export const METRIC_CATALOG_VERSION = 5; const MILEAGE_METRICS: readonly MetricDefinition[] = [ { @@ -273,6 +273,19 @@ const ELECTRIC_METRICS: readonly MetricDefinition[] = [ dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'], drillEntity: 'electric-charge-order', }, + { + id: 'electric.data_freshness', + domain: 'electric', + label: '电能数据截至时间', + description: '已导入充电订单中最后一条订单的业务开始时间,用于区分当期零业务与数据尚未更新。', + unit: 'timestamp', + aggregation: 'latest', + timeSemantics: 'freshness', + formula: 'MAX(start_time)', + sources: ['bi_ele_charge_record'], + dimensions: ['station', 'vehicle-kind'], + drillEntity: 'electric-charge-order', + }, ]; const ETC_METRICS: readonly MetricDefinition[] = [