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,