All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增 ENERGY_ACCESS_ROLES 与 canAccessEnergy(roles) 守卫(全量权限角色亦可访问) - 后端 /api/energy/* 加模块级守卫:无角色返回 403 - 前端 App.tsx 按角色动态注入 EnergyModule,无权限时主导航不显示 - dev mock 用户(前端 + 后端)追加 BI-LEADER-ENERGY 便于本地调试 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
614 lines
25 KiB
TypeScript
614 lines
25 KiB
TypeScript
import { Hono } from 'hono';
|
||
import type { RowDataPacket } from 'mysql2';
|
||
import pool from '../../db.js';
|
||
import { cached } from './cache.js';
|
||
import type { AuthUser } from '../../auth/types.js';
|
||
import { canAccessEnergy } from '../../auth/types.js';
|
||
|
||
const app = new Hono();
|
||
|
||
// 模块级访问守卫:dev 旁路 auth 时 user 为 undefined,直接放行;
|
||
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
|
||
app.use('*', async (c, next) => {
|
||
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
|
||
if (user && !canAccessEnergy(user.roles)) {
|
||
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
||
}
|
||
return next();
|
||
});
|
||
|
||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||
|
||
// hydrogen_time 已是 CST 字面值,直接使用即可(不再 +8 小时)
|
||
const HYDROGEN_LOCAL = `hydrogen_time`;
|
||
const ELECTRIC_LOCAL = `charging_start_time`;
|
||
|
||
type CustomerKind = 'external' | 'lingniu' | 'all';
|
||
|
||
// 外部/我司判定:truck_id 为空 = 外部;truck_id 非空 = 我司(羚牛车辆)
|
||
function customerClause(field: string, customer: CustomerKind): string {
|
||
if (customer === 'external') return `${field} IS NULL`;
|
||
if (customer === 'lingniu') return `${field} IS NOT NULL`;
|
||
return '1=1';
|
||
}
|
||
|
||
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
||
|
||
function rangeClause(localExpr: string, range: Range): string {
|
||
switch (range) {
|
||
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
||
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
||
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
|
||
}
|
||
}
|
||
|
||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||
function enumerateDates(range: Range): string[] {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
let start: Date;
|
||
if (range === 'thisWeek') {
|
||
// 周一为一周开始(与 YEARWEEK(?, 1) 一致)
|
||
const day = today.getDay() || 7; // 周日 7
|
||
start = new Date(today);
|
||
start.setDate(today.getDate() - (day - 1));
|
||
} else if (range === 'thisMonth') {
|
||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||
} else {
|
||
start = new Date(today);
|
||
start.setDate(today.getDate() - 14);
|
||
}
|
||
const result: string[] = [];
|
||
const cur = new Date(start);
|
||
while (cur <= today) {
|
||
result.push(fmt(cur));
|
||
cur.setDate(cur.getDate() + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// =========================================================
|
||
// 氢能 总览:KPI + Top5 + 区域占比
|
||
// =========================================================
|
||
app.get('/hydrogen/overview', async (c) => {
|
||
const yearParam = c.req.query('year');
|
||
const force = c.req.query('force') === '1';
|
||
const today = new Date();
|
||
const todayYear = today.getFullYear();
|
||
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
||
|
||
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
|
||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||
const [yearListRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||
FROM tab_energy_hydrogen_bill
|
||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
|
||
ORDER BY y DESC`,
|
||
[HYDROGEN_MIN_DATE],
|
||
);
|
||
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
|
||
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
|
||
const isCurrentYear = year === todayYear;
|
||
|
||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||
THEN cost_expense ELSE 0 END) AS yearFee,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||
THEN customer_expense ELSE 0 END) AS yearRevenue,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
|
||
THEN cost_expense ELSE 0 END) AS ourYearFee,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
|
||
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN cost_expense ELSE 0 END) AS monthFee,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
|
||
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN customer_expense ELSE 0 END) AS monthRevenue,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||
THEN cost_expense ELSE 0 END) AS todayFee,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
|
||
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||
THEN customer_expense ELSE 0 END) AS todayRevenue,
|
||
SUM(CASE WHEN truck_id IS NOT NULL
|
||
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
|
||
SUM(CASE WHEN truck_id IS NOT NULL
|
||
THEN cost_expense ELSE 0 END) AS lingniuBornFee
|
||
FROM tab_energy_hydrogen_bill
|
||
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
|
||
[year, year, year, year, year, year, year,
|
||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
|
||
HYDROGEN_MIN_DATE],
|
||
);
|
||
const k = kpiRows[0] ?? {};
|
||
const yearFee = Number(k.yearFee) || 0;
|
||
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
|
||
const yearRevenue = Number(k.yearRevenue) || 0;
|
||
const monthFee = Number(k.monthFee) || 0;
|
||
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
|
||
const monthRevenue = Number(k.monthRevenue) || 0;
|
||
const todayFee = Number(k.todayFee) || 0;
|
||
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
|
||
const todayRevenue = Number(k.todayRevenue) || 0;
|
||
const kpi = {
|
||
yearKg: Number(k.yearKg) || 0,
|
||
yearFee,
|
||
yearRevenue,
|
||
yearProfit: yearRevenue - yearCustomerCost,
|
||
ourYearKg: Number(k.ourYearKg) || 0,
|
||
ourYearFee: Number(k.ourYearFee) || 0,
|
||
customerYearKg: Number(k.customerYearKg) || 0,
|
||
monthKg: Number(k.monthKg) || 0,
|
||
monthFee,
|
||
monthRevenue,
|
||
monthProfit: monthRevenue - monthCustomerCost,
|
||
todayKg: Number(k.todayKg) || 0,
|
||
todayFee,
|
||
todayRevenue,
|
||
todayProfit: todayRevenue - todayCustomerCost,
|
||
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(MAX(s.short_name), MAX(s.name),
|
||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||
MAX(i.hydrogen_station_name),
|
||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) 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
|
||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||
WHERE b.is_deleted = 0
|
||
AND b.${HYDROGEN_LOCAL} >= ?
|
||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||
GROUP BY b.hydrogen_station_id
|
||
ORDER BY kg DESC
|
||
LIMIT 5`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
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,
|
||
}));
|
||
|
||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||
const [stationFullRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT b.hydrogen_station_id AS id,
|
||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||
MAX(i.hydrogen_station_name),
|
||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
|
||
SUM(b.hydrogen_quantity) AS kg,
|
||
SUM(b.customer_expense) AS revenue
|
||
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
|
||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||
WHERE b.is_deleted = 0
|
||
AND b.${HYDROGEN_LOCAL} >= ?
|
||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||
GROUP BY b.hydrogen_station_id
|
||
ORDER BY kg DESC`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
|
||
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
|
||
const stations = stationFullRows.map(r => ({
|
||
name: r.name as string,
|
||
kg: Number(r.kg) || 0,
|
||
revenue: Number(r.revenue) || 0,
|
||
share: (Number(r.kg) || 0) / stationKgSum,
|
||
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
|
||
}));
|
||
|
||
// 区域占比(按城市,指定年份)— 取前 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_LOCAL} >= ?
|
||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||
) r
|
||
GROUP BY region
|
||
ORDER BY kg DESC`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
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 }] : []),
|
||
];
|
||
|
||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2)
|
||
const [monthRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||
ROUND(SUM(hydrogen_quantity), 2) AS kg,
|
||
ROUND(SUM(cost_expense), 2) AS fee,
|
||
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
|
||
ROUND(SUM(customer_expense), 2) AS revenue
|
||
FROM tab_energy_hydrogen_bill
|
||
WHERE is_deleted = 0
|
||
AND ${HYDROGEN_LOCAL} >= ?
|
||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||
GROUP BY m
|
||
ORDER BY m`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
|
||
for (const r of monthRows) {
|
||
monthMap.set(r.m as string, {
|
||
kg: Number(r.kg) || 0,
|
||
fee: Number(r.fee) || 0,
|
||
revenue: Number(r.revenue) || 0,
|
||
customerCost: Number(r.customerCost) || 0,
|
||
});
|
||
}
|
||
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
|
||
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
|
||
for (let mi = 1; mi <= lastMonth; mi++) {
|
||
const key = `${year}-${String(mi).padStart(2, '0')}`;
|
||
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
|
||
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
|
||
}
|
||
|
||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||
// payer:cost_type=2 → 客户承担;cost_type=3 → 羚牛承担;其他 → 客户(默认)
|
||
const [customerRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
|
||
ELSE 'customer' END AS payer,
|
||
SUM(hydrogen_quantity) AS kg,
|
||
SUM(cost_expense) AS cost,
|
||
SUM(customer_expense) AS revenue
|
||
FROM tab_energy_hydrogen_bill
|
||
WHERE is_deleted = 0
|
||
AND ${HYDROGEN_LOCAL} >= ?
|
||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||
GROUP BY name
|
||
ORDER BY kg DESC
|
||
LIMIT 30`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
const customers = customerRows.map(r => ({
|
||
name: r.name as string,
|
||
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
|
||
kg: Number(r.kg) || 0,
|
||
cost: Number(r.cost) || 0,
|
||
revenue: Number(r.revenue) || 0,
|
||
}));
|
||
|
||
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
||
}, { force });
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
||
// =========================================================
|
||
app.get('/hydrogen/daily', async (c) => {
|
||
const range = (c.req.query('range') || 'last15') as Range;
|
||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||
const force = c.req.query('force') === '1';
|
||
|
||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||
|
||
const where = [
|
||
'b.is_deleted = 0',
|
||
`b.hydrogen_time >= '${HYDROGEN_MIN_DATE}'`,
|
||
rangeClause(`b.hydrogen_time`, range),
|
||
customerClause('b.truck_id', customer),
|
||
].join(' AND ');
|
||
|
||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||
// 站点名 fallback:内部站表 → 外部站表 → 导入订单表(tab_import_hydrogen_order,按 bill_code 关联)
|
||
// 单价不重算:同价组显示原价,混合价组返回 NULL,前端显示「—」
|
||
const [stationRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(b.hydrogen_time, '%Y-%m-%d') AS d,
|
||
b.hydrogen_station_id AS stationId,
|
||
COALESCE(MAX(s.short_name), MAX(s.name),
|
||
MAX(os.fixed_station_name), MAX(os.station_name),
|
||
MAX(i.hydrogen_station_name),
|
||
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
|
||
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS stationName,
|
||
ROUND(SUM(b.hydrogen_quantity), 2) AS kg,
|
||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||
MAX(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
|
||
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||
const allDates = enumerateDates(range);
|
||
const fullDays = allDates.map(date => {
|
||
const info = dayMap.get(date);
|
||
return {
|
||
date,
|
||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||
chainPct: dayChainPct.get(date) ?? 0,
|
||
customerType: customer === 'lingniu' ? 'lingniu' : 'external',
|
||
stations: info
|
||
? 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,
|
||
}))
|
||
: [],
|
||
};
|
||
});
|
||
|
||
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
||
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
||
let prevKg = 0;
|
||
for (const d of ascDays) {
|
||
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
||
prevKg = d.totalKg;
|
||
}
|
||
|
||
// 按日期降序返回
|
||
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||
return result;
|
||
}, { force });
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
||
// =========================================================
|
||
app.get('/electric/overview', async (c) => {
|
||
const force = c.req.query('force') === '1';
|
||
const data = await cached('electric/overview', async () => {
|
||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT
|
||
SUM(kwh) AS totalKwh,
|
||
SUM(fee) AS totalFee,
|
||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN kwh ELSE 0 END) AS monthKwh,
|
||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN fee ELSE 0 END) AS monthFee,
|
||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
|
||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
|
||
FROM bi_ele_charge_record`,
|
||
);
|
||
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(start_time, '%Y-%m-%d') AS date,
|
||
SUM(kwh) AS kwh,
|
||
SUM(fee) AS fee
|
||
FROM bi_ele_charge_record
|
||
WHERE DATE_FORMAT(start_time, '%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(start_time, '%Y-%m-%d') AS date,
|
||
SUM(kwh) AS kwh,
|
||
SUM(fee) AS fee
|
||
FROM bi_ele_charge_record
|
||
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
|
||
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
|
||
)
|
||
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;
|
||
}
|
||
|
||
let todayChainPct = 0;
|
||
if (todayKwh > 0) {
|
||
const [prevRow] = await pool.query<RowDataPacket[]>(
|
||
`SELECT SUM(kwh) AS kwh
|
||
FROM bi_ele_charge_record
|
||
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||
);
|
||
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
||
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||
}
|
||
|
||
return {
|
||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||
trend: trendArr,
|
||
};
|
||
}, { force });
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||
// 支持 range 参数(thisWeek / thisMonth / last15)
|
||
// 缺失日期补零
|
||
// =========================================================
|
||
app.get('/electric/monthly', async (c) => {
|
||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||
const range = (c.req.query('range') || 'last15') as Range;
|
||
const force = c.req.query('force') === '1';
|
||
|
||
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||
|
||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||
let kindClause = '1=1';
|
||
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
||
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
||
|
||
const [rows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||
SUM(kwh) AS kwh,
|
||
SUM(fee) AS fee
|
||
FROM bi_ele_charge_record
|
||
WHERE ${kindClause}
|
||
AND ${rangeClause('start_time', range)}
|
||
GROUP BY date`,
|
||
);
|
||
|
||
// 实际数据 map
|
||
const dataMap = new Map<string, { kwh: number; fee: number }>();
|
||
for (const r of rows) {
|
||
dataMap.set(r.date as string, {
|
||
kwh: Number(r.kwh) || 0,
|
||
fee: Number(r.fee) || 0,
|
||
});
|
||
}
|
||
|
||
// 补零:枚举 range 全部日期
|
||
const allDates = enumerateDates(range);
|
||
const fullDays = allDates.map(date => {
|
||
const d = dataMap.get(date);
|
||
return {
|
||
date,
|
||
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
|
||
fee: d ? Math.round(d.fee * 100) / 100 : 0,
|
||
};
|
||
});
|
||
|
||
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
|
||
const monthMap = new Map<string, typeof fullDays>();
|
||
for (const d of fullDays) {
|
||
const m = d.date.slice(0, 7);
|
||
if (!monthMap.has(m)) monthMap.set(m, []);
|
||
monthMap.get(m)!.push(d);
|
||
}
|
||
|
||
const months = Array.from(monthMap.entries())
|
||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||
.map(([month, days]) => {
|
||
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||
const chain = new Map<string, number>();
|
||
let prev = 0;
|
||
for (const d of asc) {
|
||
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
|
||
prev = d.kwh;
|
||
}
|
||
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
|
||
const rowsWithChain = desc.map(d => ({
|
||
date: d.date,
|
||
kwh: d.kwh,
|
||
fee: d.fee,
|
||
chainPct: chain.get(d.date) ?? 0,
|
||
}));
|
||
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
|
||
const feeSum = days.reduce((s, d) => s + d.fee, 0);
|
||
return {
|
||
month,
|
||
kwh: Math.round(kwhSum * 100) / 100,
|
||
fee: Math.round(feeSum * 100) / 100,
|
||
rows: rowsWithChain,
|
||
};
|
||
});
|
||
|
||
return months;
|
||
}, { force });
|
||
return c.json(data);
|
||
});
|
||
|
||
export default app;
|