diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index c861020..6998de2 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -1,18 +1,28 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { ChevronRight } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import TrendBadge from './TrendBadge'; -import { ELECTRIC_MONTHLY } from './mock'; -import type { CustomerType } from './types'; +import { fetchElectricMonthly } from './api'; +import type { CustomerType, ElectricMonthGroup } from './types'; export default function ElectricDaily() { const [customer, setCustomer] = useState('external'); - const [openMonths, setOpenMonths] = useState>(new Set([ELECTRIC_MONTHLY[0]?.month])); + const [months, setMonths] = useState(null); + const [openMonths, setOpenMonths] = useState>(new Set()); + const [error, setError] = useState(null); - const months = useMemo(() => { - // mock 暂不区分客户类型,customer 切换不影响数据;保留 UI 切换以与 BI 一致 - void customer; - return ELECTRIC_MONTHLY; + useEffect(() => { + let cancelled = false; + setError(null); + fetchElectricMonthly(customer) + .then(m => { + if (cancelled) return; + setMonths(m); + // 默认展开最新一个月 + if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month])); + }) + .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); + return () => { cancelled = true; }; }, [customer]); const toggleMonth = (m: string) => setOpenMonths(prev => { @@ -47,7 +57,13 @@ export default function ElectricDaily() { 充电费用(元) 环比 - {months.map(m => { + {error ? ( +
加载失败:{error}
+ ) : months === null ? ( +
加载中…
+ ) : months.length === 0 ? ( +
暂无数据
+ ) : months.map(m => { const open = openMonths.has(m.month); return (
diff --git a/src/modules/energy/ElectricOverview.tsx b/src/modules/energy/ElectricOverview.tsx index e2fc615..113c6ce 100644 --- a/src/modules/energy/ElectricOverview.tsx +++ b/src/modules/energy/ElectricOverview.tsx @@ -1,8 +1,8 @@ -import { useMemo } from 'react'; +import { useEffect, useState } from 'react'; import { Wallet, BatteryCharging, CalendarClock } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts'; import TrendBadge from './TrendBadge'; -import { ELECTRIC_KPI, ELECTRIC_MONTHLY } from './mock'; +import { fetchElectricOverview, type ElectricOverviewResponse } from './api'; function fmtYuan(yuan: number) { return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; @@ -12,15 +12,32 @@ function fmtKwh(kwh: number) { } export default function ElectricOverview() { - const k = ELECTRIC_KPI; + const [data, setData] = useState(null); + const [error, setError] = useState(null); - // 本月每日数据(按日期升序,便于柱图按时间从左到右展示) - const trendData = useMemo(() => { - const first = ELECTRIC_MONTHLY[0]; - if (!first) return []; - return [...first.rows].sort((a, b) => a.date.localeCompare(b.date)); + useEffect(() => { + let cancelled = false; + fetchElectricOverview() + .then(d => { if (!cancelled) setData(d); }) + .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); + return () => { cancelled = true; }; }, []); + if (error) { + return
加载失败:{error}
; + } + if (!data) { + return
加载中…
; + } + const k = data.kpi; + const trendData = data.trend; + // 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份 + const trendMonthLabel = trendData[0]?.date.slice(0, 7); + const currentMonth = new Date().toISOString().slice(0, 7); + const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth + ? `${trendMonthLabel} 每日充电` + : '本月每日充电'; + return (
{/* 横向 mini KPI 头 */} @@ -52,7 +69,7 @@ export default function ElectricOverview() { {/* 本月每日充电柱图 */}
- 本月每日充电 + {chartTitle} 单位 元
diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index 4c6ff32..8dbac33 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -1,13 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ChevronRight } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts'; import TrendBadge from './TrendBadge'; -import { HYDROGEN_DAILY } from './mock'; +import { fetchHydrogenDaily } from './api'; import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types'; -const TODAY = new Date('2026-04-28'); - const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ { id: 'today', label: '当天' }, { id: 'thisWeek', label: '本周' }, @@ -17,51 +15,25 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [ { id: 'last30', label: '最近30天' }, ]; -function isInPick(date: string, pick: DateQuickPick): boolean { - const d = new Date(date); - switch (pick) { - case 'today': { - return d.toISOString().slice(0, 10) === TODAY.toISOString().slice(0, 10); - } - case 'thisWeek': { - const day = TODAY.getDay() || 7; - const start = new Date(TODAY); start.setDate(TODAY.getDate() - day + 1); - return d >= start && d <= TODAY; - } - case 'thisMonth': - return d.getFullYear() === TODAY.getFullYear() && d.getMonth() === TODAY.getMonth(); - case 'thisQuarter': { - const q = Math.floor(TODAY.getMonth() / 3); - const dq = Math.floor(d.getMonth() / 3); - return d.getFullYear() === TODAY.getFullYear() && dq === q; - } - case 'last7': { - const c = new Date(TODAY); c.setDate(TODAY.getDate() - 6); - return d >= c && d <= TODAY; - } - case 'last30': { - const c = new Date(TODAY); c.setDate(TODAY.getDate() - 29); - return d >= c && d <= TODAY; - } - } -} - export default function HydrogenDaily() { const [pick, setPick] = useState('last30'); const [customer, setCustomer] = useState('external'); const [expanded, setExpanded] = useState>(new Set()); + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); - const rows = useMemo(() => { - return HYDROGEN_DAILY - .filter(r => r.customerType === customer) - .filter(r => isInPick(r.date, pick)) - .sort((a, b) => b.date.localeCompare(a.date)); + useEffect(() => { + let cancelled = false; + setError(null); + fetchHydrogenDaily(pick, customer) + .then(r => { if (!cancelled) setRows(r); }) + .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); + return () => { cancelled = true; }; }, [pick, customer]); // 柱图:按日期升序,用于"从左到右时间流" - const trendData = useMemo(() => [...rows].sort((a, b) => a.date.localeCompare(b.date)), [rows]); - - const totalKg = rows.reduce((a, r) => a + r.totalKg, 0); + const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]); + const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0); const toggle = (date: string) => setExpanded(prev => { const next = new Set(prev); @@ -161,7 +133,11 @@ export default function HydrogenDaily() {
{/* 主行 + 子行 */} - {rows.length === 0 ? ( + {error ? ( +
加载失败:{error}
+ ) : rows === null ? ( +
加载中…
+ ) : rows.length === 0 ? (
暂无数据
) : rows.map(r => { const open = expanded.has(r.date); diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx index 642486e..bce42c0 100644 --- a/src/modules/energy/HydrogenOverview.tsx +++ b/src/modules/energy/HydrogenOverview.tsx @@ -1,6 +1,7 @@ +import { useEffect, useState } from 'react'; import { Fuel, Wallet, Coins, CalendarClock } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts'; -import { HYDROGEN_KPI, HYDROGEN_STATIONS_TOP5, HYDROGEN_REGION_SHARE } from './mock'; +import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; interface YAxisTickProps { x?: number; @@ -41,7 +42,26 @@ function fmtYuan(yuan: number) { } export default function HydrogenOverview() { - const k = HYDROGEN_KPI; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchHydrogenOverview() + .then(d => { if (!cancelled) setData(d); }) + .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); + return () => { cancelled = true; }; + }, []); + + if (error) { + return
加载失败:{error}
; + } + if (!data) { + return
加载中…
; + } + const k = data.kpi; + const top5 = data.top5; + const regions = data.regions; return (
@@ -106,7 +126,7 @@ export default function HydrogenOverview() { 单位 Kg
- + - {HYDROGEN_STATIONS_TOP5.map((_, i) => ( + {top5.map((_, i) => ( ))} - {HYDROGEN_REGION_SHARE.map((_, i) => ( + {regions.map((_, i) => ( ))} @@ -166,11 +186,11 @@ export default function HydrogenOverview() {
年合计
-
{(HYDROGEN_KPI.yearKg / 1000).toFixed(2)}T
+
{(k.yearKg / 1000).toFixed(2)}T
- {HYDROGEN_REGION_SHARE.map((r, i) => ( + {regions.map((r, i) => (
{r.region} diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts new file mode 100644 index 0000000..5130f37 --- /dev/null +++ b/src/modules/energy/api.ts @@ -0,0 +1,37 @@ +import { fetchJson } from '../../auth/api-client'; +import type { + HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenDailyRow, + ElectricKpi, ElectricDailyRow, ElectricMonthGroup, + CustomerType, DateQuickPick, +} from './types'; + +const BASE = '/api/energy'; + +export interface HydrogenOverviewResponse { + kpi: HydrogenKpi; + top5: HydrogenStationTop[]; + regions: HydrogenRegionShare[]; +} + +export function fetchHydrogenOverview(): Promise { + return fetchJson(`${BASE}/hydrogen/overview`); +} + +export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise { + const q = new URLSearchParams({ range, customer }); + return fetchJson(`${BASE}/hydrogen/daily?${q.toString()}`); +} + +export interface ElectricOverviewResponse { + kpi: ElectricKpi; + trend: ElectricDailyRow[]; +} + +export function fetchElectricOverview(): Promise { + return fetchJson(`${BASE}/electric/overview`); +} + +export function fetchElectricMonthly(customer: CustomerType): Promise { + const q = new URLSearchParams({ customer }); + return fetchJson(`${BASE}/electric/monthly?${q.toString()}`); +} diff --git a/src/modules/energy/mock.ts b/src/modules/energy/mock.ts deleted file mode 100644 index 018e969..0000000 --- a/src/modules/energy/mock.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { - HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, - HydrogenDailyRow, ElectricKpi, ElectricMonthGroup, -} from './types'; - -export const HYDROGEN_KPI: HydrogenKpi = { - yearKg: 362_430, - yearFee: 10_664_600, - ourYearKg: 245_960, - ourYearFee: 6_955_200, - customerYearKg: 116_470, - monthKg: 85_410, - monthFee: 2_612_300, - todayKg: 0, - todayFee: 0, - lingniuBornKg: 302_620, - lingniuBornFee: 100_300, -}; - -export const HYDROGEN_STATIONS_TOP5: HydrogenStationTop[] = [ - { rank: 1, name: '佛山豪汇石油加氢站', kg: 78_421.30, fee: 2_744_745, share: 0.216 }, - { rank: 2, name: '嘉兴嘉燃经开站', kg: 65_028.80, fee: 2_275_988, share: 0.179 }, - { rank: 3, name: '广州新锋交通联新加氢站', kg: 54_882.50, fee: 2_195_300, share: 0.151 }, - { rank: 4, name: '北京京辉加氢站', kg: 43_127.40, fee: 1_596_714, share: 0.119 }, - { rank: 5, name: '新疆乌鲁木齐加氢站', kg: 38_601.20, fee: 1_351_042, share: 0.106 }, -]; - -export const HYDROGEN_REGION_SHARE: HydrogenRegionShare[] = [ - { region: '广东', kg: 148_400, share: 0.409 }, - { region: '浙江', kg: 72_500, share: 0.200 }, - { region: '北京', kg: 43_500, share: 0.120 }, - { region: '新疆', kg: 39_000, share: 0.108 }, - { region: '上海', kg: 21_800, share: 0.060 }, - { region: '四川', kg: 16_300, share: 0.045 }, - { region: '河北', kg: 10_900, share: 0.030 }, - { region: '山东', kg: 7_300, share: 0.020 }, - { region: '其他', kg: 2_730, share: 0.008 }, -]; - -const HD_STATION_NAMES = [ - { name: '佛山豪汇石油加氢站', pricePerKg: 35 }, - { name: '嘉兴嘉燃经开站', pricePerKg: 35 }, - { name: '广州新锋交通联新加氢站', pricePerKg: 40 }, - { name: '北京京辉加氢站', pricePerKg: 38 }, - { name: '新疆乌鲁木齐加氢站', pricePerKg: 35 }, -]; - -function makeStations(seed: number): HydrogenDailyRow['stations'] { - const count = 2 + (seed % 3); - return HD_STATION_NAMES.slice(0, count).map((s, i) => ({ - name: s.name, - pricePerKg: s.pricePerKg, - kg: Math.round(((seed * 13 + i * 17) % 1500 + 80) * 100) / 100, - chainPct: ((seed * 7 + i * 11) % 200 - 100) / 100 / 2, - })); -} - -export const HYDROGEN_DAILY: HydrogenDailyRow[] = Array.from({ length: 30 }, (_, i) => { - const day = 28 - i; - const month = day > 0 ? 4 : 3; - const realDay = day > 0 ? day : day + 31; - const date = `2026-${String(month).padStart(2, '0')}-${String(realDay).padStart(2, '0')}`; - const stations = makeStations(i + 1); - const totalKg = stations.reduce((a, b) => a + b.kg, 0); - const chainPct = ((i * 23) % 100 - 50) / 100; - return { - date, - totalKg: Math.round(totalKg * 100) / 100, - chainPct, - customerType: i % 3 === 0 ? 'lingniu' : 'external', - stations, - }; -}); - -const APR_DAYS: Array<[string, number, number]> = [ - ['2026-04-26', 510.91, 184.82], - ['2026-04-25', 2859.61, 314.20], - ['2026-04-24', 802.64, 437.83], - ['2026-04-23', 2520.22, 495.05], - ['2026-04-22', 2234.23, 653.73], - ['2026-04-21', 3520.86, 510.06], - ['2026-04-20', 527.65, 295.05], - ['2026-04-19', 3151.97, 593.55], - ['2026-04-18', 1616.38, 183.84], - ['2026-04-17', 1069.09, 597.73], - ['2026-04-16', 2186.34, 396.63], - ['2026-04-15', 2568.16, 572.27], - ['2026-04-14', 2315.38, 489.82], - ['2026-04-13', 2274.88, 423.15], - ['2026-04-12', 2742.85, 248.52], - ['2026-04-11', 599.67, 299.13], - ['2026-04-10', 2576.59, 806.44], - ['2026-04-09', 2627.30, 814.80], - ['2026-04-08', 2058.35, 573.11], - ['2026-04-07', 2739.61, 261.56], -]; - -function buildElectricRows(days: Array<[string, number, number]>) { - return days.map(([date, kwh, fee], i) => { - const prev = days[i + 1]?.[1]; - const chainPct = prev ? (kwh - prev) / prev : 0; - return { date, kwh, fee, chainPct }; - }); -} - -const APR_KWH_SUM = APR_DAYS.reduce((a, [, k]) => a + k, 0); -const APR_FEE_SUM = APR_DAYS.reduce((a, [, , f]) => a + f, 0); -const [TODAY_DATE, TODAY_KWH, TODAY_FEE] = APR_DAYS[0]; -const [, PREV_KWH] = APR_DAYS[1]; -void TODAY_DATE; - -export const ELECTRIC_KPI: ElectricKpi = { - totalKwh: 817_632.24, - totalFee: 151_542.92, - monthKwh: APR_KWH_SUM, - monthFee: APR_FEE_SUM, - todayKwh: TODAY_KWH, - todayFee: TODAY_FEE, - todayChainPct: (TODAY_KWH - PREV_KWH) / PREV_KWH, -}; - -export const ELECTRIC_MONTHLY: ElectricMonthGroup[] = [ - { - month: '2026-04', - kwh: APR_DAYS.reduce((a, [, k]) => a + k, 0), - fee: APR_DAYS.reduce((a, [, , f]) => a + f, 0), - rows: buildElectricRows(APR_DAYS), - }, -]; diff --git a/src/server/index.ts b/src/server/index.ts index 68222d6..00ed8f0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,6 +6,7 @@ import dotenv from 'dotenv'; import vehiclesRouter from './routes/vehicles.js'; import mileageRouter from './routes/mileage/index.js'; import schedulingRouter from './routes/scheduling/index.js'; +import energyRouter from './routes/energy/index.js'; import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import authRouter from './auth/login.js'; import { authMiddleware } from './auth/middleware.js'; @@ -25,6 +26,7 @@ app.use('/api/*', authMiddleware); app.route('/api/vehicles', vehiclesRouter); app.route('/api/mileage', mileageRouter); app.route('/api/scheduling', schedulingRouter); +app.route('/api/energy', energyRouter); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts new file mode 100644 index 0000000..f02bf27 --- /dev/null +++ b/src/server/routes/energy/index.ts @@ -0,0 +1,390 @@ +import { Hono } from 'hono'; +import type { RowDataPacket } from 'mysql2'; +import pool from '../../db.js'; + +const app = new Hono(); + +const HYDROGEN_MIN_DATE = '2024-01-01'; + +// 把 DATETIME (UTC 字面值) 转换为 CST 用户日期 +const HYDROGEN_LOCAL = `DATE_ADD(hydrogen_time, INTERVAL 8 HOUR)`; +const ELECTRIC_LOCAL = `DATE_ADD(charging_start_time, INTERVAL 8 HOUR)`; + +type CustomerKind = 'external' | 'lingniu' | 'all'; + +function customerClause(field: string, customer: CustomerKind): string { + if (customer === 'lingniu') return `${field} IS NULL`; + if (customer === 'external') return `${field} IS NOT NULL`; + return '1=1'; +} + +type Range = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30'; + +function rangeClause(localExpr: string, range: Range): string { + switch (range) { + case 'today': return `DATE(${localExpr}) = CURDATE()`; + case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`; + case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`; + case 'thisQuarter': return `YEAR(${localExpr}) = YEAR(CURDATE()) AND QUARTER(${localExpr}) = QUARTER(CURDATE())`; + case 'last7': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND CURDATE()`; + case 'last30': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 29 DAY) AND CURDATE()`; + } +} + +// ========================================================= +// 氢能 总览:KPI + Top5 + 区域占比 +// ========================================================= +app.get('/hydrogen/overview', async (c) => { + // KPI(年/月/日 + 我方/客户分解 + 累计羚牛承担) + const [kpiRows] = await pool.query( + `SELECT + SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) + THEN hydrogen_quantity ELSE 0 END) AS yearKg, + SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) + THEN cost_expense ELSE 0 END) AS yearFee, + SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NULL + THEN hydrogen_quantity ELSE 0 END) AS ourYearKg, + SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NULL + THEN cost_expense ELSE 0 END) AS ourYearFee, + SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND customer_id IS NOT NULL + THEN hydrogen_quantity ELSE 0 END) AS customerYearKg, + SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') + THEN hydrogen_quantity ELSE 0 END) AS monthKg, + SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') + THEN cost_expense ELSE 0 END) AS monthFee, + SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE() + THEN hydrogen_quantity ELSE 0 END) AS todayKg, + SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE() + THEN cost_expense ELSE 0 END) AS todayFee, + SUM(CASE WHEN customer_id IS NULL + THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg, + SUM(CASE WHEN customer_id IS NULL + THEN cost_expense ELSE 0 END) AS lingniuBornFee + FROM tab_energy_hydrogen_bill + WHERE is_deleted = 0 AND hydrogen_time >= ?`, + [HYDROGEN_MIN_DATE], + ); + const k = kpiRows[0] ?? {}; + const kpi = { + yearKg: Number(k.yearKg) || 0, + yearFee: Number(k.yearFee) || 0, + ourYearKg: Number(k.ourYearKg) || 0, + ourYearFee: Number(k.ourYearFee) || 0, + customerYearKg: Number(k.customerYearKg) || 0, + monthKg: Number(k.monthKg) || 0, + monthFee: Number(k.monthFee) || 0, + todayKg: Number(k.todayKg) || 0, + todayFee: Number(k.todayFee) || 0, + lingniuBornKg: Number(k.lingniuBornKg) || 0, + lingniuBornFee: Number(k.lingniuBornFee) || 0, + }; + + // Top5 加氢站(本年) + const [top5Rows] = await pool.query( + `SELECT b.hydrogen_station_id AS id, + COALESCE(s.short_name, s.name, os.fixed_station_name, os.station_name, '未知站点') AS name, + SUM(b.hydrogen_quantity) AS kg, + SUM(b.cost_expense) AS fee + FROM tab_energy_hydrogen_bill b + LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id + LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id + WHERE b.is_deleted = 0 + AND b.hydrogen_time >= ? + AND YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) + GROUP BY b.hydrogen_station_id + ORDER BY kg DESC + LIMIT 5`, + [HYDROGEN_MIN_DATE], + ); + const top5KgSum = kpi.yearKg || 1; + const top5 = top5Rows.map((r, i) => ({ + rank: i + 1, + name: r.name as string, + kg: Number(r.kg) || 0, + fee: Number(r.fee) || 0, + share: (Number(r.kg) || 0) / top5KgSum, + })); + + // 区域占比(按城市,本年)— 取前 8,其余合并为"其他" + const [regionRows] = await pool.query( + `SELECT region, SUM(kg) AS kg FROM ( + SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region, + b.hydrogen_quantity AS kg + FROM tab_energy_hydrogen_bill b + LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id + LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id + WHERE b.is_deleted = 0 + AND b.hydrogen_time >= ? + AND YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) + ) r + GROUP BY region + ORDER BY kg DESC`, + [HYDROGEN_MIN_DATE], + ); + const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1; + const TOP_REGIONS = 8; + const top = regionRows.slice(0, TOP_REGIONS); + const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0); + const regions = [ + ...top.map(r => ({ + region: r.region as string, + kg: Number(r.kg) || 0, + share: (Number(r.kg) || 0) / totalKg, + })), + ...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []), + ]; + + return c.json({ kpi, top5, regions }); +}); + +// ========================================================= +// 氢能 每日:日期范围 + 客户类型 + 站点级下钻 +// ========================================================= +app.get('/hydrogen/daily', async (c) => { + const range = (c.req.query('range') || 'last30') as Range; + const customer = (c.req.query('customer') || 'external') as CustomerKind; + + const where = [ + 'b.is_deleted = 0', + `b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`, + rangeClause(`b.hydrogen_time + INTERVAL 8 HOUR`, range), + customerClause('b.customer_id', customer), + ].join(' AND '); + + // 站点级聚合(每日 × 每站)。前端组装成 day → stations + const [stationRows] = await pool.query( + `SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d, + b.hydrogen_station_id AS stationId, + COALESCE(s.short_name, s.name, os.fixed_station_name, os.station_name, '未知站点') AS stationName, + SUM(b.hydrogen_quantity) AS kg, + AVG(b.cost_price) AS pricePerKg + FROM tab_energy_hydrogen_bill b + LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id + LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id + WHERE ${where} + GROUP BY d, b.hydrogen_station_id + ORDER BY d DESC, kg DESC`, + ); + + // 站点环比:同站点上一条记录的 kg + // 按 stationId 分组、按日期升序计算 + type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number }; + const flat: StationRow[] = stationRows.map(r => ({ + date: r.d as string, + stationId: Number(r.stationId), + name: r.stationName as string, + kg: Number(r.kg) || 0, + pricePerKg: Number(r.pricePerKg) || 0, + })); + + // 计算日级总量 + 日级环比 + const dayMap = new Map(); + for (const s of flat) { + if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] }); + const e = dayMap.get(s.date)!; + e.totalKg += s.kg; + e.stations.push(s); + } + const dates = Array.from(dayMap.keys()).sort(); // ASC for chain + const dayChainPct = new Map(); + let prev = 0; + for (const d of dates) { + const cur = dayMap.get(d)!.totalKg; + dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0); + prev = cur; + } + + // 站点级环比:按 stationId 分组按日期升序 + const stationPrev = new Map(); + const stationChain = new Map(); // key = `${date}|${stationId}` + // 需要按 stationId 分组排序 + const byStation = new Map(); + for (const s of flat) { + if (!byStation.has(s.stationId)) byStation.set(s.stationId, []); + byStation.get(s.stationId)!.push(s); + } + for (const [, list] of byStation) { + list.sort((a, b) => a.date.localeCompare(b.date)); + let p = 0; + for (const r of list) { + stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0); + p = r.kg; + } + } + + // 组装为 HydrogenDailyRow[],按日期降序 + const result = Array.from(dayMap.entries()) + .map(([date, info]) => ({ + date, + totalKg: Math.round(info.totalKg * 100) / 100, + chainPct: dayChainPct.get(date) ?? 0, + customerType: customer === 'lingniu' ? 'lingniu' : 'external', + stations: info.stations + .slice() + .sort((a, b) => b.kg - a.kg) + .map(s => ({ + name: s.name, + pricePerKg: Math.round(s.pricePerKg * 100) / 100, + kg: Math.round(s.kg * 100) / 100, + chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0, + })), + })) + .sort((a, b) => b.date.localeCompare(a.date)); + + return c.json(result); +}); + +// ========================================================= +// 电能 总览:KPI + 本月每日柱图数据 +// ========================================================= +app.get('/electric/overview', async (c) => { + const [kpiRows] = await pool.query( + `SELECT + SUM(charging_degree) AS totalKwh, + SUM(cost_expense) AS totalFee, + SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') + THEN charging_degree ELSE 0 END) AS monthKwh, + SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') + THEN cost_expense ELSE 0 END) AS monthFee, + SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE() + THEN charging_degree ELSE 0 END) AS todayKwh, + SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE() + THEN cost_expense ELSE 0 END) AS todayFee + FROM tab_energy_electricity_bill + WHERE is_deleted = 0`, + ); + const k = kpiRows[0] ?? {}; + const totalKwh = Number(k.totalKwh) || 0; + const totalFee = Number(k.totalFee) || 0; + const monthKwh = Number(k.monthKwh) || 0; + const monthFee = Number(k.monthFee) || 0; + const todayKwh = Number(k.todayKwh) || 0; + const todayFee = Number(k.todayFee) || 0; + + // 本月每日(用于柱图) + const [trendRows] = await pool.query( + `SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date, + SUM(charging_degree) AS kwh, + SUM(cost_expense) AS fee + FROM tab_energy_electricity_bill + WHERE is_deleted = 0 + AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') + GROUP BY date + ORDER BY date ASC`, + ); + // 若本月无数据(电能数据滞后),降级展示最近一个有数据的自然月 + let trend = trendRows; + if (trend.length === 0) { + const [fallback] = await pool.query( + `SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date, + SUM(charging_degree) AS kwh, + SUM(cost_expense) AS fee + FROM tab_energy_electricity_bill + WHERE is_deleted = 0 + AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = ( + SELECT DATE_FORMAT(MAX(${ELECTRIC_LOCAL}), '%Y-%m') + FROM tab_energy_electricity_bill + WHERE is_deleted = 0 + ) + GROUP BY date + ORDER BY date ASC`, + ); + trend = fallback; + } + const trendArr = trend.map(r => ({ + date: r.date as string, + kwh: Math.round((Number(r.kwh) || 0) * 100) / 100, + fee: Math.round((Number(r.fee) || 0) * 100) / 100, + chainPct: 0, + })); + // 计算环比 + for (let i = 1; i < trendArr.length; i++) { + const prev = trendArr[i - 1].kwh; + trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0; + } + + // 今日环比 = 今日 kwh / 上一个有数据的自然日 kwh - 1 + let todayChainPct = 0; + if (todayKwh > 0) { + const [prevRow] = await pool.query( + `SELECT SUM(charging_degree) AS kwh + FROM tab_energy_electricity_bill + WHERE is_deleted = 0 + AND DATE(${ELECTRIC_LOCAL}) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`, + ); + const prevKwh = Number(prevRow[0]?.kwh) || 0; + todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0; + } + + return c.json({ + kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct }, + trend: trendArr, + }); +}); + +// ========================================================= +// 电能 每日:月份分组 + 日级行 +// ========================================================= +app.get('/electric/monthly', async (c) => { + const customer = (c.req.query('customer') || 'external') as CustomerKind; + + const where = [ + 'is_deleted = 0', + customerClause('customer_id', customer), + ].join(' AND '); + + // 取最近 6 个月 + const [rows] = await pool.query( + `SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') AS month, + DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date, + SUM(charging_degree) AS kwh, + SUM(cost_expense) AS fee + FROM tab_energy_electricity_bill + WHERE ${where} + AND ${ELECTRIC_LOCAL} >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) + GROUP BY month, date + ORDER BY date DESC`, + ); + + // 组装 month group with daily rows + chainPct + const monthMap = new Map>(); + for (const r of rows) { + const m = r.month as string; + if (!monthMap.has(m)) monthMap.set(m, []); + monthMap.get(m)!.push({ + date: r.date as string, + kwh: Number(r.kwh) || 0, + fee: Number(r.fee) || 0, + }); + } + + const months = Array.from(monthMap.entries()) + .sort((a, b) => b[0].localeCompare(a[0])) + .map(([month, daysDesc]) => { + // 计算环比:daysDesc 是 DESC,需要按 ASC 算 + const asc = [...daysDesc].sort((a, b) => a.date.localeCompare(b.date)); + const chain = new Map(); + for (let i = 1; i < asc.length; i++) { + const prev = asc[i - 1].kwh; + chain.set(asc[i].date, prev > 0 ? (asc[i].kwh - prev) / prev : 0); + } + const rowsWithChain = daysDesc.map(d => ({ + date: d.date, + kwh: Math.round(d.kwh * 100) / 100, + fee: Math.round(d.fee * 100) / 100, + chainPct: chain.get(d.date) ?? 0, + })); + const kwhSum = daysDesc.reduce((s, d) => s + d.kwh, 0); + const feeSum = daysDesc.reduce((s, d) => s + d.fee, 0); + return { + month, + kwh: Math.round(kwhSum * 100) / 100, + fee: Math.round(feeSum * 100) / 100, + rows: rowsWithChain, + }; + }); + + return c.json(months); +}); + +export default app;