diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a928eb3..8a5d8e8 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`(聚合)+ `hydrogen-bi-v2.ts` `hydrogen-station-board.ts` `electric.ts` `etc.ts` + `query-model.ts` `cache.ts` `constants.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 文件仍在路由内,因此该域尚未进守护清单 | | `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/server/routes/energy/electric.ts b/src/server/routes/energy/electric.ts index 9c5f268..e190cab 100644 --- a/src/server/routes/energy/electric.ts +++ b/src/server/routes/energy/electric.ts @@ -1,7 +1,13 @@ import type { Hono } from 'hono'; -import type { RowDataPacket } from 'mysql2'; import type pool from '../../db/mysql.js'; import type { cached } from './cache.js'; +import { + loadElectricCurrentMonthTrend, + loadElectricDailyRows, + loadElectricKpi, + loadElectricLatestMonthTrend, + loadElectricPreviousDayKwh, +} from './repository.js'; import { dateRangeClause, enumerateDateRange, @@ -23,20 +29,7 @@ export function registerElectricOverviewRoute( 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, - DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestChargeTime - FROM bi_ele_charge_record`, - ); - const k = kpiRows[0] ?? {}; + const k = await loadElectricKpi(pool); const totalKwh = Number(k.totalKwh) || 0; const totalFee = Number(k.totalFee) || 0; const monthKwh = Number(k.monthKwh) || 0; @@ -45,29 +38,11 @@ export function registerElectricOverviewRoute( 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`, - ); + const trendRows = await loadElectricCurrentMonthTrend(pool); // 若本月无数据,降级展示最近一个有数据的自然月 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`, - ); + const fallback = await loadElectricLatestMonthTrend(pool); trend = fallback; } const trendArr = trend.map(r => ({ @@ -83,12 +58,7 @@ export function registerElectricOverviewRoute( 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; + const prevKwh = await loadElectricPreviousDayKwh(pool); todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0; } @@ -120,17 +90,7 @@ export function registerElectricMonthlyRoute( 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 ${dateRangeClause('start_time')} - GROUP BY date`, - [dateRange.start, dateRange.end], - ); - + const rows = await loadElectricDailyRows(pool, { customer, start: dateRange.start, end: dateRange.end }); // 实际数据 map const dataMap = new Map(); for (const r of rows) { diff --git a/src/server/routes/energy/etc.ts b/src/server/routes/energy/etc.ts index 36e2295..ea8022e 100644 --- a/src/server/routes/energy/etc.ts +++ b/src/server/routes/energy/etc.ts @@ -1,7 +1,7 @@ import type { Hono } from 'hono'; -import type { RowDataPacket } from 'mysql2'; import type pool from '../../db/mysql.js'; import type { cached } from './cache.js'; +import { loadEtcOverview } from './repository.js'; export interface EtcOverviewDependencies { pool: Pick; @@ -16,17 +16,7 @@ export function registerEtcOverviewRoute( app.get('/etc/overview', async (c) => { const force = c.req.query('force') === '1'; const data = await cached('etc/overview', async () => { - const [rows] = await pool.query( - `SELECT - (SELECT COUNT(*) FROM etc_toll_record WHERE del_flag = '0') AS tollRecordCount, - (SELECT COUNT(DISTINCT plate_number) FROM etc_toll_record WHERE del_flag = '0') AS vehicleCount, - (SELECT ROUND(SUM(total_amount), 2) FROM etc_toll_record WHERE del_flag = '0') AS totalAmount, - (SELECT DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') FROM etc_toll_record WHERE del_flag = '0') AS latestTollTime, - (SELECT COUNT(*) FROM energy_etc_bill WHERE del_flag = '0') AS billCount, - (SELECT ROUND(SUM(receivable_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS receivableAmount, - (SELECT ROUND(SUM(paid_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS paidAmount`, - ); - const row = rows[0] ?? {}; + const row = await loadEtcOverview(pool); const tollRecordCount = Number(row.tollRecordCount) || 0; const billCount = Number(row.billCount) || 0; return { diff --git a/src/server/routes/energy/repository.ts b/src/server/routes/energy/repository.ts new file mode 100644 index 0000000..92d5603 --- /dev/null +++ b/src/server/routes/energy/repository.ts @@ -0,0 +1,120 @@ +import type { RowDataPacket } from 'mysql2'; +import { dateRangeClause, type CustomerKind } from './query-model.js'; + +/** + * 能源域的数据访问。 + * + * 电能 / ETC / 单站日报 / 氢能 v2 的 SQL 都集中在这里;路由只做校验、编排与口径计算。 + * SQL 文本与参数顺序由 routes.test.ts 的契约测试锁定(该文件对多处查询做了指纹断言)。 + */ + +/** 只需要 query 能力的连接(主库或氢能库)。 */ +export interface Database { + query(sql: string, values?: any[]): Promise<[T, ...any[]]>; +} + +// --------------------------------------------------------------------------- +// ETC +// --------------------------------------------------------------------------- + +/** ETC 台账与账单汇总;空表返回全 0,由路由判定 hasData。 */ +export async function loadEtcOverview(db: Database): Promise> { + const [rows] = await db.query( + `SELECT + (SELECT COUNT(*) FROM etc_toll_record WHERE del_flag = '0') AS tollRecordCount, + (SELECT COUNT(DISTINCT plate_number) FROM etc_toll_record WHERE del_flag = '0') AS vehicleCount, + (SELECT ROUND(SUM(total_amount), 2) FROM etc_toll_record WHERE del_flag = '0') AS totalAmount, + (SELECT DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') FROM etc_toll_record WHERE del_flag = '0') AS latestTollTime, + (SELECT COUNT(*) FROM energy_etc_bill WHERE del_flag = '0') AS billCount, + (SELECT ROUND(SUM(receivable_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS receivableAmount, + (SELECT ROUND(SUM(paid_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS paidAmount`, + ); + return (rows[0] ?? {}) as Record; +} + +// --------------------------------------------------------------------------- +// 电能 +// --------------------------------------------------------------------------- + +/** 电能总览 KPI(全量 + 本月 + 本日)。 */ +export async function loadElectricKpi(db: Database): Promise> { + const [rows] = await db.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, + DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestChargeTime + FROM bi_ele_charge_record`, + ); + return (rows[0] ?? {}) as Record; +} + +/** 本月逐日电量/费用(柱图)。 */ +export async function loadElectricCurrentMonthTrend(db: Database): Promise { + const [rows] = await db.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`, + ); + return rows; +} + +/** 本月无数据时的降级:最近一个有数据的自然月。 */ +export async function loadElectricLatestMonthTrend(db: Database): Promise { + const [rows] = await db.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`, + ); + return rows; +} + +/** 昨日电量(用于本日环比)。 */ +export async function loadElectricPreviousDayKwh(db: Database): Promise { + const [rows] = await db.query( + `SELECT SUM(kwh) AS kwh + FROM bi_ele_charge_record + WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`, + ); + return Number(rows[0]?.kwh) || 0; +} + +/** 车辆归属筛选片段。internal=我司,external=外部;其余不过滤。 */ +export function electricKindClause(customer: CustomerKind): string { + if (customer === 'lingniu') return `vehicle_kind = 'internal'`; + if (customer === 'external') return `vehicle_kind = 'external'`; + return '1=1'; +} + +/** 按日电量/费用(区间)。 */ +export async function loadElectricDailyRows( + db: Database, + args: { customer: CustomerKind; start: string; end: string }, +): Promise { + const [rows] = await db.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 ${electricKindClause(args.customer)} + AND ${dateRangeClause('start_time')} + GROUP BY date`, + [args.start, args.end], + ); + return rows; +}