import { Hono } from 'hono'; import type { RowDataPacket } from 'mysql2'; import pool from '../../db.js'; import hydrogenPool from '../../hydrogen-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_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时) const HYDROGEN_TABLE = 'hydrogen_fuel_ledger'; const HYDROGEN_LOCAL = `refuel_time`; const HYDROGEN_BASE_WHERE = `del_flag = '0'`; const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`; const ELECTRIC_LOCAL = `charging_start_time`; type CustomerKind = 'external' | 'lingniu' | 'all'; // 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。 function customerClause(customer: CustomerKind): string { if (customer === 'external') return '1=0'; if (customer === 'lingniu') return '1=1'; 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 hydrogenPool.query( `SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y FROM ${HYDROGEN_TABLE} WHERE ${HYDROGEN_BASE_WHERE} 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 hydrogenPool.query( `SELECT SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? THEN amount_kg ELSE 0 END) AS yearKg, SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? THEN cost_total ELSE 0 END) AS yearFee, SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0) THEN cost_total ELSE 0 END) AS yearCustomerCost, SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? THEN fee_total ELSE 0 END) AS yearRevenue, SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0 THEN amount_kg ELSE 0 END) AS ourYearKg, SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0 THEN cost_total ELSE 0 END) AS ourYearFee, SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0) THEN amount_kg ELSE 0 END) AS customerYearKg, SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') THEN amount_kg ELSE 0 END) AS monthKg, SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') THEN cost_total ELSE 0 END) AS monthFee, SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0) THEN cost_total ELSE 0 END) AS monthCustomerCost, SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') THEN fee_total ELSE 0 END) AS monthRevenue, SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() THEN amount_kg ELSE 0 END) AS todayKg, SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() THEN cost_total ELSE 0 END) AS todayFee, SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0) THEN cost_total ELSE 0 END) AS todayCustomerCost, SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() THEN fee_total ELSE 0 END) AS todayRevenue, SUM(CASE WHEN vehicle_id IS NOT NULL THEN amount_kg ELSE 0 END) AS lingniuBornKg, SUM(CASE WHEN vehicle_id IS NOT NULL THEN cost_total ELSE 0 END) AS lingniuBornFee FROM ${HYDROGEN_TABLE} WHERE ${HYDROGEN_BASE_WHERE} 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 hydrogenPool.query( `SELECT b.station_id AS id, COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name), CASE WHEN b.station_id IS NULL THEN '未关联站点' ELSE CONCAT('未知站点 #', b.station_id) END) AS name, SUM(b.amount_kg) AS kg, SUM(b.cost_total) AS fee FROM ${HYDROGEN_TABLE} b LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${HYDROGEN_BASE_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ? GROUP BY b.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 hydrogenPool.query( `SELECT b.station_id AS id, COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name), CASE WHEN b.station_id IS NULL THEN '未关联站点' ELSE CONCAT('未知站点 #', b.station_id) END) AS name, SUM(b.amount_kg) AS kg, SUM(b.fee_total) AS revenue FROM ${HYDROGEN_TABLE} b LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${HYDROGEN_BASE_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ? GROUP BY b.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 hydrogenPool.query( `SELECT region, SUM(kg) AS kg FROM ( SELECT CASE WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴' WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州' WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山' WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都' WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆' WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐' WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山' ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知') END AS region, b.amount_kg AS kg FROM ${HYDROGEN_TABLE} b LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${HYDROGEN_BASE_WHERE_B} 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)含成本/收入/利润 // 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担) const [monthRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m, ROUND(SUM(amount_kg), 2) AS kg, ROUND(SUM(cost_total), 2) AS fee, ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost, ROUND(SUM(fee_total), 2) AS revenue FROM ${HYDROGEN_TABLE} WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ? AND YEAR(${HYDROGEN_LOCAL}) = ? GROUP BY m ORDER BY m`, [HYDROGEN_MIN_DATE, year], ); const monthMap = new Map(); 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:有客户单价/收入 → 客户承担;否则 → 羚牛承担 const [customerRows] = await hydrogenPool.query( `SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name, CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu' ELSE 'customer' END AS payer, SUM(amount_kg) AS kg, SUM(cost_total) AS cost, SUM(fee_total) AS revenue FROM ${HYDROGEN_TABLE} WHERE ${HYDROGEN_BASE_WHERE} 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 = [ HYDROGEN_BASE_WHERE_B, `b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`, rangeClause(`b.${HYDROGEN_LOCAL}`, range), customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'), ].join(' AND '); // 站点级聚合(每日 × 每站)。前端组装成 day → stations // 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点 // 单价不重算:直接取账本成本价。 const [stationRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d, COALESCE(b.station_id, 0) AS stationId, COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name), CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点' ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName, ROUND(SUM(b.amount_kg), 2) AS kg, -- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单 MAX(b.cost_price) AS pricePerKg FROM ${HYDROGEN_TABLE} b LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0' WHERE ${where} GROUP BY d, COALESCE(b.station_id, 0) 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; } } // 补零:列出 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, 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( `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( `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( `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( `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( `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(); 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(); 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(); 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;