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:
kkfluous
2026-04-28 16:42:37 +08:00
parent 7de2d1ecd5
commit 9a4f1945d9
8 changed files with 526 additions and 197 deletions

View 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;