feat: reconcile ETC overview totals
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
totalPages: number;
|
||||
filters: EtcListFilters;
|
||||
summary: { tollAmount: number; serviceFee: number; totalAmount: number };
|
||||
summary: { vehicleCount: number; tollAmount: number; serviceFee: number; totalAmount: number };
|
||||
items: EtcTollRecord[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user