feat: reconcile ETC overview totals

This commit is contained in:
kkfluous
2026-08-07 17:15:52 +08:00
parent 5ea788e565
commit d786b984bb
6 changed files with 190 additions and 4 deletions
+56 -2
View File
@@ -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({
</div>
) : null}
{reconciliation && (
<div
role={reconciliation.matches ? 'status' : 'alert'}
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
reconciliation.matches
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-800'
}`}
>
{reconciliation.matches
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
<span>
{reconciliation.matches
? `口径核对通过:总览与 ${activeData?.total ?? 0}${view === 'records' ? '通行记录' : '结算账单'}全量汇总一致`
: `口径存在差异:${reconciliationDifferences}`}
</span>
</div>
)}
{error ? (
<ErrorState message={error} variant="inline" />
) : loading && !activeData ? (
+1 -1
View File
@@ -176,7 +176,7 @@ export default function ETCView() {
</SurfaceCard>
</div>
<ETCDetails context={detailContext} onContextChange={commitDetailContext} />
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} />
{error && <ErrorState message={error} />}
</div>
@@ -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,
});
});
+61
View File
@@ -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,
};
}
+1 -1
View File
@@ -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[];
}
+2
View File
@@ -1020,6 +1020,7 @@ app.get('/etc/records', async (c) => {
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query<RowDataPacket[]>(
`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,