feat(energy): connect to real DB (lingniu_prod)
Replace front-end mock data with live API backed by: - tab_energy_hydrogen_bill (66.5K rows) joined with tab_hydrogen_site (internal stations) and tab_outside_hydrogen_site (external stations, joined via inner_site_id) - tab_energy_electricity_bill (4.4K rows, all 龙王路充电站) New server routes (src/server/routes/energy/): - GET /api/energy/hydrogen/overview → KPI + Top5 站点 + 区域占比 - GET /api/energy/hydrogen/daily?range=&customer= → 日级 + 站点级下钻 - GET /api/energy/electric/overview → KPI + 本月柱图 (fallback to last available month if current month has no data) - GET /api/energy/electric/monthly?customer= → 6 个月分组日级表 Business rules encoded server-side: - 客户类型: customer_id IS NULL = 羚牛承担, NOT NULL = 外部 - 时区: DATETIME 列字面值是 UTC,分组前 +8h 转成 CST - 数据清理: hydrogen_time >= 2024-01-01 (排除 1900 年脏数据) - 站点名 fallback: short_name → name → fixed_station_name → station_name → '未知站点' - 区域归一化: SUBSTRING_INDEX(city, '-', -1) 取最后一段,去掉 '省'/'市' 让 '四川省-成都市' 和 '成都市' 合并为 '成都' Component changes: - All 4 components (HydrogenOverview, HydrogenDaily, ElectricOverview, ElectricDaily) now use useEffect + fetch with loading/error states - HydrogenDaily filtering moved to server (range + customer params) → drops client-side TODAY constant + isInPick switch - ElectricOverview chart title is dynamic: shows 'YYYY-MM 每日充电' when fallback kicks in (current month has no data) - mock.ts deleted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<CustomerType>('external');
|
||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set([ELECTRIC_MONTHLY[0]?.month]));
|
||||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
<span className="text-right">充电费用(元)</span>
|
||||
<span className="text-right">环比</span>
|
||||
</div>
|
||||
{months.map(m => {
|
||||
{error ? (
|
||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||||
) : months === null ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||||
) : months.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
||||
) : months.map(m => {
|
||||
const open = openMonths.has(m.month);
|
||||
return (
|
||||
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
||||
|
||||
@@ -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<ElectricOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
||||
}
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 横向 mini KPI 头 */}
|
||||
@@ -52,7 +69,7 @@ export default function ElectricOverview() {
|
||||
{/* 本月每日充电柱图 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">本月每日充电</span>
|
||||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
|
||||
@@ -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<DateQuickPick>('last30');
|
||||
const [customer, setCustomer] = useState<CustomerType>('external');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const rows = useMemo<HydrogenDailyRow[]>(() => {
|
||||
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() {
|
||||
<span />
|
||||
</div>
|
||||
{/* 主行 + 子行 */}
|
||||
{rows.length === 0 ? (
|
||||
{error ? (
|
||||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||||
) : rows === null ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
||||
) : rows.map(r => {
|
||||
const open = expanded.has(r.date);
|
||||
|
||||
@@ -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<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">加载中…</div>;
|
||||
}
|
||||
const k = data.kpi;
|
||||
const top5 = data.top5;
|
||||
const regions = data.regions;
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||||
@@ -106,7 +126,7 @@ export default function HydrogenOverview() {
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={HYDROGEN_STATIONS_TOP5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 0 }}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 0 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
@@ -121,7 +141,7 @@ export default function HydrogenOverview() {
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{HYDROGEN_STATIONS_TOP5.map((_, i) => (
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill={`url(#topBarGrad)`} />
|
||||
))}
|
||||
<LabelList
|
||||
@@ -150,14 +170,14 @@ export default function HydrogenOverview() {
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={HYDROGEN_REGION_SHARE}
|
||||
data={regions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{HYDROGEN_REGION_SHARE.map((_, i) => (
|
||||
{regions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -166,11 +186,11 @@ export default function HydrogenOverview() {
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(HYDROGEN_KPI.yearKg / 1000).toFixed(2)}T</div>
|
||||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{HYDROGEN_REGION_SHARE.map((r, i) => (
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600">{r.region}</span>
|
||||
|
||||
37
src/modules/energy/api.ts
Normal file
37
src/modules/energy/api.ts
Normal file
@@ -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<HydrogenOverviewResponse> {
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview`);
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
const q = new URLSearchParams({ range, customer });
|
||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||
}
|
||||
|
||||
export interface ElectricOverviewResponse {
|
||||
kpi: ElectricKpi;
|
||||
trend: ElectricDailyRow[];
|
||||
}
|
||||
|
||||
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType): Promise<ElectricMonthGroup[]> {
|
||||
const q = new URLSearchParams({ customer });
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
@@ -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() }));
|
||||
|
||||
|
||||
390
src/server/routes/energy/index.ts
Normal file
390
src/server/routes/energy/index.ts
Normal file
@@ -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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<string, { totalKg: number; stations: typeof flat }>();
|
||||
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<string, number>();
|
||||
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<number, number>();
|
||||
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
|
||||
// 需要按 stationId 分组排序
|
||||
const byStation = new Map<number, StationRow[]>();
|
||||
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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<string, Array<{ date: string; kwh: number; fee: number }>>();
|
||||
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<string, number>();
|
||||
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;
|
||||
Reference in New Issue
Block a user