import type { Hono } from 'hono'; import type { RowDataPacket } from 'mysql2'; import type hydrogenPool from '../../hydrogen-db.js'; import type { cached } from './cache.js'; import { HYDROGEN_FUEL_ONLY_WHERE, HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE, } from './constants.js'; export interface HydrogenOverviewDependencies { hydrogenPool: Pick; cached: typeof cached; } type VehicleScope = 'all' | 'lingniu' | 'external'; type VerifyScope = 'all' | 'verified'; interface OverviewFilter { cacheKey: string; ledgerSql: string; billSql: string; params: number[]; stationId: number | null; vehicleScope: VehicleScope; verifyScope: VerifyScope; } function overviewFilter( stationIdParam: string | undefined, vehicleScopeParam: string | undefined, verifyScopeParam: string | undefined, ): OverviewFilter { const stationId = Number(stationIdParam); const safeStationId = Number.isInteger(stationId) && stationId > 0 ? stationId : null; const vehicleScope: VehicleScope = vehicleScopeParam === 'lingniu' || vehicleScopeParam === 'external' ? vehicleScopeParam : 'all'; const verifyScope: VerifyScope = verifyScopeParam === 'verified' ? 'verified' : 'all'; const clauses: string[] = []; const params: number[] = []; if (safeStationId) { clauses.push('station_id = ?'); params.push(safeStationId); } if (vehicleScope === 'lingniu') clauses.push('vehicle_id IS NOT NULL'); if (vehicleScope === 'external') clauses.push('vehicle_id IS NULL'); // Only use the ledger's persisted verification state; never infer verification // from import source or payment status. if (verifyScope === 'verified') { clauses.push("LOWER(COALESCE(NULLIF(TRIM(verify_status), ''), 'unverified')) = 'verified'"); } const suffix = clauses.length ? ` AND ${clauses.join(' AND ')}` : ''; const billSuffix = suffix .replaceAll('station_id', 'b.station_id') .replaceAll('vehicle_id', 'b.vehicle_id') .replaceAll('verify_status', 'b.verify_status'); const cacheParts = [ safeStationId ? `station=${safeStationId}` : '', vehicleScope !== 'all' ? `vehicle=${vehicleScope}` : '', verifyScope !== 'all' ? `verify=${verifyScope}` : '', ].filter(Boolean); return { cacheKey: cacheParts.length ? `&${cacheParts.join('&')}` : '', ledgerSql: suffix, billSql: billSuffix, params, stationId: safeStationId, vehicleScope, verifyScope, }; } // 氢能总览:KPI + 趋势 + 站点/区域/客户汇总。筛选只使用账本中可验证的站点和车辆归属字段。 export function registerHydrogenOverviewRoute( app: Hono, { hydrogenPool, cached }: HydrogenOverviewDependencies, ) { app.get('/hydrogen/overview', async (c) => { const yearParam = c.req.query('year'); const force = c.req.query('force') === '1'; const filter = overviewFilter(c.req.query('stationId'), c.req.query('vehicleScope'), c.req.query('verifyScope')); const today = new Date(); const todayYear = today.getFullYear(); const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear; const data = await cached(`hydrogen/overview?year=${requestedYear}${filter.cacheKey}`, async () => { // 可选年份(数据自 HYDROGEN_MIN_DATE 起) const [yearListRows] = await hydrogenPool.query( `SELECT YEAR(${HYDROGEN_LOCAL}) AS y, DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime FROM ${HYDROGEN_TABLE} WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ? GROUP BY YEAR(${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); // The watermark is calculated with the active scope below. The year list // remains global metadata so users can still select a year with no matching // verified/vehicle/station records. let latestLedgerTime: string | null = null; const isCurrentYear = year === todayYear; // KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)。 // yearProfit 是客户单毛利:仅以存在对客价/对客收入的订单成本抵扣对客收入。 const [kpiRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(MAX(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? THEN ${HYDROGEN_LOCAL} END), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime, 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_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`, [year, 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, ...filter.params], ); const k = kpiRows[0] ?? {}; latestLedgerTime = typeof k.latestLedgerTime === 'string' ? k.latestLedgerTime : null; 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_FUEL_ONLY_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql} GROUP BY b.station_id ORDER BY kg DESC LIMIT 5`, [HYDROGEN_MIN_DATE, year, ...filter.params], ); const top5KgSum = kpi.yearKg || 1; const top5 = top5Rows.map((r, i) => ({ rank: i + 1, id: Number(r.id) || 0, 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, COALESCE(MAX(d.NAME), '未归属') AS province, 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' LEFT JOIN common_district d ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND d.STATUS = 'VALID' WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql} GROUP BY b.station_id ORDER BY kg DESC`, [HYDROGEN_MIN_DATE, year, ...filter.params], ); 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 => ({ id: Number(r.id) || 0, name: r.name as string, province: r.province 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 NULLIF(TRIM(d.NAME), '') IS NOT NULL THEN d.NAME 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 '未归属区域' 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' -- 历史站点城市编码与行政区字典的排序规则不同,显式统一避免线上连接报错。 LEFT JOIN common_district d ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND d.STATUS = 'VALID' WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql} ) r GROUP BY region ORDER BY kg DESC`, [HYDROGEN_MIN_DATE, year, ...filter.params], ); 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(CASE WHEN vehicle_id IS NOT NULL THEN amount_kg ELSE 0 END), 2) AS lingniuKg, ROUND(SUM(CASE WHEN vehicle_id IS NULL THEN amount_kg ELSE 0 END), 2) AS externalKg, 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_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ? AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql} GROUP BY m ORDER BY m`, [HYDROGEN_MIN_DATE, year, ...filter.params], ); const monthMap = new Map(); for (const r of monthRows) { monthMap.set(r.m as string, { kg: Number(r.kg) || 0, lingniuKg: Number(r.lingniuKg) || 0, externalKg: Number(r.externalKg) || 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; lingniuKg: number; externalKg: 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, lingniuKg: 0, externalKg: 0, fee: 0, revenue: 0, customerCost: 0 }; monthly.push({ month: key, kg: v.kg, lingniuKg: v.lingniuKg, externalKg: v.externalKg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost }); } // 客户账单 Top(指定年份;按加氢量降序,前 30)。 // 同一客户可能同时存在客户承担与羚牛承担订单,必须显示为混合,不能按任一条记录覆盖。 const [customerRows] = await hydrogenPool.query( `SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name, CASE WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0 AND SUM(CASE WHEN COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0 THEN 1 ELSE 0 END) > 0 THEN 'mixed' WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0 THEN 'customer' ELSE 'lingniu' END AS payer, SUM(amount_kg) AS kg, SUM(cost_total) AS cost, SUM(fee_total) AS revenue FROM ${HYDROGEN_TABLE} WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ? AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql} GROUP BY name ORDER BY kg DESC LIMIT 30`, [HYDROGEN_MIN_DATE, year, ...filter.params], ); const customers = customerRows.map(r => ({ name: r.name as string, payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : (r.payer as string) === 'mixed' ? 'mixed' 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, latestLedgerTime, filter: { stationId: filter.stationId, vehicleScope: filter.vehicleScope, verifyScope: filter.verifyScope, } }; }, { force }); return c.json(data); }); }