refactor: expose electric data freshness
This commit is contained in:
@@ -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<ElectricOverviewResponse | null>(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<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 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 (
|
||||
<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">
|
||||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||||
数据截至 {data.latestRecordAt ?? '暂无记录'} · 龙王路停车场充电站 · 手工导入
|
||||
</div>
|
||||
{/* 横向 mini KPI 头 */}
|
||||
<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={CalendarClock} label={`${trendMonthText}充电费`} value={fmtYuan(trendFee)} helper={fmtKwh(trendKwh)} tone="emerald" />
|
||||
<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'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-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-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||||
</SurfaceCard>
|
||||
@@ -85,12 +94,12 @@ export default function ElectricOverview() {
|
||||
</SurfaceCard>
|
||||
<SurfaceCard className="p-3">
|
||||
<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-[11px] font-bold text-slate-500">本月费用 / 累计费用</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">{trendMonthText}费用 / 累计费用</div>
|
||||
</SurfaceCard>
|
||||
</div>
|
||||
|
||||
{/* 本月每日充电柱图 */}
|
||||
{/* 当前月无数据时展示最近一个有数据的自然月 */}
|
||||
<SurfaceCard className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||
|
||||
@@ -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<ElectricOverviewResponse> {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<RowDataPacket[]>(
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user