refactor: add electric order drilldown
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, MapPin, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import { fetchElectricMonthly, fetchElectricOrders } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
import {
|
||||
buildElectricDrillUrl,
|
||||
parseElectricDrillContext,
|
||||
type ElectricDrillContext,
|
||||
} from './electric-drill-context';
|
||||
|
||||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
@@ -43,14 +48,38 @@ function normalizeRange(start: string, end: string): { start: string; end: strin
|
||||
}
|
||||
|
||||
export default function ElectricDaily() {
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
|
||||
parseElectricDrillContext(window.location.search)
|
||||
));
|
||||
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope);
|
||||
const [pick, setPick] = useState<RangeMode>(() => (
|
||||
drillContext.startDate && drillContext.endDate || drillContext.selectedDate ? 'custom' : 'last15'
|
||||
));
|
||||
const [dateRange, setDateRange] = useState(() => {
|
||||
if (drillContext.startDate && drillContext.endDate) {
|
||||
return { start: drillContext.startDate, end: drillContext.endDate };
|
||||
}
|
||||
if (drillContext.selectedDate) {
|
||||
return { start: drillContext.selectedDate, end: drillContext.selectedDate };
|
||||
}
|
||||
return getQuickRange('last15');
|
||||
});
|
||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orders, setOrders] = useState<ElectricChargeOrderResponse | null>(null);
|
||||
const [ordersError, setOrdersError] = useState<string | null>(null);
|
||||
|
||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||
const selectedDate = drillContext.selectedDate;
|
||||
const selectedDateRef = useRef(selectedDate);
|
||||
selectedDateRef.current = selectedDate;
|
||||
|
||||
const commitDrillContext = useCallback((next: ElectricDrillContext) => {
|
||||
const url = buildElectricDrillUrl(window.location, next);
|
||||
window.history.replaceState(null, '', url);
|
||||
setDrillContext(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -62,13 +91,29 @@ export default function ElectricDaily() {
|
||||
.then(m => {
|
||||
if (cancelled) return;
|
||||
setMonths(m);
|
||||
// 默认展开最新一个月
|
||||
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
|
||||
if (m.length > 0) {
|
||||
setOpenMonths(new Set([selectedDateRef.current?.slice(0, 7) || m[0].month]));
|
||||
}
|
||||
})
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate) {
|
||||
setOrders(null);
|
||||
setOrdersError(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setOrders(null);
|
||||
setOrdersError(null);
|
||||
fetchElectricOrders(selectedDate, customer)
|
||||
.then(result => { if (!cancelled) setOrders(result); })
|
||||
.catch(e => { if (!cancelled) setOrdersError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedDate, customer]);
|
||||
|
||||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(m) ? next.delete(m) : next.add(m);
|
||||
@@ -88,16 +133,66 @@ export default function ElectricDaily() {
|
||||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||||
|
||||
const applyQuickPick = (nextPick: DateQuickPick) => {
|
||||
const nextRange = getQuickRange(nextPick);
|
||||
setPick(nextPick);
|
||||
setDateRange(getQuickRange(nextPick));
|
||||
setDateRange(nextRange);
|
||||
commitDrillContext({
|
||||
vehicleScope: customer,
|
||||
startDate: nextRange.start,
|
||||
endDate: nextRange.end,
|
||||
});
|
||||
};
|
||||
|
||||
const updateDateRange = (field: 'start' | 'end', value: string) => {
|
||||
if (!value) return;
|
||||
const nextRange = { ...dateRange, [field]: value };
|
||||
const normalized = normalizeRange(nextRange.start, nextRange.end);
|
||||
setPick('custom');
|
||||
setDateRange(prev => ({ ...prev, [field]: value }));
|
||||
setDateRange(nextRange);
|
||||
commitDrillContext({
|
||||
vehicleScope: customer,
|
||||
startDate: normalized.start,
|
||||
endDate: normalized.end,
|
||||
});
|
||||
};
|
||||
|
||||
const updateCustomer = (next: CustomerType) => {
|
||||
setCustomer(next);
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: next,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDate = (date: string) => {
|
||||
commitDrillContext({
|
||||
...drillContext,
|
||||
vehicleScope: customer,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
selectedDate: selectedDate === date ? undefined : date,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const next = parseElectricDrillContext(window.location.search);
|
||||
setDrillContext(next);
|
||||
setCustomer(next.vehicleScope);
|
||||
if (next.startDate && next.endDate) {
|
||||
setPick('custom');
|
||||
setDateRange({ start: next.startDate, end: next.endDate });
|
||||
} else if (next.selectedDate) {
|
||||
setPick('custom');
|
||||
setDateRange({ start: next.selectedDate, end: next.selectedDate });
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
@@ -154,7 +249,7 @@ export default function ElectricDaily() {
|
||||
{(['lingniu', 'external'] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCustomer(c)}
|
||||
onClick={() => updateCustomer(c)}
|
||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
@@ -248,15 +343,76 @@ export default function ElectricDaily() {
|
||||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: 'bg-slate-50/50';
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
|
||||
<div key={d.date} className="border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDate(d.date)}
|
||||
className={`grid w-full grid-cols-[minmax(0,1fr)_120px_88px] gap-3 px-3 py-2 pl-9 text-left md:grid-cols-[minmax(0,1fr)_160px_120px] ${abnormalBg}`}
|
||||
aria-expanded={selectedDate === d.date}
|
||||
>
|
||||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
||||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||||
<span className="flex items-center gap-1 text-[12px] font-bold text-slate-600">
|
||||
<ChevronRight size={12} className={`text-slate-400 transition-transform ${selectedDate === d.date ? 'rotate-90' : ''}`} />
|
||||
{d.date.slice(5)}
|
||||
</span>
|
||||
<span className="text-right text-[12px] font-bold tabular-nums text-slate-700">
|
||||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||||
</button>
|
||||
{selectedDate === d.date && (
|
||||
<div className="border-t border-blue-100 bg-blue-50/40 px-3 py-3 md:pl-9">
|
||||
{ordersError ? (
|
||||
<ErrorState message={ordersError} />
|
||||
) : !orders || orders.date !== d.date ? (
|
||||
<LoadingState label="正在加载充电订单" />
|
||||
) : orders.items.length === 0 ? (
|
||||
<EmptyState title="当日无充电订单" description="当前车辆归属下没有订单明细" />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-blue-100 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-blue-100 bg-blue-50 px-3 py-2 text-[10px] font-bold text-blue-700">
|
||||
<span>{orders.recordCount} 笔 · {orders.totalKwh.toLocaleString('zh-CN')} 度</span>
|
||||
<span>合计 ¥{orders.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
{orders.truncated && (
|
||||
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[10px] font-bold text-amber-700">
|
||||
明细超过 500 笔,当前仅展示前 500 笔;顶部合计仍为全量口径。
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 bg-slate-50 px-3 py-1.5 text-[10px] font-bold text-slate-400 md:grid-cols-[minmax(0,1fr)_100px_120px]">
|
||||
<span>时间 / 站点 / 车辆</span>
|
||||
<span className="text-right">电量</span>
|
||||
<span className="text-right">费用</span>
|
||||
</div>
|
||||
{orders.items.map(order => (
|
||||
<div key={order.id} className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 px-3 py-2 last:border-b-0 md:grid-cols-[minmax(0,1fr)_100px_120px]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[11px] font-black text-slate-700">
|
||||
<span>{order.startTime.slice(11, 16)}</span>
|
||||
<span className="truncate text-blue-600">{order.plate}</span>
|
||||
<span className="shrink-0 rounded bg-slate-100 px-1 py-0.5 text-[9px] text-slate-500">{order.orderStatus}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1 text-[10px] font-bold text-slate-400">
|
||||
<MapPin size={10} className="shrink-0" />
|
||||
<span className="truncate">{order.stationName}</span>
|
||||
<span className="hidden shrink-0 md:inline">· {order.orderNo}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[11px] font-black tabular-nums text-slate-700">
|
||||
{order.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
<div className="mt-1 text-[9px] font-bold text-slate-400">度</div>
|
||||
</div>
|
||||
<div className="text-right text-[11px] font-black tabular-nums text-emerald-600">
|
||||
¥{order.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||||
<div className="mt-1 text-[9px] font-bold text-slate-400">
|
||||
电 {order.electricityFee.toLocaleString('zh-CN')} · 服 {order.serviceFee.toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
ElectricChargeOrderResponse,
|
||||
CustomerType, DateQuickPick,
|
||||
} from './types';
|
||||
|
||||
@@ -61,3 +62,8 @@ export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDail
|
||||
if (query.endDate) q.set('endDate', query.endDate);
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchElectricOrders(date: string, customer: CustomerType): Promise<ElectricChargeOrderResponse> {
|
||||
const q = new URLSearchParams({ date, customer });
|
||||
return fetchJson<ElectricChargeOrderResponse>(`${BASE}/electric/orders?${q.toString()}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildElectricDrillUrl,
|
||||
parseElectricDrillContext,
|
||||
} from './electric-drill-context.js';
|
||||
|
||||
test('parses electric scope, range, and selected day', () => {
|
||||
assert.deepEqual(
|
||||
parseElectricDrillContext('?electricScope=external&electricStart=2026-07-01&electricEnd=2026-07-31&electricDate=2026-07-24'),
|
||||
{
|
||||
vehicleScope: 'external',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
selectedDate: '2026-07-24',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('drops invalid electric dates and defaults scope', () => {
|
||||
assert.deepEqual(
|
||||
parseElectricDrillContext('?electricScope=unknown&electricDate=2026-02-31'),
|
||||
{ vehicleScope: 'lingniu' },
|
||||
);
|
||||
});
|
||||
|
||||
test('builds namespaced electric drill URL and preserves unrelated filters', () => {
|
||||
assert.equal(
|
||||
buildElectricDrillUrl(
|
||||
{ pathname: '/energy', search: '?company=5&electricDate=2026-01-01', hash: '#electric' },
|
||||
{
|
||||
vehicleScope: 'lingniu',
|
||||
startDate: '2026-07-24',
|
||||
endDate: '2026-08-07',
|
||||
selectedDate: '2026-07-24',
|
||||
},
|
||||
),
|
||||
'/energy?company=5&electricScope=lingniu&electricStart=2026-07-24&electricEnd=2026-08-07&electricDate=2026-07-24#electric',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CustomerType } from './types';
|
||||
|
||||
export interface ElectricDrillContext {
|
||||
vehicleScope: CustomerType;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
selectedDate?: string;
|
||||
}
|
||||
|
||||
interface LocationParts {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function validDate(value: string | null): string | undefined {
|
||||
if (!value || !DATE_PATTERN.test(value)) return undefined;
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.getUTCFullYear() === year
|
||||
&& date.getUTCMonth() === month - 1
|
||||
&& date.getUTCDate() === day
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function parseElectricDrillContext(search: string): ElectricDrillContext {
|
||||
const params = new URLSearchParams(search);
|
||||
const context: ElectricDrillContext = {
|
||||
vehicleScope: params.get('electricScope') === 'external' ? 'external' : 'lingniu',
|
||||
};
|
||||
const startDate = validDate(params.get('electricStart'));
|
||||
const endDate = validDate(params.get('electricEnd'));
|
||||
const selectedDate = validDate(params.get('electricDate'));
|
||||
if (startDate) context.startDate = startDate;
|
||||
if (endDate) context.endDate = endDate;
|
||||
if (selectedDate) context.selectedDate = selectedDate;
|
||||
return context;
|
||||
}
|
||||
|
||||
export function buildElectricDrillUrl(
|
||||
location: LocationParts,
|
||||
context: ElectricDrillContext,
|
||||
): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
for (const key of ['electricScope', 'electricStart', 'electricEnd', 'electricDate']) {
|
||||
params.delete(key);
|
||||
}
|
||||
params.set('electricScope', context.vehicleScope);
|
||||
if (context.startDate) params.set('electricStart', context.startDate);
|
||||
if (context.endDate) params.set('electricEnd', context.endDate);
|
||||
if (context.selectedDate) params.set('electricDate', context.selectedDate);
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
||||
}
|
||||
@@ -100,3 +100,27 @@ export interface ElectricMonthGroup {
|
||||
fee: number;
|
||||
rows: ElectricDailyRow[];
|
||||
}
|
||||
|
||||
export interface ElectricChargeOrder {
|
||||
id: number;
|
||||
orderNo: string;
|
||||
startTime: string;
|
||||
stationName: string;
|
||||
plate: string;
|
||||
vehicleKind: 'internal' | 'external' | 'unknown';
|
||||
orderStatus: string;
|
||||
kwh: number;
|
||||
electricityFee: number;
|
||||
serviceFee: number;
|
||||
totalFee: number;
|
||||
}
|
||||
|
||||
export interface ElectricChargeOrderResponse {
|
||||
date: string;
|
||||
vehicleScope: CustomerType;
|
||||
recordCount: number;
|
||||
totalKwh: number;
|
||||
totalFee: number;
|
||||
truncated: boolean;
|
||||
items: ElectricChargeOrder[];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseHydrogenCustomerKind,
|
||||
parseHydrogenCustomerName,
|
||||
parseHydrogenStationId,
|
||||
parseEnergyDate,
|
||||
type HydrogenCustomerKind,
|
||||
} from './query.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
@@ -592,6 +593,81 @@ app.get('/electric/overview', async (c) => {
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 订单下钻:指定自然日 + 车辆归属,最多返回 500 条明细
|
||||
// =========================================================
|
||||
app.get('/electric/orders', async (c) => {
|
||||
const dateParam = c.req.query('date');
|
||||
const date = parseEnergyDate(dateParam);
|
||||
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
|
||||
|
||||
const customerParam = c.req.query('customer');
|
||||
if (customerParam !== undefined && customerParam !== 'lingniu' && customerParam !== 'external') {
|
||||
return c.json({ error: 'customer 必须是 lingniu 或 external' }, 400);
|
||||
}
|
||||
const customer = customerParam === 'external' ? 'external' : 'lingniu';
|
||||
const vehicleKind = customer === 'lingniu' ? 'internal' : 'external';
|
||||
|
||||
const data = await cached(`electric/orders?date=${date}&customer=${customer}`, async () => {
|
||||
const params = [date, date, vehicleKind];
|
||||
const [[summaryRows], [detailRows]] = await Promise.all([
|
||||
pool.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) AS recordCount,
|
||||
SUM(kwh) AS totalKwh,
|
||||
SUM(fee) AS totalFee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
AND vehicle_kind = ?`,
|
||||
params,
|
||||
),
|
||||
pool.query<RowDataPacket[]>(
|
||||
`SELECT id,
|
||||
order_no AS orderNo,
|
||||
DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS startTime,
|
||||
COALESCE(NULLIF(TRIM(station_name), ''), '未指定站点') AS stationName,
|
||||
COALESCE(NULLIF(TRIM(matched_plate), ''), NULLIF(TRIM(judged_plate), ''), NULLIF(TRIM(plate), ''), '未识别车辆') AS plate,
|
||||
vehicle_kind AS vehicleKind,
|
||||
COALESCE(NULLIF(TRIM(order_status), ''), '未指定状态') AS orderStatus,
|
||||
kwh,
|
||||
e_fee AS electricityFee,
|
||||
service_fee AS serviceFee,
|
||||
fee AS totalFee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
AND vehicle_kind = ?
|
||||
ORDER BY start_time DESC, id DESC
|
||||
LIMIT 501`,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
const summary = summaryRows[0] ?? {};
|
||||
const truncated = detailRows.length > 500;
|
||||
return {
|
||||
date,
|
||||
vehicleScope: customer,
|
||||
recordCount: Number(summary.recordCount) || 0,
|
||||
totalKwh: Math.round((Number(summary.totalKwh) || 0) * 100) / 100,
|
||||
totalFee: Math.round((Number(summary.totalFee) || 0) * 100) / 100,
|
||||
truncated,
|
||||
items: detailRows.slice(0, 500).map(row => ({
|
||||
id: Number(row.id),
|
||||
orderNo: String(row.orderNo),
|
||||
startTime: String(row.startTime),
|
||||
stationName: String(row.stationName),
|
||||
plate: String(row.plate),
|
||||
vehicleKind: row.vehicleKind as 'internal' | 'external' | 'unknown',
|
||||
orderStatus: String(row.orderStatus),
|
||||
kwh: Math.round((Number(row.kwh) || 0) * 100) / 100,
|
||||
electricityFee: Math.round((Number(row.electricityFee) || 0) * 100) / 100,
|
||||
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
|
||||
totalFee: Math.round((Number(row.totalFee) || 0) * 100) / 100,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||||
// 支持 range 参数(thisWeek / thisMonth / last15)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseHydrogenCustomerKind,
|
||||
parseHydrogenCustomerName,
|
||||
parseHydrogenStationId,
|
||||
parseEnergyDate,
|
||||
} from './query.js';
|
||||
|
||||
test('parses only non-negative integer station ids', () => {
|
||||
@@ -39,3 +40,23 @@ test('rejects invalid entity filters before querying hydrogen data', async () =>
|
||||
assert.equal(invalidCustomer.status, 400);
|
||||
assert.deepEqual(await invalidCustomer.json(), { error: 'customerName 必须是 1-128 个字符' });
|
||||
});
|
||||
|
||||
test('parses only valid energy calendar dates', () => {
|
||||
assert.equal(parseEnergyDate('2026-07-24'), '2026-07-24');
|
||||
assert.equal(parseEnergyDate('2026-02-31'), null);
|
||||
assert.equal(parseEnergyDate('2026-7-24'), null);
|
||||
assert.equal(parseEnergyDate(undefined), null);
|
||||
});
|
||||
|
||||
test('rejects invalid electric order drill filters before querying data', async () => {
|
||||
const invalidDate = await app.request('/electric/orders?date=2026-02-31&customer=lingniu');
|
||||
assert.equal(invalidDate.status, 400);
|
||||
assert.deepEqual(await invalidDate.json(), { error: 'date 必须是有效的 YYYY-MM-DD 日期' });
|
||||
|
||||
const invalidScope = await app.request('/electric/orders?date=2026-07-28&customer=all');
|
||||
assert.equal(invalidScope.status, 400);
|
||||
assert.deepEqual(await invalidScope.json(), { error: 'customer 必须是 lingniu 或 external' });
|
||||
|
||||
const unknownScope = await app.request('/electric/orders?date=2026-07-28&customer=unknown');
|
||||
assert.equal(unknownScope.status, 400);
|
||||
});
|
||||
|
||||
@@ -16,3 +16,16 @@ export function parseHydrogenCustomerName(value: string | undefined): string | n
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 && normalized.length <= 128 ? normalized : null;
|
||||
}
|
||||
|
||||
const YMD_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function parseEnergyDate(value: string | undefined): string | null {
|
||||
if (!value || !YMD_PATTERN.test(value)) return null;
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.getUTCFullYear() === year
|
||||
&& date.getUTCMonth() === month - 1
|
||||
&& date.getUTCDate() === day
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user