feat: add ETC records and bill drilldowns

This commit is contained in:
kkfluous
2026-08-07 16:19:19 +08:00
parent 47a4474608
commit 263d8c6245
10 changed files with 612 additions and 17 deletions
+4 -4
View File
@@ -93,8 +93,8 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
| 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 | | 父级 | 下钻目标 | 必须继承的上下文 | 当前状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 通行费用 | 通行记录 | 日期、车牌、客户、费用承担类型 | 待首次同步 | | 通行费用 | 通行记录 | 日期、车牌、客户、费用承担类型 | 下钻代码已完成,待首次真实同步验收 |
| 账单应收 | ETC 账单 | 账期、客户、支付状态 | 待首次同步 | | 账单应收 | ETC 账单 | 账期、客户、支付状态 | 下钻代码已完成,待首次真实同步验收 |
供应商未配置或从未同步时展示接入状态,不使用模拟业务数据填充图表。 供应商未配置或从未同步时展示接入状态,不使用模拟业务数据填充图表。
@@ -156,7 +156,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成里程考核归因、车辆详情和每日汇报跳转。 - 已完成里程考核归因、车辆详情和每日汇报跳转。
- 已完成氢能站点、客户下钻代码。 - 已完成氢能站点、客户下钻代码。
- 已完成电能全部车辆、日期和订单下钻。 - 已完成电能全部车辆、日期和订单下钻。
- ETC 等待首次真实同步后实施 - 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据
### 阶段 C:数据可信度 ### 阶段 C:数据可信度
@@ -190,5 +190,5 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
## 10. 当前外部依赖 ## 10. 当前外部依赖
- 氢能数据库连接尚未恢复,真实数据对账未完成。 - 氢能数据库连接尚未恢复,真实数据对账未完成。
- ETC 供应商未配置、未执行首次同步记录与账单下钻无数据可验收。 - ETC 供应商未配置、未执行首次同步记录与账单下钻代码已完成,但真实数据仍不可验收。
- 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。 - 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, Loader2, ReceiptText, Route, Search } from 'lucide-react';
import Blur from '../../components/Blur';
import { fetchEtcBills, fetchEtcRecords } from './api';
import type { EtcBillResponse, EtcTollRecordResponse } from './types';
import type { EtcDetailView } from './etc-drill-context';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function costTypeLabel(value: number): string {
if (value === 1) return '客户承担';
if (value === 2) return '我方承担';
return '承担方待确认';
}
function paymentLabel(receivable: number, paid: number): string {
if (receivable > 0 && paid >= receivable) return '已收';
if (paid > 0) return '部分收款';
return '未收';
}
export default function ETCDetails({
view,
onViewChange,
}: {
view: EtcDetailView;
onViewChange: (view: EtcDetailView) => void;
}) {
const [draftSearch, setDraftSearch] = useState('');
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [page, setPage] = useState(1);
const [records, setRecords] = useState<EtcTollRecordResponse | null>(null);
const [bills, setBills] = useState<EtcBillResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const query = {
page,
limit: 20,
startDate: startDate || undefined,
endDate: endDate || undefined,
search: search || undefined,
};
const request = view === 'records' ? fetchEtcRecords(query) : fetchEtcBills(query);
request
.then(result => {
if (cancelled) return;
if (view === 'records') setRecords(result as EtcTollRecordResponse);
else setBills(result as EtcBillResponse);
})
.catch(loadError => {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [endDate, page, search, startDate, view]);
const activeData = view === 'records' ? records : bills;
const totalPages = activeData?.totalPages || 1;
const applySearch = () => {
setPage(1);
setSearch(draftSearch.trim());
};
const changeView = (next: EtcDetailView) => {
setPage(1);
onViewChange(next);
};
return (
<section className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-black text-slate-900">ETC </h2>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{view === 'records' ? '通行流水与费用承担' : '客户账期、应收与收款'}
</p>
</div>
<div className="flex rounded-lg bg-slate-100 p-1">
<button
type="button"
onClick={() => changeView('records')}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'records' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
>
<Route size={13} />
</button>
<button
type="button"
onClick={() => changeView('bills')}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'bills' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
>
<ReceiptText size={13} />
</button>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[140px_140px_minmax(0,1fr)_36px]">
<input
type="date"
aria-label="ETC 开始日期"
value={startDate}
onChange={event => { setPage(1); setStartDate(event.target.value); }}
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300"
/>
<input
type="date"
aria-label="ETC 结束日期"
value={endDate}
onChange={event => { setPage(1); setEndDate(event.target.value); }}
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-2 text-[11px] font-bold text-slate-700 outline-none focus:border-blue-300"
/>
<input
type="search"
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
value={draftSearch}
onChange={event => setDraftSearch(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
className="h-9 min-w-0 rounded-lg border border-slate-200 bg-white px-3 text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300 focus:border-blue-300"
/>
<button
type="button"
onClick={applySearch}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-white hover:bg-blue-600"
aria-label="应用 ETC 明细搜索"
title="搜索"
>
<Search size={14} />
</button>
</div>
</header>
{activeData ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2 text-[10px] font-bold text-slate-500 md:px-4">
<span>{activeData.total} </span>
{view === 'records' && records ? (
<span> {fmtMoney(records.summary.tollAmount)} · {fmtMoney(records.summary.serviceFee)} · {fmtMoney(records.summary.totalAmount)}</span>
) : view === 'bills' && bills ? (
<span> {fmtMoney(bills.summary.receivableAmount)} · {fmtMoney(bills.summary.paidAmount)}</span>
) : null}
</div>
) : null}
{error ? (
<div className="m-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : loading && !activeData ? (
<div className="flex items-center justify-center gap-2 py-14 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" /> ETC
</div>
) : view === 'records' && records ? (
records.items.length === 0 ? (
<div className="py-14 text-center">
<Route size={22} className="mx-auto text-slate-300" />
<div className="mt-3 text-xs font-black text-slate-500"></div>
<div className="mt-1 text-[10px] font-bold text-slate-300"></div>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[820px] text-left text-[11px]">
<thead className="bg-white text-[10px] font-black text-slate-400">
<tr><th className="px-4 py-2"></th><th className="px-3 py-2"> / </th><th className="px-3 py-2"> </th><th className="px-3 py-2"></th><th className="px-4 py-2 text-right"></th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{records.items.map(item => (
<tr key={item.id}>
<td className="px-4 py-3 font-bold text-slate-600">{item.transTime}</td>
<td className="px-3 py-3"><div className="font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur></div></td>
<td className="px-3 py-3 font-bold text-slate-600">{item.entryStation || '-'} {item.exitStation || '-'}</td>
<td className="px-3 py-3 font-bold text-slate-600">{costTypeLabel(item.costType)}</td>
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.totalAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.tollAmount)} · {fmtMoney(item.serviceFee)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{records.items.map(item => (
<article key={item.id} className="px-3 py-3">
<div className="flex items-start justify-between gap-3"><div><div className="font-mono text-xs font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{item.transTime}</div></div><div className="text-right text-sm font-black text-slate-800">{fmtMoney(item.totalAmount)}</div></div>
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.entryStation || '-'} {item.exitStation || '-'}</div>
<div className="mt-1 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur><span>{costTypeLabel(item.costType)}</span></div>
</article>
))}
</div>
</>
)
) : view === 'bills' && bills ? (
bills.items.length === 0 ? (
<div className="py-14 text-center">
<ReceiptText size={22} className="mx-auto text-slate-300" />
<div className="mt-3 text-xs font-black text-slate-500"></div>
<div className="mt-1 text-[10px] font-bold text-slate-300"></div>
</div>
) : (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[760px] text-left text-[11px]">
<thead className="text-[10px] font-black text-slate-400"><tr><th className="px-4 py-2"> / </th><th className="px-3 py-2"></th><th className="px-3 py-2"></th><th className="px-3 py-2"></th><th className="px-4 py-2 text-right"> / </th></tr></thead>
<tbody className="divide-y divide-slate-100">
{bills.items.map(item => (
<tr key={item.id}>
<td className="px-4 py-3"><div className="font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></td>
<td className="px-3 py-3 font-bold text-slate-600">{item.periodStart} {item.periodEnd}</td>
<td className="px-3 py-3 font-bold text-slate-600">{item.tollCount} · {fmtMoney(item.tollAmount)}</td>
<td className="px-3 py-3 font-bold text-slate-600">{paymentLabel(item.receivableAmount, item.paidAmount)}</td>
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.paidAmount)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{bills.items.map(item => (
<article key={item.id} className="px-3 py-3">
<div className="flex items-start justify-between gap-3"><div><div className="text-xs font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></div><div className="text-right"><div className="text-sm font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(item.paidAmount)}</div></div></div>
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.periodStart} {item.periodEnd}</div>
<div className="mt-1 flex justify-between text-[9px] font-bold text-slate-400"><span>{item.tollCount} </span><span>{paymentLabel(item.receivableAmount, item.paidAmount)}</span></div>
</article>
))}
</div>
</>
)
) : null}
{activeData ? (
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
<span className="text-[10px] font-bold text-slate-400"> {page} / {totalPages} </span>
<div className="flex gap-2">
<button type="button" onClick={() => setPage(current => Math.max(1, current - 1))} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页" title="上一页"><ChevronLeft size={14} /></button>
<button type="button" onClick={() => setPage(current => Math.min(totalPages, current + 1))} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页" title="下一页"><ChevronRight size={14} /></button>
</div>
</footer>
) : null}
</section>
);
}
+21 -13
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react'; import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
import { fetchEtcOverview } from './api'; import { fetchEtcOverview } from './api';
import type { EtcOverviewResponse } from './types'; import type { EtcOverviewResponse } from './types';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface'; import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import ETCDetails from './ETCDetails';
import { buildEtcDetailUrl, parseEtcDetailView, type EtcDetailView } from './etc-drill-context';
function fmtMoney(value: number): string { function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
@@ -20,6 +22,18 @@ export default function ETCView() {
const [data, setData] = useState<EtcOverviewResponse | null>(null); const [data, setData] = useState<EtcOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [detailView, setDetailView] = useState<EtcDetailView>(() => parseEtcDetailView(window.location.search));
const selectDetailView = useCallback((next: EtcDetailView) => {
window.history.pushState(null, '', buildEtcDetailUrl(window.location, next));
setDetailView(next);
}, []);
useEffect(() => {
const handlePopState = () => setDetailView(parseEtcDetailView(window.location.search));
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const load = async (force = false) => { const load = async (force = false) => {
if (force) setRefreshing(true); if (force) setRefreshing(true);
@@ -41,7 +55,6 @@ export default function ETCView() {
if (!data) return <LoadingState label="正在检查 ETC 接入状态" />; if (!data) return <LoadingState label="正在检查 ETC 接入状态" />;
const { integration, kpi } = data; const { integration, kpi } = data;
const hasData = kpi.passageCount > 0;
const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0; const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0;
return ( return (
@@ -73,10 +86,10 @@ export default function ETCView() {
</SurfaceCard> </SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} /> <button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} /></button>
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" /> <button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" /></button>
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" /> <button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" /></button>
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" /> <button type="button" onClick={() => selectDetailView('bills')} className="text-left" title="查看结算账单"><MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" /></button>
</div> </div>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
@@ -99,12 +112,7 @@ export default function ETCView() {
</SurfaceCard> </SurfaceCard>
</div> </div>
{!hasData && ( <ETCDetails view={detailView} onViewChange={selectDetailView} />
<EmptyState
title="ETC 尚无可统计数据"
description="当前通行记录、账单、同步配置和同步日志均为空;完成提供方配置与首次同步后,本页将直接展示真实指标。"
/>
)}
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
</div> </div>
+29
View File
@@ -5,6 +5,7 @@ import type {
ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
ElectricChargeOrderResponse, ElectricChargeOrderResponse,
EtcOverviewResponse, EtcOverviewResponse,
EtcBillResponse, EtcTollRecordResponse,
CustomerType, DateQuickPick, ElectricVehicleScope, CustomerType, DateQuickPick, ElectricVehicleScope,
} from './types'; } from './types';
@@ -73,3 +74,31 @@ export function fetchElectricOrders(date: string, customer: ElectricVehicleScope
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> { export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`); return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
} }
export interface EtcListQuery {
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
search?: string;
}
function etcListParams(query: EtcListQuery): string {
const params = new URLSearchParams();
if (query.page) params.set('page', String(query.page));
if (query.limit) params.set('limit', String(query.limit));
if (query.startDate) params.set('startDate', query.startDate);
if (query.endDate) params.set('endDate', query.endDate);
if (query.search) params.set('search', query.search);
return params.toString();
}
export function fetchEtcRecords(query: EtcListQuery): Promise<EtcTollRecordResponse> {
const params = etcListParams(query);
return fetchJson<EtcTollRecordResponse>(`${BASE}/etc/records${params ? `?${params}` : ''}`);
}
export function fetchEtcBills(query: EtcListQuery): Promise<EtcBillResponse> {
const params = etcListParams(query);
return fetchJson<EtcBillResponse>(`${BASE}/etc/bills${params ? `?${params}` : ''}`);
}
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildEtcDetailUrl, parseEtcDetailView } from './etc-drill-context.js';
test('parses only published ETC detail views', () => {
assert.equal(parseEtcDetailView('?etcView=bills'), 'bills');
assert.equal(parseEtcDetailView('?etcView=unknown'), 'records');
});
test('updates ETC drill state without dropping other URL context', () => {
assert.equal(buildEtcDetailUrl({
pathname: '/energy',
search: '?electricScope=all&etcView=records',
hash: '#etc',
}, 'bills'), '/energy?electricScope=all&etcView=bills#etc');
});
+18
View File
@@ -0,0 +1,18 @@
export type EtcDetailView = 'records' | 'bills';
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
export function parseEtcDetailView(search: string): EtcDetailView {
return new URLSearchParams(search).get('etcView') === 'bills' ? 'bills' : 'records';
}
export function buildEtcDetailUrl(location: LocationParts, view: EtcDetailView): string {
const params = new URLSearchParams(location.search);
params.set('etcView', view);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}
+57
View File
@@ -146,3 +146,60 @@ export interface EtcOverviewResponse {
latestTransactionTime: string | null; latestTransactionTime: string | null;
}; };
} }
export interface EtcListFilters {
startDate: string | null;
endDate: string | null;
search: string;
}
export interface EtcTollRecord {
id: number;
recordCode: string;
plate: string;
customerName: string | null;
entryStation: string | null;
exitStation: string | null;
transTime: string;
tollAmount: number;
serviceFee: number;
totalAmount: number;
costType: number;
contractMatched: boolean;
reviewStatus: number;
}
export interface EtcTollRecordResponse {
page: number;
limit: number;
total: number;
totalPages: number;
filters: EtcListFilters;
summary: { tollAmount: number; serviceFee: number; totalAmount: number };
items: EtcTollRecord[];
}
export interface EtcBill {
id: number;
billCode: string;
customerName: string | null;
periodStart: string;
periodEnd: string;
tollCount: number;
tollAmount: number;
serviceFee: number;
receivableAmount: number;
paidAmount: number;
paymentStatus: number;
reviewStatus: number;
}
export interface EtcBillResponse {
page: number;
limit: number;
total: number;
totalPages: number;
filters: EtcListFilters;
summary: { receivableAmount: number; paidAmount: number };
items: EtcBill[];
}
+194
View File
@@ -9,10 +9,14 @@ import {
parseHydrogenCustomerName, parseHydrogenCustomerName,
parseHydrogenStationId, parseHydrogenStationId,
parseEnergyDate, parseEnergyDate,
parseEtcLimit,
parseEtcPage,
parseEtcSearch,
type HydrogenCustomerKind, type HydrogenCustomerKind,
} from './query.js'; } from './query.js';
import type { AuthUser } from '../../auth/types.js'; import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js'; import { canAccessEnergy } from '../../auth/types.js';
import { observeDataSource } from '../../source-telemetry.js';
const app = new Hono(); const app = new Hono();
@@ -867,4 +871,194 @@ app.get('/etc/overview', async (c) => {
return c.json(data); return c.json(data);
}); });
app.get('/etc/records', async (c) => {
const page = parseEtcPage(c.req.query('page'));
const limit = parseEtcLimit(c.req.query('limit'));
const search = parseEtcSearch(c.req.query('search'));
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
const startParam = c.req.query('startDate');
const endParam = c.req.query('endDate');
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
if (parsedStart === null || parsedEnd === null) {
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
}
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
const where = ["del_flag = '0'"];
const params: Array<string | number> = [];
if (startDate) {
where.push('trans_time >= ?');
params.push(startDate);
}
if (endDate) {
where.push('trans_time < DATE_ADD(?, INTERVAL 1 DAY)');
params.push(endDate);
}
if (search) {
where.push('(plate_number LIKE ? OR customer_name LIKE ? OR record_code LIKE ?)');
const pattern = `%${search}%`;
params.push(pattern, pattern, pattern);
}
const whereSql = where.join(' AND ');
const offset = (page - 1) * limit;
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total,
SUM(toll_amount) AS tollAmount,
SUM(service_fee) AS serviceFee,
SUM(total_amount) AS totalAmount
FROM etc_toll_record
WHERE ${whereSql}`,
params,
),
pool.query<RowDataPacket[]>(
`SELECT id,
record_code AS recordCode,
plate_number AS plate,
customer_name AS customerName,
entry_station_name AS entryStation,
exit_station_name AS exitStation,
DATE_FORMAT(trans_time, '%Y-%m-%d %H:%i:%s') AS transTime,
toll_amount AS tollAmount,
service_fee AS serviceFee,
total_amount AS totalAmount,
cost_type AS costType,
contract_matched AS contractMatched,
review_status AS reviewStatus
FROM etc_toll_record
WHERE ${whereSql}
ORDER BY trans_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]));
const summary = summaryRows[0] ?? {};
const total = Number(summary.total) || 0;
return c.json({
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
filters: { startDate: startDate || null, endDate: endDate || null, search },
summary: {
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,
},
items: detailRows.map(row => ({
id: Number(row.id),
recordCode: String(row.recordCode),
plate: String(row.plate),
customerName: row.customerName ? String(row.customerName) : null,
entryStation: row.entryStation ? String(row.entryStation) : null,
exitStation: row.exitStation ? String(row.exitStation) : null,
transTime: String(row.transTime),
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(row.totalAmount) || 0) * 100) / 100,
costType: Number(row.costType) || 0,
contractMatched: Number(row.contractMatched) === 1,
reviewStatus: Number(row.reviewStatus) || 0,
})),
});
});
app.get('/etc/bills', async (c) => {
const page = parseEtcPage(c.req.query('page'));
const limit = parseEtcLimit(c.req.query('limit'));
const search = parseEtcSearch(c.req.query('search'));
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
const startParam = c.req.query('startDate');
const endParam = c.req.query('endDate');
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
if (parsedStart === null || parsedEnd === null) {
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
}
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
const where = ["del_flag = '0'"];
const params: Array<string | number> = [];
if (startDate) {
where.push('bill_period_end >= ?');
params.push(startDate);
}
if (endDate) {
where.push('bill_period_start <= ?');
params.push(endDate);
}
if (search) {
where.push('(customer_name LIKE ? OR bill_code LIKE ?)');
const pattern = `%${search}%`;
params.push(pattern, pattern);
}
const whereSql = where.join(' AND ');
const offset = (page - 1) * limit;
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total,
SUM(receivable_amount) AS receivableAmount,
SUM(paid_amount) AS paidAmount
FROM energy_etc_bill
WHERE ${whereSql}`,
params,
),
pool.query<RowDataPacket[]>(
`SELECT id,
bill_code AS billCode,
customer_name AS customerName,
DATE_FORMAT(bill_period_start, '%Y-%m-%d') AS periodStart,
DATE_FORMAT(bill_period_end, '%Y-%m-%d') AS periodEnd,
total_toll_count AS tollCount,
total_toll_amount AS tollAmount,
total_service_fee AS serviceFee,
receivable_amount AS receivableAmount,
paid_amount AS paidAmount,
payment_status AS paymentStatus,
review_status AS reviewStatus
FROM energy_etc_bill
WHERE ${whereSql}
ORDER BY bill_period_end DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]));
const summary = summaryRows[0] ?? {};
const total = Number(summary.total) || 0;
return c.json({
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
filters: { startDate: startDate || null, endDate: endDate || null, search },
summary: {
receivableAmount: Math.round((Number(summary.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(summary.paidAmount) || 0) * 100) / 100,
},
items: detailRows.map(row => ({
id: Number(row.id),
billCode: String(row.billCode),
customerName: row.customerName ? String(row.customerName) : null,
periodStart: String(row.periodStart),
periodEnd: String(row.periodEnd),
tollCount: Number(row.tollCount) || 0,
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
receivableAmount: Math.round((Number(row.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(row.paidAmount) || 0) * 100) / 100,
paymentStatus: Number(row.paymentStatus) || 0,
reviewStatus: Number(row.reviewStatus) || 0,
})),
});
});
export default app; export default app;
+12
View File
@@ -7,6 +7,9 @@ import {
parseHydrogenCustomerName, parseHydrogenCustomerName,
parseHydrogenStationId, parseHydrogenStationId,
parseEnergyDate, parseEnergyDate,
parseEtcLimit,
parseEtcPage,
parseEtcSearch,
} from './query.js'; } from './query.js';
test('parses only non-negative integer station ids', () => { test('parses only non-negative integer station ids', () => {
@@ -57,6 +60,15 @@ test('parses only valid energy calendar dates', () => {
assert.equal(parseEnergyDate(undefined), null); assert.equal(parseEnergyDate(undefined), null);
}); });
test('bounds ETC list pagination and search input', () => {
assert.equal(parseEtcPage('3'), 3);
assert.equal(parseEtcPage('-1'), 1);
assert.equal(parseEtcLimit('5'), 10);
assert.equal(parseEtcLimit('500'), 100);
assert.equal(parseEtcSearch(' 粤A '), '粤A');
assert.equal(parseEtcSearch('x'.repeat(129)), null);
});
test('rejects invalid electric drill filters before querying data', async () => { test('rejects invalid electric drill filters before querying data', async () => {
const invalidDate = await app.request('/electric/orders?date=2026-02-31&customer=lingniu'); const invalidDate = await app.request('/electric/orders?date=2026-02-31&customer=lingniu');
assert.equal(invalidDate.status, 400); assert.equal(invalidDate.status, 400);
+16
View File
@@ -36,3 +36,19 @@ export function parseEnergyDate(value: string | undefined): string | null {
? value ? value
: null; : null;
} }
export function parseEtcPage(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}
export function parseEtcLimit(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? Math.min(Math.max(parsed, 10), 100) : 20;
}
export function parseEtcSearch(value: string | undefined): string | null {
if (value === undefined) return '';
const normalized = value.trim();
return normalized.length <= 128 ? normalized : null;
}