From 0fb62135f3dcd73c3d5ccf3d67ab3ff0affa86aa Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 15:00:58 +0800 Subject: [PATCH] refactor: expose ETC integration readiness --- src/modules/energy/ETCView.tsx | 169 +++++++++++++--------- src/modules/energy/EtcModule.tsx | 4 +- src/modules/energy/api.ts | 5 + src/modules/energy/types.ts | 21 +++ src/server/routes/analytics/index.test.ts | 15 +- src/server/routes/energy/index.ts | 84 +++++++++++ src/shared/analytics/catalog.test.ts | 12 ++ src/shared/analytics/catalog.ts | 129 ++++++++++++++++- 8 files changed, 363 insertions(+), 76 deletions(-) diff --git a/src/modules/energy/ETCView.tsx b/src/modules/energy/ETCView.tsx index c218700..700289f 100644 --- a/src/modules/energy/ETCView.tsx +++ b/src/modules/energy/ETCView.tsx @@ -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(null); + const [error, setError] = useState(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 ; + if (!data) return ; + + const { integration, kpi } = data; + const hasData = kpi.passageCount > 0; + const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0; + return ( -
- -
- - - - - - +
+ + + {integration.providerConfigured ? : } + +
+
+ {integration.providerConfigured ? 'ETC 数据提供方已配置' : 'ETC 数据提供方尚未配置'} +
+
+ {integration.providerConfigured + ? `${integration.providerType || '已配置提供方'} · ${syncStatusLabel(integration.lastSyncStatus)}` + : '数据库结构已准备,等待配置同步账户并完成首次同步'} +
+ +
-
ETC 模块建设中
-
- 通行费明细、按车按月统计、运营成本拆分 -
- 这些数据都在路上啦 -
+
+ + + + +
- {/* 简单的里程碑进度感 */} -
- {[ - { label: '需求评审', done: true }, - { label: '数据对接', done: true }, - { label: '页面开发', done: false, current: true }, - { label: '正式上线', done: false }, - ].map((m, i) => ( - - - - {m.label} - - {m.done && 已完成} - {m.current && 进行中} - - ))} -
- +
+ +
提供方配置
+
+ {integration.providerConfigured ? '已配置' : '未配置'} +
+
+ +
自动同步
+
+ {integration.syncEnabled ? '已启用' : '未启用'} +
+
+ +
最近同步
+
{syncStatusLabel(integration.lastSyncStatus)}
+
{integration.lastSyncAt || '无同步记录'}
+
+
- + {!hasData && ( + + )} + + {error && }
); } diff --git a/src/modules/energy/EtcModule.tsx b/src/modules/energy/EtcModule.tsx index 977e35b..799a377 100644 --- a/src/modules/energy/EtcModule.tsx +++ b/src/modules/energy/EtcModule.tsx @@ -6,10 +6,10 @@ export default function EtcModule() { return ( diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 0844ea3..433aec5 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -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(`${BASE}/electric/orders?${q.toString()}`); } + +export function fetchEtcOverview(force = false): Promise { + return fetchJson(`${BASE}/etc/overview${force ? '?force=1' : ''}`); +} diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index 0e40ca6..7b8f389 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -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; + }; +} diff --git a/src/server/routes/analytics/index.test.ts b/src/server/routes/analytics/index.test.ts index 485382a..a58affb 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, 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'); diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 4753a87..8715dd0 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -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( + `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( + `SELECT COUNT(*) AS billCount, + SUM(receivable_amount) AS receivableAmount, + SUM(paid_amount) AS paidAmount + FROM energy_etc_bill + WHERE del_flag = '0'`, + ), + pool.query( + `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( + `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; diff --git a/src/shared/analytics/catalog.test.ts b/src/shared/analytics/catalog.test.ts index ba1ea97..8d86d09 100644 --- a/src/shared/analytics/catalog.test.ts +++ b/src/shared/analytics/catalog.test.ts @@ -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); }); diff --git a/src/shared/analytics/catalog.ts b/src/shared/analytics/catalog.ts index 6e6073e..8e2b1b4 100644 --- a/src/shared/analytics/catalog.ts +++ b/src/shared/analytics/catalog.ts @@ -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 {