refactor: expose ETC integration readiness
This commit is contained in:
+101
-68
@@ -1,79 +1,112 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { Construction, Hammer } from 'lucide-react';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
|
||||
import { fetchEtcOverview } from './api';
|
||||
import type { EtcOverviewResponse } from './types';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
const ETC_HINTS = [
|
||||
'ETC 通行费数据正在与发卡方系统打通…',
|
||||
'工人 GG 正在搭脚手架,敬请期待 ~',
|
||||
'马上能看到每月通行费明细啦',
|
||||
'想看哪个维度的 ETC?反馈一下嘛',
|
||||
'上线时机:等数据接通的那一天',
|
||||
];
|
||||
function fmtMoney(value: number): string {
|
||||
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
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() {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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 className="flex flex-col gap-3">
|
||||
<SurfaceCard className="flex items-center gap-3 p-3">
|
||||
<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'}`}>
|
||||
{integration.providerConfigured ? <CircleCheck size={18} /> : <CircleX size={18} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-black text-slate-800">
|
||||
{integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'}
|
||||
</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>
|
||||
|
||||
<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="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="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>
|
||||
{m.done && <span className="text-[10px] text-emerald-500 font-bold ml-auto">已完成</span>}
|
||||
{m.current && <span className="text-[10px] text-amber-500 font-bold ml-auto">进行中</span>}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.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>
|
||||
|
||||
<RotatingFooterHint hints={ETC_HINTS} />
|
||||
{!hasData && (
|
||||
<EmptyState
|
||||
title="ETC 尚无可统计数据"
|
||||
description="当前通行记录、账单、同步配置和同步日志均为空;完成提供方配置与首次同步后,本页将直接展示真实指标。"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ export default function EtcModule() {
|
||||
return (
|
||||
<PageFrame
|
||||
title="ETC 通行费看板"
|
||||
subtitle="规划按车、按月、按线路拆分通行费,让车辆运营成本口径逐步完整。"
|
||||
subtitle="区分通行记录与结算账单口径,展示通行费、服务费、车辆规模及数据同步状态。"
|
||||
icon={Receipt}
|
||||
eyebrow="ETC BI"
|
||||
meta="数据对接中 · 页面能力预留"
|
||||
meta="真实接入状态 · 指标口径已发布"
|
||||
>
|
||||
<ETCView />
|
||||
</PageFrame>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
ElectricChargeOrderResponse,
|
||||
EtcOverviewResponse,
|
||||
CustomerType, DateQuickPick,
|
||||
} from './types';
|
||||
|
||||
@@ -67,3 +68,7 @@ export function fetchElectricOrders(date: string, customer: CustomerType): Promi
|
||||
const q = new URLSearchParams({ date, customer });
|
||||
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
|
||||
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
|
||||
}
|
||||
|
||||
@@ -124,3 +124,24 @@ export interface ElectricChargeOrderResponse {
|
||||
truncated: boolean;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, 3);
|
||||
assert.equal(payload.catalogVersion, 4);
|
||||
assert.deepEqual(payload.domains, ['mileage']);
|
||||
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();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.catalogVersion, 3);
|
||||
assert.equal(payload.catalogVersion, 4);
|
||||
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('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 () => {
|
||||
const response = await app.request('/metrics?domain=energy');
|
||||
|
||||
|
||||
@@ -757,4 +757,88 @@ app.get('/electric/monthly', async (c) => {
|
||||
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;
|
||||
|
||||
@@ -42,9 +42,21 @@ test('publishes electric fee components and blended cost semantics', () => {
|
||||
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', () => {
|
||||
assert.equal(isMetricDomain('mileage'), true);
|
||||
assert.equal(isMetricDomain('hydrogen'), true);
|
||||
assert.equal(isMetricDomain('electric'), true);
|
||||
assert.equal(isMetricDomain('etc'), true);
|
||||
assert.equal(isMetricDomain('unknown'), false);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type MetricDomain = 'mileage' | 'hydrogen' | 'electric';
|
||||
export type MetricUnit = 'km' | 'kg' | 'kwh' | 'cny' | 'cny-per-kwh' | 'percent' | 'vehicle' | 'station' | 'order' | 'timestamp';
|
||||
export type MetricDomain = 'mileage' | 'hydrogen' | 'electric' | 'etc';
|
||||
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 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' | '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[] = [
|
||||
{
|
||||
@@ -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[] = [
|
||||
...MILEAGE_METRICS,
|
||||
...HYDROGEN_METRICS,
|
||||
...ELECTRIC_METRICS,
|
||||
...ETC_METRICS,
|
||||
];
|
||||
|
||||
export function isMetricDomain(value: string): value is MetricDomain {
|
||||
|
||||
Reference in New Issue
Block a user