diff --git a/src/modules/energy/ETCDetails.tsx b/src/modules/energy/ETCDetails.tsx
index df7857f..3bb6772 100644
--- a/src/modules/energy/ETCDetails.tsx
+++ b/src/modules/energy/ETCDetails.tsx
@@ -1,16 +1,25 @@
import { useEffect, useState } from 'react';
-import { ChevronLeft, ChevronRight, ReceiptText, Route, Search } from 'lucide-react';
+import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, Route, Search, TriangleAlert } from 'lucide-react';
import Blur from '../../components/Blur';
import { ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchEtcBills, fetchEtcRecords } from './api';
-import type { EtcBillResponse, EtcTollRecordResponse } from './types';
+import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
import { DateRangeInputs } from './AnalysisDateFilters';
+import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
+function formatSigned(value: number, fractionDigits = 0): string {
+ const formatted = Math.abs(value).toLocaleString('zh-CN', {
+ minimumFractionDigits: fractionDigits,
+ maximumFractionDigits: fractionDigits,
+ });
+ return `${value >= 0 ? '+' : '-'}${formatted}`;
+}
+
function costTypeLabel(value: number): string {
if (value === 1) return '客户承担';
if (value === 2) return '我方承担';
@@ -25,9 +34,11 @@ function paymentLabel(receivable: number, paid: number): string {
export default function ETCDetails({
context,
+ overviewKpi,
onContextChange,
}: {
context: EtcDrillContext;
+ overviewKpi: EtcOverviewResponse['kpi'];
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
}) {
const { view, startDate, endDate, search, page } = context;
@@ -72,6 +83,29 @@ export default function ETCDetails({
const activeData = view === 'records' ? records : bills;
const totalPages = activeData?.totalPages || 1;
+ const isFullDataset = !startDate && !endDate && !search;
+ const reconciliation = isFullDataset
+ ? view === 'records' && records
+ ? reconcileEtcRecords(overviewKpi, records)
+ : view === 'bills' && bills
+ ? reconcileEtcBills(overviewKpi, bills)
+ : null
+ : null;
+ const reconciliationDifferences = reconciliation && !reconciliation.matches
+ ? 'passageCountDifference' in reconciliation
+ ? [
+ reconciliation.passageCountDifference !== 0 && `通行次数 ${formatSigned(reconciliation.passageCountDifference)}`,
+ reconciliation.vehicleCountDifference !== 0 && `车辆数 ${formatSigned(reconciliation.vehicleCountDifference)}`,
+ reconciliation.tollAmountDifference !== 0 && `通行费 ${formatSigned(reconciliation.tollAmountDifference, 2)} 元`,
+ reconciliation.serviceFeeDifference !== 0 && `服务费 ${formatSigned(reconciliation.serviceFeeDifference, 2)} 元`,
+ reconciliation.totalAmountDifference !== 0 && `总费用 ${formatSigned(reconciliation.totalAmountDifference, 2)} 元`,
+ ].filter(Boolean).join(' · ')
+ : [
+ reconciliation.billCountDifference !== 0 && `账单数 ${formatSigned(reconciliation.billCountDifference)}`,
+ reconciliation.receivableAmountDifference !== 0 && `应收 ${formatSigned(reconciliation.receivableAmountDifference, 2)} 元`,
+ reconciliation.paidAmountDifference !== 0 && `已收 ${formatSigned(reconciliation.paidAmountDifference, 2)} 元`,
+ ].filter(Boolean).join(' · ')
+ : '';
const applySearch = () => {
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
};
@@ -167,6 +201,26 @@ export default function ETCDetails({
) : null}
+ {reconciliation && (
+
+ {reconciliation.matches
+ ?
+ : }
+
+ {reconciliation.matches
+ ? `口径核对通过:总览与 ${activeData?.total ?? 0} 条${view === 'records' ? '通行记录' : '结算账单'}全量汇总一致`
+ : `口径存在差异:${reconciliationDifferences}`}
+
+
+ )}
+
{error ? (
) : loading && !activeData ? (
diff --git a/src/modules/energy/ETCView.tsx b/src/modules/energy/ETCView.tsx
index 47ef5b3..444b225 100644
--- a/src/modules/energy/ETCView.tsx
+++ b/src/modules/energy/ETCView.tsx
@@ -176,7 +176,7 @@ export default function ETCView() {
-
+
{error && }
diff --git a/src/modules/energy/etc-reconciliation.test.ts b/src/modules/energy/etc-reconciliation.test.ts
new file mode 100644
index 0000000..1b09a39
--- /dev/null
+++ b/src/modules/energy/etc-reconciliation.test.ts
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation.js';
+import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types.js';
+
+const kpi: EtcOverviewResponse['kpi'] = {
+ passageCount: 12,
+ vehicleCount: 4,
+ tollAmount: 100.01,
+ serviceFee: 5.02,
+ totalAmount: 105.03,
+ billCount: 2,
+ receivableAmount: 105.03,
+ paidAmount: 80,
+ latestTransactionTime: '2026-07-01 12:00:00',
+};
+
+const records: EtcTollRecordResponse = {
+ page: 1,
+ limit: 20,
+ total: 12,
+ totalPages: 1,
+ filters: { startDate: null, endDate: null, search: '' },
+ summary: { vehicleCount: 4, tollAmount: 100.01, serviceFee: 5.02, totalAmount: 105.03 },
+ items: [],
+};
+
+const bills: EtcBillResponse = {
+ page: 1,
+ limit: 20,
+ total: 2,
+ totalPages: 1,
+ filters: { startDate: null, endDate: null, search: '' },
+ summary: { receivableAmount: 105.03, paidAmount: 80 },
+ items: [],
+};
+
+test('matches ETC overview against full record and bill summaries', () => {
+ assert.equal(reconcileEtcRecords(kpi, records).matches, true);
+ assert.equal(reconcileEtcBills(kpi, bills).matches, true);
+});
+
+test('reports record count, vehicle, and fee differences at hundredth precision', () => {
+ assert.deepEqual(reconcileEtcRecords(kpi, {
+ ...records,
+ total: 13,
+ summary: { vehicleCount: 3, tollAmount: 100, serviceFee: 5.04, totalAmount: 105.04 },
+ }), {
+ matches: false,
+ passageCountDifference: 1,
+ vehicleCountDifference: -1,
+ tollAmountDifference: -0.01,
+ serviceFeeDifference: 0.02,
+ totalAmountDifference: 0.01,
+ });
+});
+
+test('reports bill count and settlement differences at hundredth precision', () => {
+ assert.deepEqual(reconcileEtcBills(kpi, {
+ ...bills,
+ total: 1,
+ summary: { receivableAmount: 100, paidAmount: 80.01 },
+ }), {
+ matches: false,
+ billCountDifference: -1,
+ receivableAmountDifference: -5.03,
+ paidAmountDifference: 0.01,
+ });
+});
diff --git a/src/modules/energy/etc-reconciliation.ts b/src/modules/energy/etc-reconciliation.ts
new file mode 100644
index 0000000..c684437
--- /dev/null
+++ b/src/modules/energy/etc-reconciliation.ts
@@ -0,0 +1,61 @@
+import type { EtcBillResponse, EtcOverviewResponse, EtcTollRecordResponse } from './types';
+
+export interface EtcRecordReconciliation {
+ matches: boolean;
+ passageCountDifference: number;
+ vehicleCountDifference: number;
+ tollAmountDifference: number;
+ serviceFeeDifference: number;
+ totalAmountDifference: number;
+}
+
+export interface EtcBillReconciliation {
+ matches: boolean;
+ billCountDifference: number;
+ receivableAmountDifference: number;
+ paidAmountDifference: number;
+}
+
+function hundredths(value: number): number {
+ return Math.round(value * 100);
+}
+
+export function reconcileEtcRecords(
+ kpi: EtcOverviewResponse['kpi'],
+ records: EtcTollRecordResponse,
+): EtcRecordReconciliation {
+ const tollDifference = hundredths(records.summary.tollAmount) - hundredths(kpi.tollAmount);
+ const serviceFeeDifference = hundredths(records.summary.serviceFee) - hundredths(kpi.serviceFee);
+ const totalDifference = hundredths(records.summary.totalAmount) - hundredths(kpi.totalAmount);
+ const passageCountDifference = records.total - kpi.passageCount;
+ const vehicleCountDifference = records.summary.vehicleCount - kpi.vehicleCount;
+
+ return {
+ matches: passageCountDifference === 0
+ && vehicleCountDifference === 0
+ && tollDifference === 0
+ && serviceFeeDifference === 0
+ && totalDifference === 0,
+ passageCountDifference,
+ vehicleCountDifference,
+ tollAmountDifference: tollDifference / 100,
+ serviceFeeDifference: serviceFeeDifference / 100,
+ totalAmountDifference: totalDifference / 100,
+ };
+}
+
+export function reconcileEtcBills(
+ kpi: EtcOverviewResponse['kpi'],
+ bills: EtcBillResponse,
+): EtcBillReconciliation {
+ const receivableDifference = hundredths(bills.summary.receivableAmount) - hundredths(kpi.receivableAmount);
+ const paidDifference = hundredths(bills.summary.paidAmount) - hundredths(kpi.paidAmount);
+ const billCountDifference = bills.total - kpi.billCount;
+
+ return {
+ matches: billCountDifference === 0 && receivableDifference === 0 && paidDifference === 0,
+ billCountDifference,
+ receivableAmountDifference: receivableDifference / 100,
+ paidAmountDifference: paidDifference / 100,
+ };
+}
diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts
index baceb0d..2976f53 100644
--- a/src/modules/energy/types.ts
+++ b/src/modules/energy/types.ts
@@ -203,7 +203,7 @@ export interface EtcTollRecordResponse {
total: number;
totalPages: number;
filters: EtcListFilters;
- summary: { tollAmount: number; serviceFee: number; totalAmount: number };
+ summary: { vehicleCount: number; tollAmount: number; serviceFee: number; totalAmount: number };
items: EtcTollRecord[];
}
diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts
index 896b23f..c6d99b4 100644
--- a/src/server/routes/energy/index.ts
+++ b/src/server/routes/energy/index.ts
@@ -1020,6 +1020,7 @@ app.get('/etc/records', async (c) => {
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query(
`SELECT COUNT(*) AS total,
+ COUNT(DISTINCT plate_number) AS vehicleCount,
SUM(toll_amount) AS tollAmount,
SUM(service_fee) AS serviceFee,
SUM(total_amount) AS totalAmount
@@ -1058,6 +1059,7 @@ app.get('/etc/records', async (c) => {
totalPages: Math.max(1, Math.ceil(total / limit)),
filters: { startDate: startDate || null, endDate: endDate || null, search },
summary: {
+ vehicleCount: Number(summary.vehicleCount) || 0,
tollAmount: Math.round((Number(summary.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(summary.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(summary.totalAmount) || 0) * 100) / 100,