refactor: expose ETC integration readiness

This commit is contained in:
kkfluous
2026-08-07 15:00:58 +08:00
parent cdf1fd27e7
commit 0fb62135f3
8 changed files with 363 additions and 76 deletions
+102 -69
View File
@@ -1,79 +1,112 @@
import { motion } from 'motion/react'; import { useEffect, useState } from 'react';
import { Construction, Hammer } from 'lucide-react'; import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import { fetchEtcOverview } from './api';
import type { EtcOverviewResponse } from './types';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const ETC_HINTS = [ function fmtMoney(value: number): string {
'ETC 通行费数据正在与发卡方系统打通…', return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
'工人 GG 正在搭脚手架,敬请期待 ~', }
'马上能看到每月通行费明细啦',
'想看哪个维度的 ETC?反馈一下嘛', function syncStatusLabel(status: EtcOverviewResponse['integration']['lastSyncStatus']): string {
'上线时机:等数据接通的那一天', if (status === 'success') return '同步成功';
]; if (status === 'failed') return '同步失败';
if (status === 'partial') return '部分成功';
if (status === 'never') return '尚未同步';
return '状态未知';
}
export default function ETCView() { export default function ETCView() {
const [data, setData] = useState<EtcOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const load = async (force = false) => {
if (force) setRefreshing(true);
setError(null);
try {
setData(await fetchEtcOverview(force));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setRefreshing(false);
}
};
useEffect(() => {
void load();
}, []);
if (error && !data) return <ErrorState message={error} />;
if (!data) return <LoadingState label="正在检查 ETC 接入状态" />;
const { integration, kpi } = data;
const hasData = kpi.passageCount > 0;
const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
<motion.div <SurfaceCard className="flex items-center gap-3 p-3">
initial={{ opacity: 0, y: 12 }} <span className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${integration.providerConfigured ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
animate={{ opacity: 1, y: 0 }} {integration.providerConfigured ? <CircleCheck size={18} /> : <CircleX size={18} />}
transition={{ duration: 0.3 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="relative w-20 h-20 mb-4">
<motion.div
animate={{ rotate: [0, -8, 8, -4, 4, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
className="absolute inset-0 rounded-3xl bg-gradient-to-br from-amber-50 to-orange-50 flex items-center justify-center"
>
<Construction size={36} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
<motion.div
animate={{ rotate: [0, 18, -10, 0], y: [0, -2, 1, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-1 -right-1 w-9 h-9 rounded-2xl bg-white border border-amber-100 shadow-sm flex items-center justify-center"
>
<Hammer size={16} className="text-amber-500" strokeWidth={2.2} />
</motion.div>
</div>
<div className="text-base font-black text-slate-800 mb-1.5">ETC </div>
<div className="text-[12px] text-slate-500 font-bold leading-relaxed max-w-[280px]">
<br />
</div>
{/* 简单的里程碑进度感 */}
<div className="mt-6 w-full max-w-xs space-y-2">
{[
{ label: '需求评审', done: true },
{ label: '数据对接', done: true },
{ label: '页面开发', done: false, current: true },
{ label: '正式上线', done: false },
].map((m, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + i * 0.08, duration: 0.3 }}
className="flex items-center gap-2.5 text-[11px]"
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${
m.done ? 'bg-emerald-400'
: m.current ? 'bg-amber-400 ring-4 ring-amber-100 animate-pulse'
: 'bg-slate-200'
}`} />
<span className={`font-bold ${m.done ? 'text-slate-500' : m.current ? 'text-amber-600' : 'text-slate-300'}`}>
{m.label}
</span> </span>
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto"></span>} <div className="min-w-0 flex-1">
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto"></span>} <div className="text-xs font-black text-slate-800">
</motion.div> {integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
))}
</div> </div>
</motion.div> <div className="mt-1 truncate text-[10px] font-bold text-slate-400">
{integration.providerConfigured
? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}`
: '数据库结构已准备,等待配置同步账户并完成首次同步'}
</div>
</div>
<button
type="button"
onClick={() => void load(true)}
disabled={refreshing}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-blue-600 disabled:opacity-50"
aria-label="刷新 ETC 状态"
title="刷新 ETC 状态"
>
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
</button>
</SurfaceCard>
<RotatingFooterHint hints={ETC_HINTS} /> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} />
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" />
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" />
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
</div>
<div className="grid gap-3 md:grid-cols-3">
<SurfaceCard className="p-3">
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-1 text-sm font-black ${integration.providerConfigured ? 'text-emerald-600' : 'text-amber-600'}`}>
{integration.providerConfigured ? '已配置' : '未配置'}
</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="text-[10px] font-black text-slate-400"></div>
<div className={`mt-1 text-sm font-black ${integration.syncEnabled ? 'text-emerald-600' : 'text-slate-500'}`}>
{integration.syncEnabled ? '已启用' : '未启用'}
</div>
</SurfaceCard>
<SurfaceCard className="p-3">
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400"><Clock3 size={11} /></div>
<div className="mt-1 text-sm font-black text-slate-700">{syncStatusLabel(integration.lastSyncStatus)}</div>
<div className="mt-1 text-[10px] font-bold text-slate-400">{integration.lastSyncAt || '无同步记录'}</div>
</SurfaceCard>
</div>
{!hasData && (
<EmptyState
title="ETC 尚无可统计数据"
description="当前通行记录、账单、同步配置和同步日志均为空;完成提供方配置与首次同步后,本页将直接展示真实指标。"
/>
)}
{error && <ErrorState message={error} />}
</div> </div>
); );
} }
+2 -2
View File
@@ -6,10 +6,10 @@ export default function EtcModule() {
return ( return (
<PageFrame <PageFrame
title="ETC 通行费看板" title="ETC 通行费看板"
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。" subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
icon={Receipt} icon={Receipt}
eyebrow="ETC BI" eyebrow="ETC BI"
meta="数据对接中 · 页面能力预留" meta="真实接入状态 · 指标口径已发布"
> >
<ETCView /> <ETCView />
</PageFrame> </PageFrame>
+5
View File
@@ -4,6 +4,7 @@ import type {
HydrogenCustomerRow, HydrogenStationFull, HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
ElectricChargeOrderResponse, ElectricChargeOrderResponse,
EtcOverviewResponse,
CustomerType, DateQuickPick, CustomerType, DateQuickPick,
} from './types'; } from './types';
@@ -67,3 +68,7 @@ export function fetchElectricOrders(date: string, customer: CustomerType): Promi
const q = new URLSearchParams({ date, customer }); const q = new URLSearchParams({ date, customer });
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`); return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
} }
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
}
+21
View File
@@ -124,3 +124,24 @@ export interface ElectricChargeOrderResponse {
truncated: boolean; truncated: boolean;
items: ElectricChargeOrder[]; items: ElectricChargeOrder[];
} }
export interface EtcOverviewResponse {
integration: {
providerConfigured: boolean;
syncEnabled: boolean;
providerType: string | null;
lastSyncAt: string | null;
lastSyncStatus: 'never' | 'success' | 'failed' | 'partial' | 'unknown';
};
kpi: {
passageCount: number;
vehicleCount: number;
tollAmount: number;
serviceFee: number;
totalAmount: number;
billCount: number;
receivableAmount: number;
paidAmount: number;
latestTransactionTime: string | null;
};
}
+13 -2
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, 3); assert.equal(payload.catalogVersion, 4);
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,12 +27,23 @@ 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, 3); assert.equal(payload.catalogVersion, 4);
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'));
}); });
test('returns the published ETC metric contract', async () => {
const response = await app.request('/metrics?domain=etc');
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 4);
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'));
});
test('rejects unpublished metric domains', async () => { test('rejects unpublished metric domains', async () => {
const response = await app.request('/metrics?domain=energy'); const response = await app.request('/metrics?domain=energy');
+84
View File
@@ -757,4 +757,88 @@ app.get('/electric/monthly', async (c) => {
return c.json(data); return c.json(data);
}); });
// =========================================================
// ETC 总览:真实接入状态 + 通行记录/账单基础 KPI
// 不读取或返回同步账号、密码、API 地址等敏感配置。
// =========================================================
app.get('/etc/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('etc/overview', async () => {
const [[recordRows], [billRows], [configRows], [logRows]] = await Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS passageCount,
COUNT(DISTINCT plate_number) AS vehicleCount,
SUM(toll_amount) AS tollAmount,
SUM(service_fee) AS serviceFee,
SUM(total_amount) AS totalAmount,
DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') AS latestTransactionTime
FROM etc_toll_record
WHERE del_flag = '0'`,
),
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS billCount,
SUM(receivable_amount) AS receivableAmount,
SUM(paid_amount) AS paidAmount
FROM energy_etc_bill
WHERE del_flag = '0'`,
),
pool.query<RowDataPacket[]>(
`SELECT provider_type AS providerType,
sync_enabled AS syncEnabled,
DATE_FORMAT(last_sync_time, '%Y-%m-%d %H:%i:%s') AS lastSyncAt,
last_sync_status AS lastSyncStatus
FROM etc_sync_config
WHERE del_flag = '0'
ORDER BY update_time DESC, id DESC
LIMIT 1`,
),
pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(sync_end_time, '%Y-%m-%d %H:%i:%s') AS lastSyncAt,
sync_status AS syncStatus
FROM etc_sync_log
ORDER BY sync_start_time DESC, id DESC
LIMIT 1`,
),
]);
const record = recordRows[0] ?? {};
const bill = billRows[0] ?? {};
const config = configRows[0];
const log = logRows[0];
const rawSyncStatus = log?.syncStatus ?? config?.lastSyncStatus;
const lastSyncStatus = rawSyncStatus === 1
? 'success'
: rawSyncStatus === 0
? 'failed'
: rawSyncStatus === 2
? 'partial'
: rawSyncStatus == null
? 'never'
: 'unknown';
return {
integration: {
providerConfigured: Boolean(config),
syncEnabled: Number(config?.syncEnabled) === 1,
providerType: config?.providerType ? String(config.providerType) : null,
lastSyncAt: log?.lastSyncAt ? String(log.lastSyncAt) : config?.lastSyncAt ? String(config.lastSyncAt) : null,
lastSyncStatus,
},
kpi: {
passageCount: Number(record.passageCount) || 0,
vehicleCount: Number(record.vehicleCount) || 0,
tollAmount: Math.round((Number(record.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(record.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(record.totalAmount) || 0) * 100) / 100,
billCount: Number(bill.billCount) || 0,
receivableAmount: Math.round((Number(bill.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(bill.paidAmount) || 0) * 100) / 100,
latestTransactionTime: record.latestTransactionTime ? String(record.latestTransactionTime) : null,
},
} as const;
}, { force });
return c.json(data);
});
export default app; export default app;
+12
View File
@@ -42,9 +42,21 @@ test('publishes electric fee components and blended cost semantics', () => {
assert.equal(byId.get('electric.blended_cost_intensity')?.aggregation, 'weighted-ratio'); assert.equal(byId.get('electric.blended_cost_intensity')?.aggregation, 'weighted-ratio');
}); });
test('publishes ETC passage and billing metrics as separate grains', () => {
const metrics = listMetricDefinitions('etc');
const byId = new Map(metrics.map(metric => [metric.id, metric]));
assert.ok(metrics.length >= 9);
assert.equal(byId.get('etc.total_amount')?.sources[0], 'etc_toll_record');
assert.equal(byId.get('etc.bill_receivable')?.sources[0], 'energy_etc_bill');
assert.equal(byId.get('etc.company_borne_amount')?.formula.includes('cost_type = 2'), true);
assert.equal(byId.get('etc.customer_borne_amount')?.formula.includes('cost_type = 1'), true);
});
test('validates only published metric domains', () => { test('validates only published metric domains', () => {
assert.equal(isMetricDomain('mileage'), true); assert.equal(isMetricDomain('mileage'), true);
assert.equal(isMetricDomain('hydrogen'), true); assert.equal(isMetricDomain('hydrogen'), true);
assert.equal(isMetricDomain('electric'), true); assert.equal(isMetricDomain('electric'), true);
assert.equal(isMetricDomain('etc'), true);
assert.equal(isMetricDomain('unknown'), false); assert.equal(isMetricDomain('unknown'), false);
}); });
+125 -4
View File
@@ -1,5 +1,5 @@
export type MetricDomain = 'mileage' | 'hydrogen' | 'electric'; export type MetricDomain = 'mileage' | 'hydrogen' | 'electric' | 'etc';
export type MetricUnit = 'km' | 'kg' | 'kwh' | 'cny' | 'cny-per-kwh' | 'percent' | 'vehicle' | 'station' | 'order' | 'timestamp'; export type MetricUnit = 'km' | 'kg' | 'kwh' | 'cny' | 'cny-per-kwh' | 'percent' | 'vehicle' | 'station' | 'order' | 'passage' | 'timestamp';
export type MetricAggregation = 'sum' | 'count' | 'derived' | 'weighted-ratio' | 'distinct-count' | 'latest'; export type MetricAggregation = 'sum' | 'count' | 'derived' | 'weighted-ratio' | 'distinct-count' | 'latest';
export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness'; export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness';
@@ -14,10 +14,10 @@ export interface MetricDefinition {
formula: string; formula: string;
sources: readonly string[]; sources: readonly string[];
dimensions: readonly string[]; dimensions: readonly string[];
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order'; drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill';
} }
export const METRIC_CATALOG_VERSION = 3; export const METRIC_CATALOG_VERSION = 4;
const MILEAGE_METRICS: readonly MetricDefinition[] = [ const MILEAGE_METRICS: readonly MetricDefinition[] = [
{ {
@@ -275,10 +275,131 @@ const ELECTRIC_METRICS: readonly MetricDefinition[] = [
}, },
]; ];
const ETC_METRICS: readonly MetricDefinition[] = [
{
id: 'etc.toll_amount',
domain: 'etc',
label: '通行费',
description: '有效 ETC 通行记录中的道路通行费之和,不含服务费。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: "SUM(toll_amount) WHERE del_flag = '0'",
sources: ['etc_toll_record'],
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode', 'contract-match'],
drillEntity: 'etc-toll-record',
},
{
id: 'etc.service_fee',
domain: 'etc',
label: 'ETC 服务费',
description: '有效 ETC 通行记录中的服务费之和。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: "SUM(service_fee) WHERE del_flag = '0'",
sources: ['etc_toll_record'],
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode'],
drillEntity: 'etc-toll-record',
},
{
id: 'etc.total_amount',
domain: 'etc',
label: 'ETC 总费用',
description: '有效 ETC 通行记录的总金额之和,业务定义为通行费加服务费。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: "SUM(total_amount) WHERE del_flag = '0'",
sources: ['etc_toll_record'],
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode', 'deduction-status'],
drillEntity: 'etc-toll-record',
},
{
id: 'etc.passage_count',
domain: 'etc',
label: '通行次数',
description: '有效 ETC 通行记录按记录编码去重后的通行次数。',
unit: 'passage',
aggregation: 'distinct-count',
timeSemantics: 'flow',
formula: "COUNT(DISTINCT record_code) WHERE del_flag = '0'",
sources: ['etc_toll_record'],
dimensions: ['date', 'plate', 'customer', 'route', 'cost-type', 'payment-mode'],
drillEntity: 'etc-toll-record',
},
{
id: 'etc.vehicle_count',
domain: 'etc',
label: '通行车辆数',
description: '所选范围内产生有效 ETC 通行记录的去重车牌数量。',
unit: 'vehicle',
aggregation: 'distinct-count',
timeSemantics: 'flow',
formula: "COUNT(DISTINCT plate_number) WHERE del_flag = '0'",
sources: ['etc_toll_record'],
dimensions: ['date', 'customer', 'route', 'cost-type'],
drillEntity: 'vehicle',
},
{
id: 'etc.company_borne_amount',
domain: 'etc',
label: '我方承担 ETC 费用',
description: '费用承担方 cost_type=2 的有效 ETC 总费用。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: "SUM(total_amount) WHERE del_flag = '0' AND cost_type = 2",
sources: ['etc_toll_record'],
dimensions: ['date', 'plate', 'customer', 'route', 'payment-mode'],
drillEntity: 'etc-toll-record',
},
{
id: 'etc.customer_borne_amount',
domain: 'etc',
label: '客户承担 ETC 费用',
description: '费用承担方 cost_type=1 的有效 ETC 总费用。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: "SUM(total_amount) WHERE del_flag = '0' AND cost_type = 1",
sources: ['etc_toll_record'],
dimensions: ['date', 'plate', 'customer', 'route', 'payment-mode'],
drillEntity: 'etc-toll-record',
},
{
id: 'etc.bill_receivable',
domain: 'etc',
label: 'ETC 账单应收',
description: '有效 ETC 账单的应收金额之和,与通行记录费用属于不同结算粒度。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: "SUM(receivable_amount) WHERE del_flag = '0'",
sources: ['energy_etc_bill'],
dimensions: ['bill-period', 'customer', 'payment-status', 'review-status', 'finance-status'],
drillEntity: 'etc-bill',
},
{
id: 'etc.bill_paid_amount',
domain: 'etc',
label: 'ETC 账单已收',
description: '有效 ETC 账单的已支付金额之和。',
unit: 'cny',
aggregation: 'sum',
timeSemantics: 'flow',
formula: "SUM(paid_amount) WHERE del_flag = '0'",
sources: ['energy_etc_bill'],
dimensions: ['bill-period', 'customer', 'payment-status', 'review-status', 'finance-status'],
drillEntity: 'etc-bill',
},
];
export const METRIC_CATALOG: readonly MetricDefinition[] = [ export const METRIC_CATALOG: readonly MetricDefinition[] = [
...MILEAGE_METRICS, ...MILEAGE_METRICS,
...HYDROGEN_METRICS, ...HYDROGEN_METRICS,
...ELECTRIC_METRICS, ...ELECTRIC_METRICS,
...ETC_METRICS,
]; ];
export function isMetricDomain(value: string): value is MetricDomain { export function isMetricDomain(value: string): value is MetricDomain {