refactor(stage13): 能源域先行搬迁电能与 ETC 的 SQL(该域完成一半)

改动
- 新增 energy/repository.ts:电能总览 KPI、本月逐日、最近有数据月份、昨日电量、
  按日区间查询、ETC 汇总,共 6 个查询函数(SQL 与参数逐字保留)。
- electric.ts / etc.ts 改为调用 repository;两者本就是 register*(app, deps) 形态,
  因此这次是纯粹的 SQL 位置迁移,没有改接口契约。
- 车辆归属筛选片段 electricKindClause 也移入 repository:
  它本质是 SQL,留在路由里会让"路由不含 SQL"这条规则形同虚设。

验证
- energy/routes.test.ts 的 13 个既有用例直接作为安全网(其中多处对 SQL 与参数做断言),
  迁移后全部通过,说明查询文本、参数顺序与口径未变。
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。

未完成(已在文档中写明)
- hydrogen-station-board.ts 仍有 10 条查询 + 4 个本地 SQL 片段助手;
- hydrogen-bi-v2.ts 仍有 7 条带动态插值的查询。
因此 energy 暂不加入架构守护的"已完整分层"清单,避免给出已完成的不实信号。
This commit is contained in:
dsh-agent
2026-09-11 10:46:58 +08:00
parent 5196db5df7
commit e4c8195ede
4 changed files with 135 additions and 65 deletions
+1 -1
View File
@@ -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(定位点)两个库 |
+12 -52
View File
@@ -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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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<string, { kwh: number; fee: number }>();
for (const r of rows) {
+2 -12
View File
@@ -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<typeof pool, 'query'>;
@@ -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<RowDataPacket[]>(
`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 {
+120
View File
@@ -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<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
// ---------------------------------------------------------------------------
// ETC
// ---------------------------------------------------------------------------
/** ETC 台账与账单汇总;空表返回全 0,由路由判定 hasData。 */
export async function loadEtcOverview(db: Database): Promise<Record<string, unknown>> {
const [rows] = await db.query<RowDataPacket[]>(
`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<string, unknown>;
}
// ---------------------------------------------------------------------------
// 电能
// ---------------------------------------------------------------------------
/** 电能总览 KPI(全量 + 本月 + 本日)。 */
export async function loadElectricKpi(db: Database): Promise<Record<string, unknown>> {
const [rows] = await db.query<RowDataPacket[]>(
`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<string, unknown>;
}
/** 本月逐日电量/费用(柱图)。 */
export async function loadElectricCurrentMonthTrend(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`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<number> {
const [rows] = await db.query<RowDataPacket[]>(
`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<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`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;
}