refactor: expose electric data freshness

This commit is contained in:
kkfluous
2026-08-07 15:22:04 +08:00
parent 21f5e5388a
commit 0d849fcbb1
6 changed files with 46 additions and 18 deletions
+20 -11
View File
@@ -12,6 +12,11 @@ function fmtYuan(yuan: number) {
function fmtKwh(kwh: number) { function fmtKwh(kwh: number) {
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`; 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() { export default function ElectricOverview() {
const [data, setData] = useState<ElectricOverviewResponse | null>(null); const [data, setData] = useState<ElectricOverviewResponse | null>(null);
@@ -35,16 +40,20 @@ export default function ElectricOverview() {
const trendData = data.trend; const trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份 // 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7); 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 const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电` ? `${trendMonthLabel} 每日充电`
: '本月每日充电'; : '本月每日充电';
const activeDays = trendData.filter(item => item.kwh > 0).length; const activeDays = trendData.filter(item => item.kwh > 0).length;
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0; const trendKwh = trendData.reduce((sum, item) => sum + item.kwh, 0);
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 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<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null); const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0; 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 openDay = (date: string) => {
const url = buildElectricDrillUrl( const url = buildElectricDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#electric' }, { pathname: window.location.pathname, search: window.location.search, hash: '#electric' },
@@ -62,19 +71,19 @@ export default function ElectricOverview() {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400"> <div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
2025-01-01 {data.latestRecordAt ?? '暂无记录'} · ·
</div> </div>
{/* 横向 mini KPI 头 */} {/* 横向 mini KPI 头 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <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={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={CalendarClock} label={`${trendMonthText}充电费`} value={fmtYuan(trendFee)} helper={fmtKwh(trendKwh)} 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={`${trendMonthText} ${trendPrice.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'} /> <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> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<SurfaceCard className="p-3"> <SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div> <div className="text-[11px] font-black text-slate-400">{trendMonthText}</div>
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400"></span></div> <div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400"></span></div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} </div> <div className="mt-1 text-[11px] font-bold text-slate-500"> {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} </div>
</SurfaceCard> </SurfaceCard>
@@ -85,12 +94,12 @@ export default function ElectricOverview() {
</SurfaceCard> </SurfaceCard>
<SurfaceCard className="p-3"> <SurfaceCard className="p-3">
<div className="text-[11px] font-black text-slate-400"></div> <div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div> <div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (trendFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
<div className="mt-1 text-[11px] font-bold text-slate-500"> / </div> <div className="mt-1 text-[11px] font-bold text-slate-500">{trendMonthText} / </div>
</SurfaceCard> </SurfaceCard>
</div> </div>
{/* 本月每日充电柱图 */} {/* 当前月无数据时展示最近一个有数据的自然月 */}
<SurfaceCard className="p-4"> <SurfaceCard className="p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{chartTitle}</span> <span className="text-sm font-bold text-slate-700">{chartTitle}</span>
+1
View File
@@ -50,6 +50,7 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: Customer
export interface ElectricOverviewResponse { export interface ElectricOverviewResponse {
kpi: ElectricKpi; kpi: ElectricKpi;
trend: ElectricDailyRow[]; trend: ElectricDailyRow[];
latestRecordAt: string | null;
} }
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> { export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
+4 -3
View File
@@ -7,7 +7,7 @@ test('returns the published mileage metric contract', async () => {
const payload = await response.json(); const payload = await response.json();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 4); assert.equal(payload.catalogVersion, 5);
assert.deepEqual(payload.domains, ['mileage']); assert.deepEqual(payload.domains, ['mileage']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate')); 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(); const payload = await response.json();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 4); assert.equal(payload.catalogVersion, 5);
assert.deepEqual(payload.domains, ['electric']); 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.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.blended_cost_intensity'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.data_freshness'));
}); });
test('returns the published ETC metric contract', async () => { 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(); const payload = await response.json();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 4); assert.equal(payload.catalogVersion, 5);
assert.deepEqual(payload.domains, ['etc']); 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.total_amount'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable')); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable'));
+4 -1
View File
@@ -527,7 +527,8 @@ app.get('/electric/overview', async (c) => {
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee, 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 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`, FROM bi_ele_charge_record`,
); );
const k = kpiRows[0] ?? {}; const k = kpiRows[0] ?? {};
@@ -537,6 +538,7 @@ app.get('/electric/overview', async (c) => {
const monthFee = Number(k.monthFee) || 0; const monthFee = Number(k.monthFee) || 0;
const todayKwh = Number(k.todayKwh) || 0; const todayKwh = Number(k.todayKwh) || 0;
const todayFee = Number(k.todayFee) || 0; const todayFee = Number(k.todayFee) || 0;
const latestRecordAt = k.latestRecordAt ? String(k.latestRecordAt) : null;
// 本月每日(用于柱图) // 本月每日(用于柱图)
const [trendRows] = await pool.query<RowDataPacket[]>( const [trendRows] = await pool.query<RowDataPacket[]>(
@@ -589,6 +591,7 @@ app.get('/electric/overview', async (c) => {
return { return {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct }, kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr, trend: trendArr,
latestRecordAt,
}; };
}, { force }); }, { force });
return c.json(data); return c.json(data);
+3 -2
View File
@@ -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'); 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 metrics = listMetricDefinitions('electric');
const byId = new Map(metrics.map(metric => [metric.id, metric])); 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.charge_total_fee')?.formula, 'SUM(fee)');
assert.equal(byId.get('electric.electricity_fee')?.formula, 'SUM(e_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.service_fee')?.formula, 'SUM(service_fee)');
assert.equal(byId.get('electric.blended_cost_intensity')?.aggregation, 'weighted-ratio'); 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', () => { test('publishes ETC passage and billing metrics as separate grains', () => {
+14 -1
View File
@@ -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'; 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[] = [ 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'], dimensions: ['date', 'station', 'region', 'operating-company', 'vehicle-kind', 'customer', 'plate', 'order-status'],
drillEntity: 'electric-charge-order', 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[] = [ const ETC_METRICS: readonly MetricDefinition[] = [