feat: drill hydrogen trends into orders

This commit is contained in:
kkfluous
2026-08-07 16:29:26 +08:00
parent 263d8c6245
commit a8f7548314
10 changed files with 432 additions and 14 deletions
+2 -1
View File
@@ -59,7 +59,7 @@ SUM(completed_mileage_km) / NULLIF(SUM(target_mileage_km), 0) * 100
| --- | --- | --- | --- |
| 站点排行 | 站点每日明细 | 年份、站点、日期区间、车辆范围 | 已完成 |
| 客户排行 | 客户每日明细 | 年份、客户、日期区间、车辆范围 | 已完成 |
| 每日趋势 | 站点订单 | 日期、站点、车辆范围 | 待数据源恢复后继续 |
| 每日趋势 | 加氢订单 | 日期、车辆范围、站点或客户 | 下钻代码已完成,待数据源恢复后验收 |
成本、收入和毛利必须分开:
@@ -155,6 +155,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 已完成里程考核归因、车辆详情和每日汇报跳转。
- 已完成氢能站点、客户下钻代码。
- 已完成氢能每日趋势到加氢订单的 URL 下钻、分页和结构化故障状态;待数据源恢复后验收真实明细。
- 已完成电能全部车辆、日期和订单下钻。
- 已完成 ETC 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
+67 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, UserRound, X } from 'lucide-react';
import { Building2, ChevronRight, Fuel, Plug, ReceiptText, TrendingUp, Truck, UserRound, X } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
import TrendBadge from './TrendBadge';
@@ -12,6 +12,7 @@ import {
parseHydrogenDrillContext,
type HydrogenDrillContext,
} from './hydrogen-drill-context';
import HydrogenOrders from './HydrogenOrders';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
@@ -74,6 +75,7 @@ export default function HydrogenDaily() {
const selectedCustomerName = drillContext.level === 'customer'
? drillContext.customerName
: undefined;
const selectedDate = drillContext.selectedDate;
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
const url = buildHydrogenDrillUrl(window.location, next);
@@ -128,6 +130,7 @@ export default function HydrogenDaily() {
vehicleScope: customer,
startDate: nextRange.start,
endDate: nextRange.end,
selectedDate: undefined,
}, 'replace');
};
@@ -142,6 +145,7 @@ export default function HydrogenDaily() {
vehicleScope: customer,
startDate: normalized.start,
endDate: normalized.end,
selectedDate: undefined,
}, 'replace');
};
@@ -165,6 +169,26 @@ export default function HydrogenDaily() {
}, 'replace');
};
const openOrders = (date: string) => {
commitDrillContext({
...drillContext,
vehicleScope: customer,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: date,
}, 'push');
};
const closeOrders = () => {
commitDrillContext({
...drillContext,
vehicleScope: customer,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: undefined,
}, 'replace');
};
const retryLoad = () => {
setRows(null);
setError(null);
@@ -394,7 +418,15 @@ export default function HydrogenDaily() {
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
<Bar
dataKey="totalKg"
radius={[4, 4, 0, 0]}
className="cursor-pointer"
onClick={entry => {
const date = String(entry.payload?.date ?? '');
if (date && Number(entry.payload?.totalKg) > 0) openOrders(date);
}}
>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
))}
@@ -411,18 +443,28 @@ export default function HydrogenDaily() {
</SurfaceCard>
)}
{selectedDate && (
<HydrogenOrders
date={selectedDate}
customer={customer}
stationId={selectedStationId}
customerName={selectedCustomerName}
onClose={closeOrders}
/>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!error && !(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="hidden md:block text-right"> (/Kg)</span>
<span className="text-right"> (Kg)</span>
<span className="text-right"></span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<div className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span></span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
@@ -441,20 +483,35 @@ export default function HydrogenDaily() {
: '';
return (
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
<div className={`grid w-full grid-cols-[minmax(0,1fr)_72px_64px] gap-2 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60 md:grid-cols-[minmax(0,1fr)_140px_120px_104px] md:gap-3 ${selectedDate === r.date ? 'bg-blue-50/70' : ''}`}>
<span className="flex min-w-0 items-center gap-1">
<button
type="button"
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
className="flex min-w-0 items-center gap-1 text-[12px] font-bold text-slate-700"
aria-expanded={open}
aria-label={`${open ? '收起' : '展开'} ${r.date} 加氢站明细`}
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{r.date}
<ChevronRight size={14} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
<span className="truncate">{r.date}</span>
</button>
<button
type="button"
onClick={() => openOrders(r.date)}
disabled={r.totalKg <= 0}
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-blue-100 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-25"
aria-label={`查看 ${r.date} 加氢订单`}
title="查看当日订单"
>
<ReceiptText size={13} />
</button>
</span>
<span className="hidden md:block text-right text-[12px] text-slate-300"></span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</button>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
@@ -467,7 +524,7 @@ export default function HydrogenDaily() {
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
className="grid grid-cols-[minmax(0,1fr)_72px_64px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, Loader2, ReceiptText, RefreshCw, X } from 'lucide-react';
import Blur from '../../components/Blur';
import { fetchHydrogenOrders } from './api';
import type { CustomerType, HydrogenOrderResponse } from './types';
function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export default function HydrogenOrders({
date,
customer,
stationId,
customerName,
onClose,
}: {
date: string;
customer: CustomerType;
stationId?: number;
customerName?: string;
onClose: () => void;
}) {
const [page, setPage] = useState(1);
const [data, setData] = useState<HydrogenOrderResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
setPage(1);
}, [customer, customerName, date, stationId]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setData(null);
fetchHydrogenOrders({ date, customer, stationId, customerName, page, limit: 20 })
.then(result => { if (!cancelled) setData(result); })
.catch(loadError => {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [customer, customerName, date, page, retryKey, stationId]);
const totalPages = data?.totalPages ?? 1;
const scope = customerName
? `客户:${customerName}`
: stationId !== undefined
? `站点 #${stationId}`
: customer === 'external' ? '外部车辆' : '羚牛车辆';
return (
<section className="overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm">
<header className="flex items-start justify-between gap-3 border-b border-blue-100 bg-blue-50/60 px-3 py-3 md:px-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-black text-blue-800">
<ReceiptText size={15} />{date}
</div>
<div className="mt-1 truncate text-[10px] font-bold text-blue-500">{scope} · </div>
</div>
<button
type="button"
onClick={onClose}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 ring-1 ring-blue-100 hover:text-blue-700"
aria-label="关闭加氢订单明细"
title="关闭订单明细"
>
<X size={14} />
</button>
</header>
{data ? (
<div className="grid grid-cols-3 gap-2 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[10px] font-bold md:px-4">
<div className="min-w-0"><span className="text-slate-400"> / </span><div className="mt-1 break-words text-slate-700">{data.recordCount} · {data.totalKg.toLocaleString('zh-CN')} Kg</div></div>
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-slate-700">{fmtMoney(data.totalCost)}</div></div>
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-emerald-700">{fmtMoney(data.totalRevenue)}</div></div>
</div>
) : null}
{error ? (
<div className="flex flex-col items-center px-4 py-8 text-center">
<div className="text-xs font-black text-rose-700"></div>
<div className="mt-1 text-[10px] font-bold text-rose-500">{error}</div>
<button
type="button"
onClick={() => setRetryKey(key => key + 1)}
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-100 bg-rose-50 px-3 text-[10px] font-black text-rose-700"
>
<RefreshCw size={12} />
</button>
</div>
) : loading && !data ? (
<div className="flex items-center justify-center gap-2 py-10 text-xs font-bold text-slate-400">
<Loader2 size={15} className="animate-spin" />
</div>
) : data?.items.length === 0 ? (
<div className="py-10 text-center text-xs font-bold text-slate-400"></div>
) : data ? (
<>
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[820px] 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 text-right"></th><th className="px-3 py-2 text-right"></th><th className="px-4 py-2 text-right"></th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.items.map(order => (
<tr key={order.rowNumber}>
<td className="px-4 py-3"><div className="font-bold text-slate-600">{order.refuelTime}</div><div className="mt-1 font-mono text-[10px] font-black text-slate-800"><Blur>{order.plate}</Blur></div></td>
<td className="px-3 py-3"><div className="font-bold text-slate-700"><Blur>{order.customerName}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.stationName}</div></td>
<td className="px-3 py-3 text-right font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</td>
<td className="px-3 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(order.costTotal)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">{fmtMoney(order.costPrice)} / Kg</div></td>
<td className="px-4 py-3 text-right"><div className="font-black text-emerald-700">{fmtMoney(order.revenue)}</div><div className="mt-1 text-[9px] font-bold text-slate-400"> {fmtMoney(order.customerPrice)}</div></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{data.items.map(order => (
<article key={order.rowNumber} className="px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><div className="font-mono text-xs font-black text-slate-800"><Blur>{order.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{order.refuelTime}</div></div>
<div className="shrink-0 text-right text-sm font-black text-slate-800">{order.amountKg.toLocaleString('zh-CN')} Kg</div>
</div>
<div className="mt-2 truncate text-[10px] font-bold text-slate-600"><Blur>{order.customerName}</Blur> · {order.stationName}</div>
<div className="mt-2 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><span> {fmtMoney(order.costTotal)}</span><span className="text-emerald-700"> {fmtMoney(order.revenue)}</span></div>
</article>
))}
</div>
</>
) : null}
{data ? (
<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>
);
}
+22
View File
@@ -1,6 +1,7 @@
import { fetchJson } from '../../auth/api-client';
import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenOrderResponse,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
ElectricChargeOrderResponse,
@@ -48,6 +49,27 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: Customer
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
export interface HydrogenOrderQuery {
date: string;
customer: CustomerType;
stationId?: number;
customerName?: string;
page?: number;
limit?: number;
}
export function fetchHydrogenOrders(query: HydrogenOrderQuery): Promise<HydrogenOrderResponse> {
const params = new URLSearchParams({
date: query.date,
customer: query.customer,
page: String(query.page ?? 1),
limit: String(query.limit ?? 20),
});
if (query.stationId !== undefined) params.set('stationId', String(query.stationId));
if (query.customerName) params.set('customerName', query.customerName);
return fetchJson<HydrogenOrderResponse>(`${BASE}/hydrogen/orders?${params}`);
}
export interface ElectricOverviewResponse {
kpi: ElectricKpi;
trend: ElectricDailyRow[];
@@ -81,3 +81,21 @@ test('clears station parameters when returning to overview', () => {
assert.equal(url, '/energy?company=5&hydrogenScope=lingniu#hydrogen');
});
test('preserves a selected order date in the hydrogen analysis URL', () => {
const context = parseHydrogenDrillContext(
'?hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05',
);
assert.equal(context.selectedDate, '2026-08-05');
assert.equal(
buildHydrogenDrillUrl(
{ pathname: '/energy', search: '?company=5', hash: '#hydrogen' },
context,
),
'/energy?company=5&hydrogenScope=lingniu&hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-05#hydrogen',
);
assert.equal(
parseHydrogenDrillContext('?hydrogenStart=2026-08-01&hydrogenEnd=2026-08-07&hydrogenDate=2026-08-08').selectedDate,
undefined,
);
});
@@ -11,6 +11,7 @@ export interface HydrogenDrillContext {
vehicleScope: CustomerType;
startDate?: string;
endDate?: string;
selectedDate?: string;
}
interface LocationParts {
@@ -66,8 +67,14 @@ export function parseHydrogenDrillContext(search: string): HydrogenDrillContext
if (context.level === 'customer') context.customerName = customerName;
const startDate = validDate(params.get('hydrogenStart'));
const endDate = validDate(params.get('hydrogenEnd'));
const selectedDate = validDate(params.get('hydrogenDate'));
if (startDate) context.startDate = startDate;
if (endDate) context.endDate = endDate;
const rangeStart = startDate && endDate ? (startDate <= endDate ? startDate : endDate) : undefined;
const rangeEnd = startDate && endDate ? (startDate <= endDate ? endDate : startDate) : undefined;
if (selectedDate && (!rangeStart || !rangeEnd || (selectedDate >= rangeStart && selectedDate <= rangeEnd))) {
context.selectedDate = selectedDate;
}
return context;
}
@@ -85,6 +92,7 @@ export function buildHydrogenDrillUrl(
'hydrogenScope',
'hydrogenStart',
'hydrogenEnd',
'hydrogenDate',
]) {
params.delete(key);
}
@@ -101,6 +109,7 @@ export function buildHydrogenDrillUrl(
params.set('hydrogenScope', context.vehicleScope);
if (context.startDate) params.set('hydrogenStart', context.startDate);
if (context.endDate) params.set('hydrogenEnd', context.endDate);
if (context.selectedDate) params.set('hydrogenDate', context.selectedDate);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
+28
View File
@@ -78,6 +78,34 @@ export interface HydrogenDailyRow {
stations: HydrogenStationRow[];
}
export interface HydrogenOrder {
rowNumber: number;
refuelTime: string;
plate: string;
customerName: string;
stationName: string;
amountKg: number;
costPrice: number;
costTotal: number;
customerPrice: number;
revenue: number;
}
export interface HydrogenOrderResponse {
date: string;
vehicleScope: CustomerType;
stationId: number | null;
customerName: string | null;
page: number;
limit: number;
recordCount: number;
totalPages: number;
totalKg: number;
totalCost: number;
totalRevenue: number;
items: HydrogenOrder[];
}
export interface ElectricKpi {
totalKwh: number;
totalFee: number;
+112
View File
@@ -7,6 +7,8 @@ import {
parseHydrogenCustomerKind,
parseElectricVehicleScope,
parseHydrogenCustomerName,
parseHydrogenOrderLimit,
parseHydrogenOrderPage,
parseHydrogenStationId,
parseEnergyDate,
parseEtcLimit,
@@ -538,6 +540,116 @@ app.get('/hydrogen/daily', async (c) => {
return c.json(data);
});
// =========================================================
// 氢能订单下钻:指定自然日 + 车辆归属 + 可选站点/客户
// =========================================================
app.get('/hydrogen/orders', async (c) => {
const date = parseEnergyDate(c.req.query('date'));
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
const stationIdParam = c.req.query('stationId');
const customerNameParam = c.req.query('customerName');
const stationId = parseHydrogenStationId(stationIdParam);
const customerName = parseHydrogenCustomerName(customerNameParam);
if (stationIdParam !== undefined && stationId === null) {
return c.json({ error: 'stationId 必须是非负整数' }, 400);
}
if (customerNameParam !== undefined && customerName === null) {
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
}
const page = parseHydrogenOrderPage(c.req.query('page'));
const limit = parseHydrogenOrderLimit(c.req.query('limit'));
const offset = (page - 1) * limit;
const whereParts = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= ?`,
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
];
const params: Array<string | number> = [date, date];
if (stationId !== null) {
whereParts.push('COALESCE(b.station_id, 0) = ?');
params.push(stationId);
}
if (customerName !== null) {
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
params.push(customerName);
}
const where = whereParts.join(' AND ');
const data = await cached(
`hydrogen/orders?date=${date}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}&page=${page}&limit=${limit}`,
async () => {
const hydrogenPool = getHydrogenPool();
const [[summaryRows], [detailRows]] = await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount,
SUM(b.amount_kg) AS totalKg,
SUM(b.cost_total) AS totalCost,
SUM(b.fee_total) AS totalRevenue
FROM ${HYDROGEN_TABLE} b
WHERE ${where}`,
params,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS refuelTime,
COALESCE(NULLIF(TRIM(b.plate_number), ''), '未识别车辆') AS plate,
COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') AS customerName,
COALESCE(s.station_short_name, s.station_name, NULLIF(TRIM(b.station_name), ''),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS stationName,
b.amount_kg AS amountKg,
b.cost_price AS costPrice,
b.cost_total AS costTotal,
b.customer_price AS customerPrice,
b.fee_total AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
ORDER BY b.${HYDROGEN_LOCAL} DESC,
b.plate_number ASC,
b.station_id ASC,
b.amount_kg DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]);
const summary = summaryRows[0] ?? {};
const recordCount = Number(summary.recordCount) || 0;
return {
date,
vehicleScope: customer,
stationId,
customerName,
page,
limit,
recordCount,
totalPages: Math.max(1, Math.ceil(recordCount / limit)),
totalKg: Math.round((Number(summary.totalKg) || 0) * 100) / 100,
totalCost: Math.round((Number(summary.totalCost) || 0) * 100) / 100,
totalRevenue: Math.round((Number(summary.totalRevenue) || 0) * 100) / 100,
items: detailRows.map((row, index) => ({
rowNumber: offset + index + 1,
refuelTime: String(row.refuelTime),
plate: String(row.plate),
customerName: String(row.customerName),
stationName: String(row.stationName),
amountKg: Math.round((Number(row.amountKg) || 0) * 100) / 100,
costPrice: Math.round((Number(row.costPrice) || 0) * 100) / 100,
costTotal: Math.round((Number(row.costTotal) || 0) * 100) / 100,
customerPrice: Math.round((Number(row.customerPrice) || 0) * 100) / 100,
revenue: Math.round((Number(row.revenue) || 0) * 100) / 100,
})),
};
},
{ source: 'hydrogenDatabase' },
);
return c.json(data);
});
// =========================================================
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
// =========================================================
+14
View File
@@ -5,6 +5,8 @@ import {
parseHydrogenCustomerKind,
parseElectricVehicleScope,
parseHydrogenCustomerName,
parseHydrogenOrderLimit,
parseHydrogenOrderPage,
parseHydrogenStationId,
parseEnergyDate,
parseEtcLimit,
@@ -43,6 +45,14 @@ test('normalizes bounded customer names for exact matching', () => {
assert.equal(parseHydrogenCustomerName(undefined), null);
});
test('bounds hydrogen order pagination', () => {
assert.equal(parseHydrogenOrderPage('2'), 2);
assert.equal(parseHydrogenOrderPage('0'), 1);
assert.equal(parseHydrogenOrderLimit('10'), 20);
assert.equal(parseHydrogenOrderLimit('500'), 100);
assert.equal(parseHydrogenOrderLimit(undefined), 20);
});
test('rejects invalid entity filters before querying hydrogen data', async () => {
const invalidStation = await app.request('/hydrogen/daily?stationId=-1');
assert.equal(invalidStation.status, 400);
@@ -51,6 +61,10 @@ test('rejects invalid entity filters before querying hydrogen data', async () =>
const invalidCustomer = await app.request(`/hydrogen/daily?customerName=${'x'.repeat(129)}`);
assert.equal(invalidCustomer.status, 400);
assert.deepEqual(await invalidCustomer.json(), { error: 'customerName 必须是 1-128 个字符' });
const invalidOrderDate = await app.request('/hydrogen/orders?date=2026-02-31');
assert.equal(invalidOrderDate.status, 400);
assert.deepEqual(await invalidOrderDate.json(), { error: 'date 必须是有效的 YYYY-MM-DD 日期' });
});
test('parses only valid energy calendar dates', () => {
+10
View File
@@ -24,6 +24,16 @@ export function parseHydrogenCustomerName(value: string | undefined): string | n
return normalized.length > 0 && normalized.length <= 128 ? normalized : null;
}
export function parseHydrogenOrderPage(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}
export function parseHydrogenOrderLimit(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? Math.min(Math.max(parsed, 20), 100) : 20;
}
const YMD_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function parseEnergyDate(value: string | undefined): string | null {