diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a5d8e8..2577ab2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,7 @@ server/ | `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 | | `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 | | `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | 路由 / 模型 / 服务已分开,**SQL 仍在各路由文件内** | -| `energy/` | `index.ts`(聚合)+ `electric.ts` `etc.ts` `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` + `repository.ts`(电能/ETC 已迁入)+ `query-model.ts` `cache.ts` `constants.ts` | 🔶 **部分分层**:`electric` / `etc` 的 SQL 已在 repository,两个 hydrogen 文件仍在路由内,因此该域尚未进守护清单 | +| `energy/` | `index.ts`(聚合)+ `electric.ts` `etc.ts` `hydrogen-station-board.ts` + `repository.ts` + `hydrogen-bi-v2.ts`(仍有 SQL)+ `query-model.ts` `cache.ts` `constants.ts` | 🔶 **部分分层**:前三个路由文件的 SQL 已迁入 repository,SQL 片段进了 `constants.ts`;`hydrogen-bi-v2.ts` 仍有 7 条带动态插值的查询,因此该域尚未进守护清单 | | `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` `repository.ts` + `algorithm.ts` `notification-model.ts` | ✅ 完整分层;跨域数据源(里程车辆信息 / OneOS)显式注入 | | `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 | | `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 | diff --git a/src/architecture.test.ts b/src/architecture.test.ts index 99433ee..8a1d33c 100644 --- a/src/architecture.test.ts +++ b/src/architecture.test.ts @@ -192,7 +192,9 @@ test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在 } for (const file of readdirSync(abs)) { if (!file.endsWith(".ts")) continue; - if (file === "repository.ts" || file.endsWith(".test.ts")) continue; + // repository.ts 是 SQL 的归属地;constants.ts 允许存放被多条查询共享的 + // SQL 片段(如公共 WHERE / CTE),否则这些片段会被迫复制到每个查询里。 + if (file === "repository.ts" || file === "constants.ts" || file.endsWith(".test.ts")) continue; if (SQL.test(stripComments(readFileSync(path.join(abs, file), "utf8")))) { offenders.push(`${dir}/${file} 仍含 SQL`); } diff --git a/src/server/routes/energy/constants.ts b/src/server/routes/energy/constants.ts index cfc2310..2feb27b 100644 --- a/src/server/routes/energy/constants.ts +++ b/src/server/routes/energy/constants.ts @@ -10,3 +10,30 @@ export const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`; export const HYDROGEN_FUEL_ONLY_WHERE = `${HYDROGEN_BASE_WHERE} AND COALESCE(record_source, '') <> 'external_recharge_manual'`; export const HYDROGEN_FUEL_ONLY_WHERE_B = `${HYDROGEN_BASE_WHERE_B} AND COALESCE(b.record_source, '') <> 'external_recharge_manual'`; export const ELECTRIC_LOCAL = `charging_start_time`; + +// --------------------------------------------------------------------------- +// 单站看板的 SQL 片段与租户常量。 +// 这些是 SQL 的一部分,放在 constants.ts 而不是路由里,避免 SQL 拼装散落两处。 +// --------------------------------------------------------------------------- + +/** 租户只由服务端配置决定,不接受浏览器传值。 */ +export const HYDROGEN_TENANT_ID = process.env.HYDROGEN_TENANT_ID?.trim() || '000000'; +export const MANUAL_RECHARGE_SOURCE = 'external_recharge_manual'; +export const NORMALIZED_PLATE = (column: string) => + `UPPER(REPLACE(REPLACE(REPLACE(TRIM(${column}), ' ', ''), '.', ''), '·', ''))`; +export const NORMALIZED_PLATE_UNICODE = (column: string) => + `CONVERT(${NORMALIZED_PLATE(column)} USING utf8mb4) COLLATE utf8mb4_unicode_ci`; +export 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 + )`; diff --git a/src/server/routes/energy/hydrogen-station-board.ts b/src/server/routes/energy/hydrogen-station-board.ts index aa73f0d..a5ad101 100644 --- a/src/server/routes/energy/hydrogen-station-board.ts +++ b/src/server/routes/energy/hydrogen-station-board.ts @@ -2,7 +2,18 @@ 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 { + loadCustomerMonth, + loadExternalCustomerMonth, + loadExternalReceipts, + loadRangePayments, + loadStationDaily, + loadStationDailyForStation, + loadStationList, + loadStationPaymentDaily, + loadSummaryDaily, + loadSummaryPaymentDaily, +} from './repository.js'; import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js'; export interface HydrogenStationBoardDependencies { @@ -20,28 +31,8 @@ function numberValue(value: unknown): number { } // 单站接口没有租户入参,租户只能由服务端配置决定,不能接受浏览器传值。 -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( @@ -59,87 +50,12 @@ export function registerHydrogenStationBoardRoute( 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 stationRows = await loadStationList(hydrogenPool, { start: range.start, end: 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 paymentRows = await loadRangePayments(hydrogenPool, { start: range.start, end: range.end }); + const summaryDailyRows = await loadSummaryDaily(hydrogenPool, { start: range.start, end: range.end }); + const stationDailyRows = await loadStationDaily(hydrogenPool, { start: range.start, end: range.end }); + const summaryPaymentDailyRows = await loadSummaryPaymentDaily(hydrogenPool, { start: range.start, end: range.end }); const paymentByStation = new Map(paymentRows.map(row => [numberValue(row.stationId), row])); const dailyKgByStation = new Map>(); for (const row of stationDailyRows) { @@ -176,35 +92,8 @@ export function registerHydrogenStationBoardRoute( 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 dailyRows = await loadStationDailyForStation(hydrogenPool, { stationId: stationId as number, start: range.start, end: range.end }); + const dailyPaymentRows = await loadStationPaymentDaily(hydrogenPool, { stationId: stationId as number, start: range.start, end: 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; @@ -226,92 +115,9 @@ export function registerHydrogenStationBoardRoute( 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, - ], - ); + const customerMonthRows = await loadCustomerMonth(hydrogenPool, { stationId: stationId as number, end: range.end }); + const externalCustomerMonthRows = await loadExternalCustomerMonth(hydrogenPool, { stationId: stationId as number, end: range.end }); + const externalReceiptRows = await loadExternalReceipts(hydrogenPool, { start: range.start, end: range.end }); selected = { daily, customerMonths: customerMonthRows.map(row => ({ diff --git a/src/server/routes/energy/repository.ts b/src/server/routes/energy/repository.ts index 92d5603..e4bfab5 100644 --- a/src/server/routes/energy/repository.ts +++ b/src/server/routes/energy/repository.ts @@ -1,5 +1,16 @@ import type { RowDataPacket } from 'mysql2'; import { dateRangeClause, type CustomerKind } from './query-model.js'; +import { + EXTERNAL_CUSTOMER_MAP_CTE, + HYDROGEN_BASE_WHERE, + HYDROGEN_BASE_WHERE_B, + HYDROGEN_LOCAL, + HYDROGEN_TABLE, + HYDROGEN_TENANT_ID, + MANUAL_RECHARGE_SOURCE, + NORMALIZED_PLATE, + NORMALIZED_PLATE_UNICODE, +} from './constants.js'; /** * 能源域的数据访问。 @@ -118,3 +129,234 @@ export async function loadElectricDailyRows( ); return rows; } + +// --------------------------------------------------------------------------- +// 氢能单站日报(/hydrogen/station-board) +// --------------------------------------------------------------------------- + + +/** 区间内有有效加氢记录的站点列表。 */ +export async function loadStationList(db: Database, args: { start: string; end: string }): Promise { + const { start, end } = args; + const [rows] = await db.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, start, end]); + return rows; +} + + +/** 区间内现结流水(按站点汇总)。 */ +export async function loadRangePayments(db: Database, args: { start: string; end: string }): Promise { + const { start, end } = args; + const [rows] = await db.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`, [start, end]); + return rows; +} + + +/** 全部站点逐日汇总。 */ +export async function loadSummaryDaily(db: Database, args: { start: string; end: string }): Promise { + const { start, end } = args; + const [rows] = await db.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, start, end]); + return rows; +} + + +/** 站点逐日汇总。 */ +export async function loadStationDaily(db: Database, args: { start: string; end: string }): Promise { + const { start, end } = args; + const [rows] = await db.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, start, end]); + return rows; +} + + +/** 全部站点逐日现结。 */ +export async function loadSummaryPaymentDaily(db: Database, args: { start: string; end: string }): Promise { + const { start, end } = args; + const [rows] = await db.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`, [start, end]); + return rows; +} + + +/** 指定站点逐日加氢。 */ +export async function loadStationDailyForStation(db: Database, args: { stationId: number; start: string; end: string }): Promise { + const { stationId, start, end } = args; + const [rows] = await db.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, start, end]); + return rows; +} + + +/** 指定站点逐日现结。 */ +export async function loadStationPaymentDaily(db: Database, args: { stationId: number; start: string; end: string }): Promise { + const { stationId, start, end } = args; + const [rows] = await db.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, start, end]); + return rows; +} + + +/** 指定站点的客户月度汇总。 */ +export async function loadCustomerMonth(db: Database, args: { stationId: number; end: string }): Promise { + const { stationId, end } = args; + const [rows] = await db.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, end, end]); + return rows; +} + + +/** 指定站点的外部客户月度汇总(含映射 CTE)。 */ +export async function loadExternalCustomerMonth(db: Database, args: { stationId: number; end: string }): Promise { + const { stationId, end } = args; + const [rows] = await db.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, end, end]); + return rows; +} + + +/** 外部客户进账(自动 + 手工充值合并)。 */ +export async function loadExternalReceipts(db: Database, args: { start: string; end: string }): Promise { + const { start, end } = args; + const [rows] = await db.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, start, end, HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]); + return rows; +}