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( `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( `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( `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( `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( `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( `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(); 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( `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( `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(); 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 === '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( `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;