diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index fa76cae..de6cf5b 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -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 通行记录与结算账单下钻代码、筛选、分页和未同步空状态;待首次真实同步后验收真实数据。 diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index 0b08fe8..5df637d 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -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 }} /> )} - + { + const date = String(entry.payload?.date ?? ''); + if (date && Number(entry.payload?.totalKg) > 0) openOrders(date); + }} + > {trendData.map((_, i) => ( ))} @@ -411,18 +443,28 @@ export default function HydrogenDaily() { )} + {selectedDate && ( + + )} + {/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */} {!error && !(customer === 'external' && rows !== null && totalKg === 0) && (
{/* 表头 */} -
+
日期 / 加氢站 单价 (元/Kg) 加氢量 (Kg) 环比
{/* 合计行 */} -
+
合计 {totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} @@ -441,20 +483,35 @@ export default function HydrogenDaily() { : ''; return (
- + {r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} - +
{open && ( (
diff --git a/src/modules/energy/HydrogenOrders.tsx b/src/modules/energy/HydrogenOrders.tsx new file mode 100644 index 0000000..e9fdd06 --- /dev/null +++ b/src/modules/energy/HydrogenOrders.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+
+ {date} 加氢订单 +
+
{scope} · 按加氢时间倒序
+
+ +
+ + {data ? ( +
+
订单 / 加氢量
{data.recordCount} 笔 · {data.totalKg.toLocaleString('zh-CN')} Kg
+
采购成本
{fmtMoney(data.totalCost)}
+
客户收入
{fmtMoney(data.totalRevenue)}
+
+ ) : null} + + {error ? ( +
+
订单数据暂不可用
+
{error}
+ +
+ ) : loading && !data ? ( +
+ 正在加载加氢订单 +
+ ) : data?.items.length === 0 ? ( +
当前筛选下无加氢订单
+ ) : data ? ( + <> +
+ + + + + + {data.items.map(order => ( + + + + + + + + ))} + +
时间 / 车辆客户 / 站点加氢量采购成本客户收入
{order.refuelTime}
{order.plate}
{order.customerName}
{order.stationName}
{order.amountKg.toLocaleString('zh-CN')} Kg
{fmtMoney(order.costTotal)}
{fmtMoney(order.costPrice)} / Kg
{fmtMoney(order.revenue)}
单价 {fmtMoney(order.customerPrice)}
+
+
+ {data.items.map(order => ( +
+
+
{order.plate}
{order.refuelTime}
+
{order.amountKg.toLocaleString('zh-CN')} Kg
+
+
{order.customerName} · {order.stationName}
+
成本 {fmtMoney(order.costTotal)}收入 {fmtMoney(order.revenue)}
+
+ ))} +
+ + ) : null} + + {data ? ( +
+ 第 {page} / {totalPages} 页 +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 00d03c2..d09374b 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -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(`${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 { + 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(`${BASE}/hydrogen/orders?${params}`); +} + export interface ElectricOverviewResponse { kpi: ElectricKpi; trend: ElectricDailyRow[]; diff --git a/src/modules/energy/hydrogen-drill-context.test.ts b/src/modules/energy/hydrogen-drill-context.test.ts index e62ea19..b677cbe 100644 --- a/src/modules/energy/hydrogen-drill-context.test.ts +++ b/src/modules/energy/hydrogen-drill-context.test.ts @@ -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, + ); +}); diff --git a/src/modules/energy/hydrogen-drill-context.ts b/src/modules/energy/hydrogen-drill-context.ts index 1822340..4444aa6 100644 --- a/src/modules/energy/hydrogen-drill-context.ts +++ b/src/modules/energy/hydrogen-drill-context.ts @@ -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}`; diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index 38fd139..baceb0d 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -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; diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 8dcc65e..896b23f 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -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 = [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( + `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( + `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 // ========================================================= diff --git a/src/server/routes/energy/query.test.ts b/src/server/routes/energy/query.test.ts index cb77594..614853d 100644 --- a/src/server/routes/energy/query.test.ts +++ b/src/server/routes/energy/query.test.ts @@ -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', () => { diff --git a/src/server/routes/energy/query.ts b/src/server/routes/energy/query.ts index 82aff43..0c3c369 100644 --- a/src/server/routes/energy/query.ts +++ b/src/server/routes/energy/query.ts @@ -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 {