feat: reconcile ETC overview totals
This commit is contained in:
@@ -1,16 +1,25 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 Blur from '../../components/Blur';
|
||||||
import { ErrorState, LoadingState } from '../../components/ui/surface';
|
import { ErrorState, LoadingState } from '../../components/ui/surface';
|
||||||
import { fetchEtcBills, fetchEtcRecords } from './api';
|
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 type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
|
||||||
import { DateRangeInputs } from './AnalysisDateFilters';
|
import { DateRangeInputs } from './AnalysisDateFilters';
|
||||||
|
import { reconcileEtcBills, reconcileEtcRecords } from './etc-reconciliation';
|
||||||
|
|
||||||
function fmtMoney(value: number): string {
|
function fmtMoney(value: number): string {
|
||||||
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
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 {
|
function costTypeLabel(value: number): string {
|
||||||
if (value === 1) return '客户承担';
|
if (value === 1) return '客户承担';
|
||||||
if (value === 2) return '我方承担';
|
if (value === 2) return '我方承担';
|
||||||
@@ -25,9 +34,11 @@ function paymentLabel(receivable: number, paid: number): string {
|
|||||||
|
|
||||||
export default function ETCDetails({
|
export default function ETCDetails({
|
||||||
context,
|
context,
|
||||||
|
overviewKpi,
|
||||||
onContextChange,
|
onContextChange,
|
||||||
}: {
|
}: {
|
||||||
context: EtcDrillContext;
|
context: EtcDrillContext;
|
||||||
|
overviewKpi: EtcOverviewResponse['kpi'];
|
||||||
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
|
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
|
||||||
}) {
|
}) {
|
||||||
const { view, startDate, endDate, search, page } = context;
|
const { view, startDate, endDate, search, page } = context;
|
||||||
@@ -72,6 +83,29 @@ export default function ETCDetails({
|
|||||||
|
|
||||||
const activeData = view === 'records' ? records : bills;
|
const activeData = view === 'records' ? records : bills;
|
||||||
const totalPages = activeData?.totalPages || 1;
|
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 = () => {
|
const applySearch = () => {
|
||||||
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
|
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
|
||||||
};
|
};
|
||||||
@@ -167,6 +201,26 @@ export default function ETCDetails({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{error ? (
|
||||||
<ErrorState message={error} variant="inline" />
|
<ErrorState message={error} variant="inline" />
|
||||||
) : loading && !activeData ? (
|
) : loading && !activeData ? (
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export default function ETCView() {
|
|||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ETCDetails context={detailContext} onContextChange={commitDetailContext} />
|
<ETCDetails context={detailContext} overviewKpi={kpi} onContextChange={commitDetailContext} />
|
||||||
|
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
</div>
|
</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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -203,7 +203,7 @@ export interface EtcTollRecordResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
filters: EtcListFilters;
|
filters: EtcListFilters;
|
||||||
summary: { tollAmount: number; serviceFee: number; totalAmount: number };
|
summary: { vehicleCount: number; tollAmount: number; serviceFee: number; totalAmount: number };
|
||||||
items: EtcTollRecord[];
|
items: EtcTollRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1020,6 +1020,7 @@ app.get('/etc/records', async (c) => {
|
|||||||
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
|
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
|
||||||
pool.query<RowDataPacket[]>(
|
pool.query<RowDataPacket[]>(
|
||||||
`SELECT COUNT(*) AS total,
|
`SELECT COUNT(*) AS total,
|
||||||
|
COUNT(DISTINCT plate_number) AS vehicleCount,
|
||||||
SUM(toll_amount) AS tollAmount,
|
SUM(toll_amount) AS tollAmount,
|
||||||
SUM(service_fee) AS serviceFee,
|
SUM(service_fee) AS serviceFee,
|
||||||
SUM(total_amount) AS totalAmount
|
SUM(total_amount) AS totalAmount
|
||||||
@@ -1058,6 +1059,7 @@ app.get('/etc/records', async (c) => {
|
|||||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||||
filters: { startDate: startDate || null, endDate: endDate || null, search },
|
filters: { startDate: startDate || null, endDate: endDate || null, search },
|
||||||
summary: {
|
summary: {
|
||||||
|
vehicleCount: Number(summary.vehicleCount) || 0,
|
||||||
tollAmount: Math.round((Number(summary.tollAmount) || 0) * 100) / 100,
|
tollAmount: Math.round((Number(summary.tollAmount) || 0) * 100) / 100,
|
||||||
serviceFee: Math.round((Number(summary.serviceFee) || 0) * 100) / 100,
|
serviceFee: Math.round((Number(summary.serviceFee) || 0) * 100) / 100,
|
||||||
totalAmount: Math.round((Number(summary.totalAmount) || 0) * 100) / 100,
|
totalAmount: Math.round((Number(summary.totalAmount) || 0) * 100) / 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user