diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index 6d68025..fa76cae 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -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:数据可信度 @@ -190,5 +190,5 @@ SUM(fee) / NULLIF(SUM(kwh), 0) ## 10. 当前外部依赖 - 氢能数据库连接尚未恢复,真实数据对账未完成。 -- ETC 供应商未配置、未执行首次同步,记录与账单下钻无数据可验收。 +- ETC 供应商未配置、未执行首次同步;记录与账单下钻代码已完成,但真实数据仍不可验收。 - 氢能连接信息已从当前版本迁出,但历史凭据仍必须在数据库侧轮换;文档不记录任何连接值。 diff --git a/src/modules/energy/ETCDetails.tsx b/src/modules/energy/ETCDetails.tsx new file mode 100644 index 0000000..d3af732 --- /dev/null +++ b/src/modules/energy/ETCDetails.tsx @@ -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(null); + const [bills, setBills] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+
+

ETC 明细下钻

+

+ {view === 'records' ? '通行流水与费用承担' : '客户账期、应收与收款'} +

+
+
+ + +
+
+ +
+ { 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" + /> + { 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" + /> + 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" + /> + +
+
+ + {activeData ? ( +
+ {activeData.total} 条结果 + {view === 'records' && records ? ( + 通行费 {fmtMoney(records.summary.tollAmount)} · 服务费 {fmtMoney(records.summary.serviceFee)} · 合计 {fmtMoney(records.summary.totalAmount)} + ) : view === 'bills' && bills ? ( + 应收 {fmtMoney(bills.summary.receivableAmount)} · 已收 {fmtMoney(bills.summary.paidAmount)} + ) : null} +
+ ) : null} + + {error ? ( +
{error}
+ ) : loading && !activeData ? ( +
+ 正在加载 ETC 明细 +
+ ) : view === 'records' && records ? ( + records.items.length === 0 ? ( +
+ +
暂无匹配的通行记录
+
首次同步后将按通行时间倒序展示
+
+ ) : ( + <> +
+ + + + + + {records.items.map(item => ( + + + + + + + + ))} + +
通行时间车牌 / 客户入口 → 出口承担费用
{item.transTime}
{item.plate}
{item.customerName || '未关联客户'}
{item.entryStation || '-'} → {item.exitStation || '-'}{costTypeLabel(item.costType)}
{fmtMoney(item.totalAmount)}
通行 {fmtMoney(item.tollAmount)} · 服务 {fmtMoney(item.serviceFee)}
+
+
+ {records.items.map(item => ( +
+
{item.plate}
{item.transTime}
{fmtMoney(item.totalAmount)}
+
{item.entryStation || '-'} → {item.exitStation || '-'}
+
{item.customerName || '未关联客户'}{costTypeLabel(item.costType)}
+
+ ))} +
+ + ) + ) : view === 'bills' && bills ? ( + bills.items.length === 0 ? ( +
+ +
暂无匹配的结算账单
+
生成账单后将按账期结束日倒序展示
+
+ ) : ( + <> +
+ + + + {bills.items.map(item => ( + + + + + + + + ))} + +
账单 / 客户账期通行收款状态应收 / 已收
{item.billCode}
{item.customerName || '未指定客户'}
{item.periodStart} → {item.periodEnd}{item.tollCount} 次 · {fmtMoney(item.tollAmount)}{paymentLabel(item.receivableAmount, item.paidAmount)}
{fmtMoney(item.receivableAmount)}
已收 {fmtMoney(item.paidAmount)}
+
+
+ {bills.items.map(item => ( +
+
{item.billCode}
{item.customerName || '未指定客户'}
{fmtMoney(item.receivableAmount)}
已收 {fmtMoney(item.paidAmount)}
+
{item.periodStart} → {item.periodEnd}
+
{item.tollCount} 次通行{paymentLabel(item.receivableAmount, item.paidAmount)}
+
+ ))} +
+ + ) + ) : null} + + {activeData ? ( +
+ 第 {page} / {totalPages} 页 +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/modules/energy/ETCView.tsx b/src/modules/energy/ETCView.tsx index 700289f..13284f0 100644 --- a/src/modules/energy/ETCView.tsx +++ b/src/modules/energy/ETCView.tsx @@ -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 { fetchEtcOverview } from './api'; 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 { return `¥${value.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; @@ -20,6 +22,18 @@ export default function ETCView() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [detailView, setDetailView] = useState(() => 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) => { if (force) setRefreshing(true); @@ -41,7 +55,6 @@ export default function ETCView() { if (!data) return ; const { integration, kpi } = data; - const hasData = kpi.passageCount > 0; const collectionRate = kpi.receivableAmount > 0 ? kpi.paidAmount / kpi.receivableAmount * 100 : 0; return ( @@ -73,10 +86,10 @@ export default function ETCView() {
- - - - + + + +
@@ -99,12 +112,7 @@ export default function ETCView() {
- {!hasData && ( - - )} + {error && } diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 48ee79f..00d03c2 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -5,6 +5,7 @@ import type { ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricChargeOrderResponse, EtcOverviewResponse, + EtcBillResponse, EtcTollRecordResponse, CustomerType, DateQuickPick, ElectricVehicleScope, } from './types'; @@ -73,3 +74,31 @@ export function fetchElectricOrders(date: string, customer: ElectricVehicleScope export function fetchEtcOverview(force = false): Promise { return fetchJson(`${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 { + const params = etcListParams(query); + return fetchJson(`${BASE}/etc/records${params ? `?${params}` : ''}`); +} + +export function fetchEtcBills(query: EtcListQuery): Promise { + const params = etcListParams(query); + return fetchJson(`${BASE}/etc/bills${params ? `?${params}` : ''}`); +} diff --git a/src/modules/energy/etc-drill-context.test.ts b/src/modules/energy/etc-drill-context.test.ts new file mode 100644 index 0000000..523cf6f --- /dev/null +++ b/src/modules/energy/etc-drill-context.test.ts @@ -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'); +}); diff --git a/src/modules/energy/etc-drill-context.ts b/src/modules/energy/etc-drill-context.ts new file mode 100644 index 0000000..70f2b50 --- /dev/null +++ b/src/modules/energy/etc-drill-context.ts @@ -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}`; +} diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index 88b6d3c..38fd139 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -146,3 +146,60 @@ export interface EtcOverviewResponse { 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[]; +} diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 46a0925..8dcc65e 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -9,10 +9,14 @@ import { parseHydrogenCustomerName, parseHydrogenStationId, parseEnergyDate, + parseEtcLimit, + parseEtcPage, + parseEtcSearch, type HydrogenCustomerKind, } from './query.js'; import type { AuthUser } from '../../auth/types.js'; import { canAccessEnergy } from '../../auth/types.js'; +import { observeDataSource } from '../../source-telemetry.js'; const app = new Hono(); @@ -867,4 +871,194 @@ app.get('/etc/overview', async (c) => { 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 = []; + 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( + `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( + `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 = []; + 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( + `SELECT COUNT(*) AS total, + SUM(receivable_amount) AS receivableAmount, + SUM(paid_amount) AS paidAmount + FROM energy_etc_bill + WHERE ${whereSql}`, + params, + ), + pool.query( + `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; diff --git a/src/server/routes/energy/query.test.ts b/src/server/routes/energy/query.test.ts index f41ef2b..cb77594 100644 --- a/src/server/routes/energy/query.test.ts +++ b/src/server/routes/energy/query.test.ts @@ -7,6 +7,9 @@ import { parseHydrogenCustomerName, parseHydrogenStationId, parseEnergyDate, + parseEtcLimit, + parseEtcPage, + parseEtcSearch, } from './query.js'; test('parses only non-negative integer station ids', () => { @@ -57,6 +60,15 @@ test('parses only valid energy calendar dates', () => { 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 () => { const invalidDate = await app.request('/electric/orders?date=2026-02-31&customer=lingniu'); assert.equal(invalidDate.status, 400); diff --git a/src/server/routes/energy/query.ts b/src/server/routes/energy/query.ts index c569bdd..82aff43 100644 --- a/src/server/routes/energy/query.ts +++ b/src/server/routes/energy/query.ts @@ -36,3 +36,19 @@ export function parseEnergyDate(value: string | undefined): string | null { ? value : 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; +}