import type { Hono } from 'hono'; import type { RowDataPacket } from 'mysql2'; import type hydrogenPool from '../../db/hydrogen.js'; import type { cached } from './cache.js'; import { HYDROGEN_BASE_WHERE, HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js'; import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js'; export interface HydrogenStationBoardDependencies { hydrogenPool: Pick; cached: typeof cached; } function safeStationId(value: string | undefined): number | null { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } function numberValue(value: unknown): number { return Number(value) || 0; } // 单站接口没有租户入参,租户只能由服务端配置决定,不能接受浏览器传值。 const HYDROGEN_TENANT_ID = process.env.HYDROGEN_TENANT_ID?.trim() || '000000'; const MANUAL_RECHARGE_SOURCE = 'external_recharge_manual'; const NORMALIZED_PLATE = (column: string) => `UPPER(REPLACE(REPLACE(REPLACE(TRIM(${column}), ' ', ''), '.', ''), '·', ''))`; const NORMALIZED_PLATE_UNICODE = (column: string) => `CONVERT(${NORMALIZED_PLATE(column)} USING utf8mb4) COLLATE utf8mb4_unicode_ci`; const EXTERNAL_CUSTOMER_MAP_CTE = ` WITH external_vehicle_candidates AS ( SELECT ${NORMALIZED_PLATE_UNICODE('plate_number')} AS normalizedPlate, MAX(NULLIF(TRIM(actual_user), '')) AS customerName, COUNT(DISTINCT NULLIF(TRIM(actual_user), '')) AS customerCount FROM hydrogen_order_transfer_external_vehicle_user WHERE NULLIF(TRIM(plate_number), '') IS NOT NULL GROUP BY ${NORMALIZED_PLATE_UNICODE('plate_number')} ), external_vehicle_map AS ( SELECT normalizedPlate, customerName FROM external_vehicle_candidates WHERE customerCount = 1 AND customerName IS NOT NULL )`; // 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。 export function registerHydrogenStationBoardRoute( app: Hono, { hydrogenPool, cached }: HydrogenStationBoardDependencies, ) { app.get('/hydrogen/station-board', async (c) => { const range = resolveDateRange( 'last15', c.req.query('startDate'), c.req.query('endDate'), ); const stationId = safeStationId(c.req.query('stationId')); const force = c.req.query('force') === '1'; const cacheKey = `hydrogen/station-board?start=${range.start}&end=${range.end}${stationId ? `&station=${stationId}` : ''}`; const data = await cached(cacheKey, async () => { const [stationRows] = await hydrogenPool.query( `SELECT s.id, COALESCE(NULLIF(s.station_short_name, ''), NULLIF(s.station_name, ''), CONCAT('未知站点 #', s.id)) AS name, COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province, COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city, ROUND(COALESCE(SUM(b.amount_kg), 0), 2) AS kg, ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS fee, COUNT(b.id) AS recordCount, DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime FROM hydrogen_station s LEFT JOIN ${HYDROGEN_TABLE} b ON b.station_id = s.id AND ${HYDROGEN_BASE_WHERE_B} AND b.tenant_id = ? AND COALESCE(b.record_source, '') <> ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} LEFT JOIN common_district dp ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND dp.STATUS = 'VALID' LEFT JOIN common_district dc ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND dc.STATUS = 'VALID' WHERE s.del_flag = '0' GROUP BY s.id, s.station_short_name, s.station_name, dp.NAME, s.province, dc.NAME, s.city ORDER BY kg DESC, name ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end], ); const [paymentRows] = await hydrogenPool.query( `SELECT station_id AS stationId, ROUND(SUM(amount), 2) AS amount, COUNT(*) AS paymentCount, DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate FROM hydrogen_station_payment WHERE del_flag = '0' AND payment_date >= ? AND payment_date <= ? GROUP BY station_id`, [range.start, range.end], ); const [summaryDailyRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date, ROUND(SUM(b.amount_kg), 2) AS kg, ROUND(SUM(b.cost_total), 2) AS fee, COUNT(*) AS recordCount FROM ${HYDROGEN_TABLE} b WHERE ${HYDROGEN_BASE_WHERE_B} AND b.tenant_id = ? AND COALESCE(b.record_source, '') <> ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') ORDER BY date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end], ); const [stationDailyRows] = await hydrogenPool.query( `SELECT b.station_id AS stationId, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date, ROUND(SUM(b.amount_kg), 2) AS kg FROM ${HYDROGEN_TABLE} b WHERE ${HYDROGEN_BASE_WHERE_B} AND b.tenant_id = ? AND COALESCE(b.record_source, '') <> ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') ORDER BY stationId ASC, date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end], ); const [summaryPaymentDailyRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date, ROUND(SUM(amount), 2) AS amount, COUNT(*) AS paymentCount FROM hydrogen_station_payment WHERE del_flag = '0' AND payment_date >= ? AND payment_date <= ? GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d') ORDER BY date ASC`, [range.start, range.end], ); const paymentByStation = new Map(paymentRows.map(row => [numberValue(row.stationId), row])); const dailyKgByStation = new Map>(); for (const row of stationDailyRows) { const id = numberValue(row.stationId); const values = dailyKgByStation.get(id) ?? new Map(); values.set(String(row.date), numberValue(row.kg)); dailyKgByStation.set(id, values); } const totalKg = stationRows.reduce((sum, row) => sum + numberValue(row.kg), 0); const stations = stationRows.map(row => { const id = numberValue(row.id); const payment = paymentByStation.get(id); return { id, name: String(row.name), province: String(row.province), city: String(row.city), kg: numberValue(row.kg), fee: numberValue(row.fee), recordCount: numberValue(row.recordCount), paymentAmount: numberValue(payment?.amount), paymentCount: numberValue(payment?.paymentCount), share: totalKg > 0 ? numberValue(row.kg) / totalKg : 0, latestLedgerTime: typeof row.latestLedgerTime === 'string' ? row.latestLedgerTime : null, latestPaymentDate: typeof payment?.latestPaymentDate === 'string' ? payment.latestPaymentDate : null, dailyKg: enumerateDateRange(range.start, range.end).map(date => ({ date, kg: dailyKgByStation.get(id)?.get(date) ?? 0, })), }; }).filter(station => station.recordCount > 0 || station.name.includes('东鹏大道') || (station.name.includes('佛山南海') && station.name.includes('羚牛'))); let selected = null; if (stationId) { const [dailyRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date, ROUND(SUM(b.amount_kg), 2) AS kg, ROUND(SUM(b.cost_total), 2) AS fee, ROUND(AVG(NULLIF(b.cost_price, 0)), 2) AS avgPrice, COUNT(*) AS recordCount FROM ${HYDROGEN_TABLE} b WHERE ${HYDROGEN_BASE_WHERE_B} AND b.tenant_id = ? AND COALESCE(b.record_source, '') <> ? AND b.station_id = ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') ORDER BY date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.start, range.end], ); const [dailyPaymentRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date, ROUND(SUM(amount), 2) AS amount, COUNT(*) AS paymentCount FROM hydrogen_station_payment WHERE del_flag = '0' AND station_id = ? AND payment_date >= ? AND payment_date <= ? GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d') ORDER BY date ASC`, [stationId, range.start, range.end], ); const paymentByDate = new Map(dailyPaymentRows.map(row => [String(row.date), row])); const ledgerByDate = new Map(dailyRows.map(row => [String(row.date), row])); let previousKg = 0; const daily = enumerateDateRange(range.start, range.end).map(date => { const ledger = ledgerByDate.get(date); const kg = numberValue(ledger?.kg); const payment = paymentByDate.get(date); const result = { date, kg, fee: numberValue(ledger?.fee), avgPrice: numberValue(ledger?.avgPrice), recordCount: numberValue(ledger?.recordCount), changeKg: kg - previousKg, paymentAmount: numberValue(payment?.amount), paymentCount: numberValue(payment?.paymentCount), }; previousKg = kg; return result; }); const [customerMonthRows] = await hydrogenPool.query( `${EXTERNAL_CUSTOMER_MAP_CTE} SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month, COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户') AS customerName, ROUND(SUM(b.amount_kg), 2) AS kg, ROUND(SUM(b.cost_total), 2) AS fee, COUNT(*) AS recordCount FROM ${HYDROGEN_TABLE} b LEFT JOIN external_vehicle_map m ON CONVERT(${NORMALIZED_PLATE('b.license_plate')} USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(m.normalizedPlate USING utf8mb4) COLLATE utf8mb4_unicode_ci AND (b.vehicle_id IS NULL OR b.vehicle_id = 0) WHERE ${HYDROGEN_BASE_WHERE_B} AND b.tenant_id = ? AND COALESCE(b.record_source, '') <> ? AND b.station_id = ? AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH) AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY) GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户') ORDER BY month ASC, kg DESC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end], ); const [externalCustomerMonthRows] = await hydrogenPool.query( `${EXTERNAL_CUSTOMER_MAP_CTE} SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month, m.customerName, ROUND(SUM(b.amount_kg), 2) AS kg, ROUND(SUM(COALESCE(b.fee_total, b.cost_total)), 2) AS fee, COUNT(*) AS recordCount FROM ${HYDROGEN_TABLE} b INNER JOIN external_vehicle_map m ON ${NORMALIZED_PLATE_UNICODE('b.license_plate')} = m.normalizedPlate WHERE ${HYDROGEN_BASE_WHERE_B} AND b.tenant_id = ? AND COALESCE(b.record_source, '') <> ? AND b.station_id = ? AND (b.vehicle_id IS NULL OR b.vehicle_id = 0) AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH) AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY) GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), m.customerName ORDER BY month ASC, kg DESC, m.customerName ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end], ); const [externalReceiptRows] = await hydrogenPool.query( `SELECT * FROM ( SELECT CONCAT('auto:', CAST(id AS CHAR)) AS id, DATE_FORMAT(summary_date, '%Y-%m-%d') AS date, customer_name AS customerName, amount, pay_method AS payMethod, source, spot_record_count AS sourceRecordCount, DATE_FORMAT(COALESCE(refresh_time, update_time), '%Y-%m-%d %H:%i:%s') AS updatedAt FROM hydrogen_external_recharge_daily_summary WHERE tenant_id = ? AND summary_date >= ? AND summary_date <= ? UNION ALL SELECT CONCAT('manual:', CAST(id AS CHAR)) AS id, DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date, COALESCE(NULLIF(customer_name, ''), '未关联客户') AS customerName, fee_total AS amount, recharge_pay_method AS payMethod, record_source AS source, 1 AS sourceRecordCount, DATE_FORMAT(update_time, '%Y-%m-%d %H:%i:%s') AS updatedAt FROM ${HYDROGEN_TABLE} WHERE ${HYDROGEN_BASE_WHERE} AND tenant_id = ? AND record_source = ? AND ${HYDROGEN_LOCAL} >= ? AND ${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY) ) externalReceipts ORDER BY date DESC, customerName ASC, id ASC`, [ HYDROGEN_TENANT_ID, range.start, range.end, HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end, ], ); selected = { daily, customerMonths: customerMonthRows.map(row => ({ month: String(row.month), customerName: String(row.customerName), kg: numberValue(row.kg), fee: numberValue(row.fee), recordCount: numberValue(row.recordCount), })), externalCustomerMonths: externalCustomerMonthRows.map(row => ({ month: String(row.month), customerName: String(row.customerName), kg: numberValue(row.kg), fee: numberValue(row.fee), recordCount: numberValue(row.recordCount), })), // 快照和手工充值都没有可靠站点归属,不能把客户级进账误报为当前站点收益。 externalReceipts: { scope: 'customer', reason: '当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。', rows: externalReceiptRows.map(row => ({ id: String(row.id), date: String(row.date), customerName: String(row.customerName), amount: numberValue(row.amount), payMethod: String(row.payMethod), source: String(row.source), sourceRecordCount: numberValue(row.sourceRecordCount), updatedAt: typeof row.updatedAt === 'string' ? row.updatedAt : null, })), }, }; } const paymentTotal = stations.reduce((sum, station) => sum + station.paymentAmount, 0); const summaryLedgerByDate = new Map(summaryDailyRows.map(row => [String(row.date), row])); const summaryPaymentByDate = new Map(summaryPaymentDailyRows.map(row => [String(row.date), row])); const summaryDaily = enumerateDateRange(range.start, range.end).map(date => { const ledger = summaryLedgerByDate.get(date); const payment = summaryPaymentByDate.get(date); return { date, kg: numberValue(ledger?.kg), fee: numberValue(ledger?.fee), recordCount: numberValue(ledger?.recordCount), paymentAmount: numberValue(payment?.amount), paymentCount: numberValue(payment?.paymentCount), }; }); const latestLedgerTime = stations .map(station => station.latestLedgerTime) .filter((value): value is string => Boolean(value)) .sort() .at(-1) ?? null; return { range, summary: { stationCount: stations.length, activeStationCount: stations.filter(station => station.kg > 0).length, totalKg, totalFee: stations.reduce((sum, station) => sum + station.fee, 0), recordCount: stations.reduce((sum, station) => sum + station.recordCount, 0), paymentAmount: paymentTotal, paymentCount: stations.reduce((sum, station) => sum + station.paymentCount, 0), latestLedgerTime, daily: summaryDaily, }, stations, selected, }; }, { force }); return c.json(data); }); }