feat: drill hydrogen trends into orders
This commit is contained in:
@@ -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 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// =========================================================
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user