refactor(stage10): ele / feedback 拆出 repository + model,并补契约测试

背景
- 这两个域把 SQL 与处理器放在同一文件,是全仓仅有的两个零接口测试的后端域。
  直接拆会改动 SQL 与参数顺序,而硬约束要求不得变更统计口径 —— 因此按
  "可注入化 -> 补契约测试 -> 再提取" 的顺序做。

改动
- ele:拆为 routes.ts(校验与组装)/ repository.ts(全部 SQL)/ model.ts(xlsx 解析、
  取值清洗、筛选片段、插入值组装等纯逻辑),并改为 registerEleRoutes(app, deps) 可注入。
- feedback:拆为 routes.ts / repository.ts,同样可注入;建表与截图上传也作为依赖注入。
- 两域各补测试:ele 7 个接口契约 + 6 个模型用例,feedback 9 个接口契约。
  mock pool 逐条断言 SQL 文本与参数顺序(含分页、状态白名单、批量插入的 30 列顺序)。
- 架构测试新增一条:已完整分层的三个域(vehicles / ele / feedback)必须有 repository.ts
  且 routes.ts 不得出现 SQL。

等价性验证(关键)
- 把改造前的实现从 git 取出,与改造后的实现跑同一批请求(含真实 xlsx 解析路径),
  对比每一步落库 SQL、参数、HTTP 状态与响应体:
    feedback:6 个场景,SQL + 参数 + 状态完全一致(差异仅 DDL 已移交 db/schema 层)
    ele     :5 个场景,SQL + 参数 + 状态 + 响应体完全一致(仅随机 batchId/时间戳做掩码)
- 期间的修正:曾把 /mine 与 /list 的列集合统一,二者实际不同(管理列表多 user_id/user_name),
  已按原样保留;测试同时锁定了这一差异。

lint / test(161) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
dsh-agent
2026-09-11 10:36:05 +08:00
parent 795fb207cb
commit c199f031c5
10 changed files with 1252 additions and 482 deletions
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildListFilter,
countVehicleKinds,
dedupeByOrderNo,
findHeaderRow,
normalizePlate,
safeDt,
safeNum,
safeStr,
toInsertValues,
type ParsedRow,
} from "./model.js";
function row(orderNo: string, plate: string | null): ParsedRow {
return {
orderNo,
raw: { 订单编号: orderNo },
values: {
stationNo: null, stationName: null, terminalName: null,
region: null, city: null, district: null,
operatingCompany: null, stationType: null,
orderStatus: null, chargeForm: null,
startTime: null, endTime: null,
duration: null, kwh: null,
eFee: null, serviceFee: null, fee: null,
plate, judgedPlate: null, vin: null,
customerName: null, customerPhone: null, enterpriseName: null,
},
};
}
test("取值清洗:字符串截断、数字与日期的非法值一律为空", () => {
assert.equal(safeStr(" x "), "x");
assert.equal(safeStr("abcdef", 3), "abc");
assert.equal(safeStr(" "), null);
assert.equal(safeNum("12.5"), 12.5);
assert.equal(safeNum("abc"), null);
assert.equal(safeNum(""), null);
// 日期:仅接受 YYYY-MM-DD[ HH:MM[:SS]]10/16 位自动补全
assert.equal(safeDt("2026-04-29"), "2026-04-29 00:00:00");
assert.equal(safeDt("2026-04-29 16:24"), "2026-04-29 16:24:00");
assert.equal(safeDt("2026-04-29 16:24:05"), "2026-04-29 16:24:05");
assert.equal(safeDt("29/04/2026"), null);
});
test("车牌归一化:去空白并大写,空值返回 null", () => {
assert.equal(normalizePlate(" 粤a 12345 "), "粤A12345");
assert.equal(normalizePlate(""), null);
assert.equal(normalizePlate(null), null);
});
test("表头定位:必须同时包含订单编号与车牌号", () => {
const rows = [
["报表说明", null],
["订单编号", "车牌号", "充电电量(度)"],
["A1", "粤A1", "10"],
];
assert.deepEqual(findHeaderRow(rows), {
headerIdx: 1,
header: ["订单编号", "车牌号", "充电电量(度)"],
});
assert.equal(findHeaderRow([["订单编号"]]), null);
});
test("文件内按订单号去重保留最后一条", () => {
const parsed = [row("A", "粤A"), row("B", "粤B"), row("A", "粤C")];
const { records, fileDuplicates } = dedupeByOrderNo(parsed);
assert.equal(fileDuplicates, 1);
assert.equal(records.length, 2);
assert.equal(records.find(r => r.orderNo === "A")?.values.plate, "粤C");
});
test("列表筛选:片段与参数顺序固定,非法 kind 被丢弃", () => {
assert.deepEqual(buildListFilter({}), { where: ["1=1"], params: [] });
assert.deepEqual(buildListFilter({ kind: "bogus" }), { where: ["1=1"], params: [] });
assert.deepEqual(
buildListFilter({ kind: "internal", batchId: "b1", search: "粤A" }),
{
where: ["1=1", "vehicle_kind = ?", "batch_id = ?", "(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)"],
params: ["internal", "b1", "%粤A%", "%粤A%", "%粤A%"],
},
);
});
test("插入值:命中系统车辆记 internal,其余记 external", () => {
const records = [row("A", "粤A1"), row("B", "粤B2"), row("C", null)];
const plateMap = new Map([["粤A1", "9"]]);
const values = toInsertValues(records, plateMap, "batch-1", new Date("2026-04-29T00:00:00Z"));
assert.equal(values.length, 3);
assert.equal(values[0].length, 30, "每行 30 列,顺序由契约测试锁定");
assert.equal(values[0][0], "A");
assert.equal(values[0][24], "9"); // matched_truck_id
assert.equal(values[0][25], "粤A1"); // matched_plate
assert.equal(values[0][26], "internal"); // vehicle_kind
assert.equal(values[0][28], "batch-1"); // batch_id
assert.equal(values[1][24], null);
assert.equal(values[1][26], "external");
assert.equal(values[2][26], "external", "无车牌也算外部");
assert.equal(values[2][25], null, "无车牌时不写 matched_plate");
assert.deepEqual(countVehicleKinds(records, plateMap), { internal: 1, external: 2 });
});
+220
View File
@@ -0,0 +1,220 @@
import * as XLSX from 'xlsx';
/**
* 电能导入的纯逻辑:xlsx 解析、取值清洗、筛选条件与插入值组装。
* 不访问数据库,因此可被 node:test 直接覆盖。
*/
/** 与 xlsx 列名对齐。 */
export const COL = {
orderNo: '订单编号',
stationNo: '电站编号',
stationName: '电站名称',
terminalName: '终端名称',
region: '所属大区',
city: '所属城市',
district: '市区名称',
operatingCompany:'运营公司',
stationType: '电站类型',
orderStatus: '订单状态',
chargeForm: '充电形式',
startTime: '充电开始时间',
endTime: '充电结束时间',
duration: '充电时长(分钟)',
kwh: '充电电量(度)',
eFee: '充电电费(元)',
serviceFee: '充电服务费(元)',
fee: '充电费用(元)',
plate: '车牌号',
judgedPlate: '判定车牌号',
vin: '车架号',
customerName: '真实姓名',
customerPhone: '手机号',
enterpriseName: '企业名称',
} as const;
export function safeStr(v: unknown, max = 250): string | null {
if (v == null) return null;
const s = String(v).trim();
if (!s) return null;
return s.slice(0, max);
}
export function safeNum(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
export function safeDt(v: unknown): string | null {
const s = safeStr(v);
if (!s) return null;
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
}
export function normalizePlate(p: unknown): string | null {
const s = safeStr(p, 32);
if (!s) return null;
// 去掉所有空白字符
const trimmed = s.replace(/\s+/g, '').toUpperCase();
return trimmed || null;
}
export interface ParsedRow {
orderNo: string;
raw: Record<string, unknown>;
values: {
stationNo: string | null; stationName: string | null; terminalName: string | null;
region: string | null; city: string | null; district: string | null;
operatingCompany: string | null; stationType: string | null;
orderStatus: string | null; chargeForm: string | null;
startTime: string | null; endTime: string | null;
duration: number | null; kwh: number | null;
eFee: number | null; serviceFee: number | null; fee: number | null;
plate: string | null; judgedPlate: string | null; vin: string | null;
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
};
}
export function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
// 寻找含"订单编号"和"车牌号"的那一行
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!Array.isArray(row)) continue;
const cells = row.map(c => (c == null ? '' : String(c)));
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
return { headerIdx: i, header: cells };
}
}
return null;
}
export function parseSheet(buf: ArrayBuffer): ParsedRow[] {
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
if (!ws) return [];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
const found = findHeaderRow(rows as unknown[][]);
if (!found) return [];
const { headerIdx, header } = found;
const idx = (label: string) => header.indexOf(label);
const result: ParsedRow[] = [];
for (let r = headerIdx + 1; r < rows.length; r++) {
const row = rows[r];
if (!Array.isArray(row)) continue;
const orderNo = safeStr(row[idx(COL.orderNo)]);
if (!orderNo) continue;
const raw: Record<string, unknown> = {};
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
result.push({
orderNo,
raw,
values: {
stationNo: safeStr(row[idx(COL.stationNo)]),
stationName: safeStr(row[idx(COL.stationName)]),
terminalName: safeStr(row[idx(COL.terminalName)]),
region: safeStr(row[idx(COL.region)]),
city: safeStr(row[idx(COL.city)]),
district: safeStr(row[idx(COL.district)]),
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
stationType: safeStr(row[idx(COL.stationType)]),
orderStatus: safeStr(row[idx(COL.orderStatus)]),
chargeForm: safeStr(row[idx(COL.chargeForm)]),
startTime: safeDt(row[idx(COL.startTime)]),
endTime: safeDt(row[idx(COL.endTime)]),
duration: safeNum(row[idx(COL.duration)]),
kwh: safeNum(row[idx(COL.kwh)]),
eFee: safeNum(row[idx(COL.eFee)]),
serviceFee: safeNum(row[idx(COL.serviceFee)]),
fee: safeNum(row[idx(COL.fee)]),
plate: normalizePlate(row[idx(COL.plate)]),
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
vin: safeStr(row[idx(COL.vin)]),
customerName: safeStr(row[idx(COL.customerName)]),
customerPhone: safeStr(row[idx(COL.customerPhone)]),
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
},
});
}
return result;
}
/** 文件内按订单号去重(保留最后一条,与既有行为一致)。 */
export function dedupeByOrderNo(parsed: ParsedRow[]): { records: ParsedRow[]; fileDuplicates: number } {
const dedupMap = new Map<string, ParsedRow>();
for (const p of parsed) dedupMap.set(p.orderNo, p);
const records = Array.from(dedupMap.values());
return { records, fileDuplicates: parsed.length - records.length };
}
/** 列表接口的 WHERE 片段与参数(顺序固定,由契约测试锁定)。 */
export function buildListFilter(query: { kind?: string; batchId?: string; search?: string }): {
where: string[];
params: (string | number)[];
} {
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
const kind = query.kind || '';
if (kind === 'internal' || kind === 'external') {
where.push('vehicle_kind = ?');
params.push(kind);
}
if (query.batchId) {
where.push('batch_id = ?');
params.push(query.batchId);
}
if (query.search) {
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
const q = `%${query.search}%`;
params.push(q, q, q);
}
return { where, params };
}
/**
* 组装批量插入的值。
* 命中系统车辆记 internal;其余(含车牌为空)一律 external。
*/
export function toInsertValues(
records: ParsedRow[],
plateMap: Map<string, string>,
batchId: string,
importedAt: Date,
): unknown[][] {
return records.map(r => {
const plate = r.values.plate || r.values.judgedPlate;
const matchedId = plate ? plateMap.get(plate) || null : null;
const kind = matchedId ? 'internal' : 'external';
return [
r.orderNo,
r.values.stationNo, r.values.stationName, r.values.terminalName,
r.values.region, r.values.city, r.values.district,
r.values.operatingCompany, r.values.stationType,
r.values.orderStatus, r.values.chargeForm,
r.values.startTime, r.values.endTime, r.values.duration,
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
r.values.plate, r.values.judgedPlate, r.values.vin,
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
matchedId, matchedId ? plate : null, kind,
JSON.stringify(r.raw),
batchId, importedAt,
];
});
}
/** 内/外部车辆计数:无车牌也算外部。 */
export function countVehicleKinds(
records: ParsedRow[],
plateMap: Map<string, string>,
): { internal: number; external: number } {
let internal = 0;
let external = 0;
for (const r of records) {
const plate = r.values.plate || r.values.judgedPlate;
if (plate && plateMap.has(plate)) internal++;
else external++;
}
return { internal, external };
}
+120
View File
@@ -0,0 +1,120 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
/**
* 电能充电记录表的全部 SQL 出口。
* 语句文本与参数顺序由 routes.test.ts 的契约测试锁定。
*/
/** 只需要 query 能力的最小依赖,便于测试注入 mock。 */
export interface Database {
query<T = any>(sql: string, values?: unknown[]): Promise<[T, ...any[]]>;
}
/** 按车牌查系统车辆 id(用于区分内部/外部车辆)。 */
export async function findTruckIdsByPlates(
db: Database,
plates: string[],
): Promise<Map<string, string>> {
if (plates.length === 0) return new Map();
const placeholders = plates.map(() => '?').join(',');
const [rows] = await db.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
plates,
);
const map = new Map<string, string>();
for (const r of rows) {
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
}
return map;
}
/** 批量 INSERT IGNORE,靠 order_no 的 UNIQUE 约束做库内去重。 */
export async function insertChargeRecords(db: Database, values: unknown[][]): Promise<number> {
const [result] = await db.query<ResultSetHeader>(
`INSERT IGNORE INTO bi_ele_charge_record
(order_no, station_no, station_name, terminal_name, region, city, district,
operating_company, station_type, order_status, charge_form,
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
matched_truck_id, matched_plate, vehicle_kind, raw_json,
batch_id, imported_at)
VALUES ?`,
[values],
);
return result.affectedRows;
}
export async function listChargeRecords(
db: Database,
filter: { where: string[]; params: (string | number)[]; limit: number; offset: number },
): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT id, order_no, station_name, terminal_name, region, city,
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
plate, judged_plate, customer_name, vehicle_kind,
batch_id, imported_at
FROM bi_ele_charge_record
WHERE ${filter.where.join(' AND ')}
ORDER BY start_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...filter.params, filter.limit, filter.offset],
);
return rows;
}
export async function countChargeRecords(
db: Database,
filter: { where: string[]; params: (string | number)[] },
): Promise<number> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${filter.where.join(' AND ')}`,
filter.params,
);
return Number(rows[0]?.total || 0);
}
export async function listBatches(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT batch_id,
MIN(imported_at) AS imported_at,
COUNT(*) AS records,
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY batch_id
ORDER BY imported_at DESC
LIMIT 50`,
);
return rows;
}
export async function aggregateByVehicleKind(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY vehicle_kind`,
);
return rows;
}
export async function aggregateDaily30d(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
ORDER BY date DESC`,
);
return rows;
}
+209
View File
@@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import test from "node:test";
import { Hono } from "hono";
import type { AuthUser } from "../../auth/types.js";
import { registerEleRoutes, type EleDependencies } from "./routes.js";
import type { Database } from "./repository.js";
import type { ParsedRow } from "./model.js";
interface Call {
sql: string;
params: unknown[];
}
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
const calls: Call[] = [];
const queue = [...resultSets];
const db: Database = {
async query(sql: string, params?: unknown[]) {
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
return [queue.shift() ?? [], []] as never;
},
};
return { db, calls };
}
const ENERGY_USER: AuthUser = {
userId: "u-e",
userName: "能源",
loginName: "energy",
depCode: "",
depName: "",
permissionLevel: "full",
roles: ["BI-LEADER-ENERGY"],
};
const PLAIN_USER: AuthUser = { ...ENERGY_USER, roles: [] };
function makeApp(user: AuthUser | undefined, deps: EleDependencies): Hono<{ Variables: { user: AuthUser } }> {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
if (user) c.set("user", user);
await next();
});
registerEleRoutes(app as never, deps);
return app;
}
function parsedRow(orderNo: string, plate: string | null, kwh: number | null): ParsedRow {
return {
orderNo,
raw: { 订单编号: orderNo },
values: {
stationNo: "S1", stationName: "站", terminalName: null,
region: null, city: null, district: null,
operatingCompany: null, stationType: null,
orderStatus: null, chargeForm: null,
startTime: "2026-04-29 10:00:00", endTime: null,
duration: null, kwh,
eFee: null, serviceFee: null, fee: 12.5,
plate, judgedPlate: null, vin: null,
customerName: null, customerPhone: null, enterpriseName: null,
},
};
}
test("电能导入受能源角色保护(fail-closed", async () => {
const { db, calls } = createMockDb();
const app = makeApp(PLAIN_USER, { db, ensureTable: async () => {} });
for (const [method, url] of [["GET", "/list"], ["GET", "/batches"], ["GET", "/aggregate"]] as const) {
const res = await app.request(url, { method });
assert.equal(res.status, 403, `${method} ${url} 应被拒绝`);
}
assert.equal(calls.length, 0, "无权限时不应触达数据库");
});
test("列表:WHERE 片段、分页参数与总数查询保持一致", async () => {
const { db, calls } = createMockDb([[{ id: 1 }], [{ total: 3 }]]);
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
const res = await app.request("/list?kind=internal&batchId=b1&search=粤A&page=2&limit=10");
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { items: [{ id: 1 }], total: 3, page: 2, limit: 10, totalPages: 1 });
assert.equal(calls.length, 2);
assert.equal(
calls[0].sql,
"SELECT id, order_no, station_name, terminal_name, region, city, start_time, end_time, duration_min, kwh, fee, e_fee, service_fee, plate, judged_plate, customer_name, vehicle_kind, batch_id, imported_at FROM bi_ele_charge_record WHERE 1=1 AND vehicle_kind = ? AND batch_id = ? AND (order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?) ORDER BY start_time DESC, id DESC LIMIT ? OFFSET ?",
);
assert.deepEqual(calls[0].params, ["internal", "b1", "%粤A%", "%粤A%", "%粤A%", 10, 10]);
assert.equal(
calls[1].sql,
"SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE 1=1 AND vehicle_kind = ? AND batch_id = ? AND (order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)",
);
assert.deepEqual(calls[1].params, ["internal", "b1", "%粤A%", "%粤A%", "%粤A%"], "计数不带分页参数");
});
test("列表:limit 上限 200page 最小 1", async () => {
const { db, calls } = createMockDb([[], [{ total: 0 }]]);
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
await app.request("/list?limit=9999&page=0");
assert.deepEqual(calls[0].params, [200, 0], "limit 截断到 200page 回退 1 => offset 0");
});
test("批次与聚合:SQL 原样保留", async () => {
const { db, calls } = createMockDb([[], [], []]);
const app = makeApp(ENERGY_USER, { db, ensureTable: async () => {} });
await app.request("/batches");
assert.equal(
calls[0].sql,
"SELECT batch_id, MIN(imported_at) AS imported_at, COUNT(*) AS records, SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count, SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count, ROUND(SUM(kwh), 2) AS total_kwh, ROUND(SUM(fee), 2) AS total_fee FROM bi_ele_charge_record GROUP BY batch_id ORDER BY imported_at DESC LIMIT 50",
);
calls.length = 0;
await app.request("/aggregate");
assert.equal(calls.length, 2);
assert.equal(
calls[0].sql,
"SELECT vehicle_kind, COUNT(*) AS records, ROUND(SUM(kwh), 2) AS total_kwh, ROUND(SUM(fee), 2) AS total_fee FROM bi_ele_charge_record GROUP BY vehicle_kind",
);
assert.equal(
calls[1].sql,
"SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date, vehicle_kind, COUNT(*) AS records, ROUND(SUM(kwh), 2) AS total_kwh, ROUND(SUM(fee), 2) AS total_fee FROM bi_ele_charge_record WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind ORDER BY date DESC",
);
});
test("导入:车牌匹配 + 批量插入语句与统计口径不变", async () => {
const { db, calls } = createMockDb([
[{ plate_number: "粤A1", truck_id: 9 }], // findTruckIdsByPlates
{ affectedRows: 1 }, // insert
]);
const rows = [
parsedRow("A", "粤B2", 10),
parsedRow("B", "粤B2", 20),
parsedRow("A", "粤A1", 30), // 文件内重复订单号:按既有口径保留最后一条
];
const app = makeApp(ENERGY_USER, {
db,
ensureTable: async () => {},
parse: () => rows,
});
const form = new FormData();
form.set("file", new File([new Uint8Array([1])], "charges.xlsx"));
const res = await app.request("/import", { method: "POST", body: form });
assert.equal(res.status, 200);
const payload = await res.json() as Record<string, unknown>;
assert.equal(payload.ok, true);
assert.equal(payload.filename, "charges.xlsx");
assert.equal(payload.parsed, 3);
assert.equal(payload.fileDuplicates, 1);
assert.equal(payload.inserted, 1);
assert.equal(payload.dbDuplicates, 1, "去重后 2 条,插入 1 条 => 库内重复 1 条");
assert.deepEqual(payload.breakdown, { internal: 1, external: 1 });
assert.equal(typeof payload.batchId, "string");
assert.equal(calls.length, 2);
assert.equal(
calls[0].sql,
"SELECT plate_number, CAST(id AS CHAR) AS truck_id FROM vehicle_info WHERE del_flag = '0' AND plate_number IN (?,?)",
);
assert.deepEqual(calls[0].params, ["粤A1", "粤B2"]);
assert.equal(
calls[1].sql,
"INSERT IGNORE INTO bi_ele_charge_record (order_no, station_no, station_name, terminal_name, region, city, district, operating_company, station_type, order_status, charge_form, start_time, end_time, duration_min, kwh, e_fee, service_fee, fee, plate, judged_plate, vin, customer_name, customer_phone, enterprise_name, matched_truck_id, matched_plate, vehicle_kind, raw_json, batch_id, imported_at) VALUES ?",
);
const inserted = calls[1].params[0] as unknown[][];
assert.equal(inserted.length, 2);
assert.equal(inserted[0].length, 30);
assert.equal(inserted[0][24], "9", "truck id 以字符串写入,与原实现一致");
assert.equal(inserted[0][26], "internal");
assert.equal(inserted[1][26], "external");
});
test("导入:解析失败与空结果返回 400,且不写库", async () => {
const { db, calls } = createMockDb();
const app = makeApp(ENERGY_USER, {
db,
ensureTable: async () => {},
parse: () => { throw new Error('bad file'); },
});
const form = new FormData();
form.set("file", new File([new Uint8Array([1])], "bad.xlsx"));
const res = await app.request("/import", { method: "POST", body: form });
assert.equal(res.status, 400);
assert.equal(calls.length, 0);
const emptyApp = makeApp(ENERGY_USER, { db, ensureTable: async () => {}, parse: () => [] });
const form2 = new FormData();
form2.set("file", new File([new Uint8Array([1])], "empty.xlsx"));
const res2 = await emptyApp.request("/import", { method: "POST", body: form2 });
assert.equal(res2.status, 400);
assert.equal(calls.length, 0);
});
test("分层:路由文件不再直接书写 SQL", () => {
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
const offenders = source.match(/\b(SELECT|INSERT IGNORE|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts,路由只做校验与组装");
});
+117 -345
View File
@@ -1,367 +1,139 @@
import { Hono } from 'hono';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
import * as XLSX from 'xlsx';
import pool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { ensureChargeRecordTable } from '../../db/schema/ele.js';
import {
buildListFilter,
countVehicleKinds,
dedupeByOrderNo,
parseSheet,
toInsertValues,
type ParsedRow,
} from './model.js';
import {
aggregateByVehicleKind,
aggregateDaily30d,
countChargeRecords,
findTruckIdsByPlates,
insertChargeRecords,
listBatches,
listChargeRecords,
type Database,
} from './repository.js';
const app = new Hono();
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
app.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 电能导入需要 BI-LEADER-ENERGY 角色' }, 403);
}
return next();
});
// 与 xlsx 列名对齐
const COL = {
orderNo: '订单编号',
stationNo: '电站编号',
stationName: '电站名称',
terminalName: '终端名称',
region: '所属大区',
city: '所属城市',
district: '市区名称',
operatingCompany:'运营公司',
stationType: '电站类型',
orderStatus: '订单状态',
chargeForm: '充电形式',
startTime: '充电开始时间',
endTime: '充电结束时间',
duration: '充电时长(分钟)',
kwh: '充电电量(度)',
eFee: '充电电费(元)',
serviceFee: '充电服务费(元)',
fee: '充电费用(元)',
plate: '车牌号',
judgedPlate: '判定车牌号',
vin: '车架号',
customerName: '真实姓名',
customerPhone: '手机号',
enterpriseName: '企业名称',
} as const;
function safeStr(v: unknown, max = 250): string | null {
if (v == null) return null;
const s = String(v).trim();
if (!s) return null;
return s.slice(0, max);
export interface EleDependencies {
db: Database;
/** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */
ensureTable: () => Promise<void>;
/** xlsx 解析,测试可注入以避免构造真实文件。 */
parse?: (buf: ArrayBuffer) => ParsedRow[];
}
function safeNum(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
const { db, ensureTable } = deps;
const parse = deps.parse ?? parseSheet;
function safeDt(v: unknown): string | null {
const s = safeStr(v);
if (!s) return null;
// Excel 文本化日期 "2026-04-29 16:24:05" 直接传给 MySQL DATETIME 是 OK 的
// 简单校验
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(s)) return null;
return s.length === 10 ? `${s} 00:00:00` : (s.length === 16 ? `${s}:00` : s);
}
function normalizePlate(p: unknown): string | null {
const s = safeStr(p, 32);
if (!s) return null;
// 去掉所有空白字符
const trimmed = s.replace(/\s+/g, '').toUpperCase();
return trimmed || null;
}
function findHeaderRow(rows: unknown[][]): { headerIdx: number; header: string[] } | null {
// 寻找含"订单编号"和"车牌号"的那一行
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!Array.isArray(row)) continue;
const cells = row.map(c => (c == null ? '' : String(c)));
if (cells.includes(COL.orderNo) && cells.includes(COL.plate)) {
return { headerIdx: i, header: cells };
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
// 独立鉴权(fail-closed),否则任何已登录用户都能写入 bi_ele_charge_record。
app.use('*', async (c, next) => {
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
if (!canAccessEnergy(user?.roles)) {
return c.json({ error: 'Forbidden: 电能导入需要 BI-LEADER-ENERGY 角色' }, 403);
}
}
return null;
}
return next();
});
interface ParsedRow {
orderNo: string;
raw: Record<string, unknown>;
values: {
stationNo: string | null; stationName: string | null; terminalName: string | null;
region: string | null; city: string | null; district: string | null;
operatingCompany: string | null; stationType: string | null;
orderStatus: string | null; chargeForm: string | null;
startTime: string | null; endTime: string | null;
duration: number | null; kwh: number | null;
eFee: number | null; serviceFee: number | null; fee: number | null;
plate: string | null; judgedPlate: string | null; vin: string | null;
customerName: string | null; customerPhone: string | null; enterpriseName: string | null;
};
}
// POST /api/ele/import — 上传 xlsx 文件
app.post('/import', async (c) => {
await ensureTable();
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const filename = file.name || 'unnamed.xlsx';
const buf = await file.arrayBuffer();
let parsed: ParsedRow[];
try {
parsed = parse(buf);
} catch (e) {
console.error('parseSheet error:', e);
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
}
if (parsed.length === 0) {
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
}
function parseSheet(buf: ArrayBuffer): ParsedRow[] {
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
if (!ws) return [];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { defval: null, raw: false, header: 1 });
const found = findHeaderRow(rows as unknown[][]);
if (!found) return [];
const { headerIdx, header } = found;
const idx = (label: string) => header.indexOf(label);
const result: ParsedRow[] = [];
for (let r = headerIdx + 1; r < rows.length; r++) {
const row = rows[r];
if (!Array.isArray(row)) continue;
const orderNo = safeStr(row[idx(COL.orderNo)]);
if (!orderNo) continue;
const raw: Record<string, unknown> = {};
header.forEach((h, i) => { raw[h] = row[i] ?? null; });
result.push({
orderNo,
raw,
values: {
stationNo: safeStr(row[idx(COL.stationNo)]),
stationName: safeStr(row[idx(COL.stationName)]),
terminalName: safeStr(row[idx(COL.terminalName)]),
region: safeStr(row[idx(COL.region)]),
city: safeStr(row[idx(COL.city)]),
district: safeStr(row[idx(COL.district)]),
operatingCompany: safeStr(row[idx(COL.operatingCompany)]),
stationType: safeStr(row[idx(COL.stationType)]),
orderStatus: safeStr(row[idx(COL.orderStatus)]),
chargeForm: safeStr(row[idx(COL.chargeForm)]),
startTime: safeDt(row[idx(COL.startTime)]),
endTime: safeDt(row[idx(COL.endTime)]),
duration: safeNum(row[idx(COL.duration)]),
kwh: safeNum(row[idx(COL.kwh)]),
eFee: safeNum(row[idx(COL.eFee)]),
serviceFee: safeNum(row[idx(COL.serviceFee)]),
fee: safeNum(row[idx(COL.fee)]),
plate: normalizePlate(row[idx(COL.plate)]),
judgedPlate: normalizePlate(row[idx(COL.judgedPlate)]),
vin: safeStr(row[idx(COL.vin)]),
customerName: safeStr(row[idx(COL.customerName)]),
customerPhone: safeStr(row[idx(COL.customerPhone)]),
enterpriseName: safeStr(row[idx(COL.enterpriseName)]),
},
const { records, fileDuplicates } = dedupeByOrderNo(parsed);
// 系统车辆匹配
const allPlates = new Set<string>();
for (const r of records) {
if (r.values.plate) allPlates.add(r.values.plate);
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
}
const plateMap = await findTruckIdsByPlates(db, Array.from(allPlates));
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const importedAt = new Date();
const inserted = await insertChargeRecords(db, toInsertValues(records, plateMap, batchId, importedAt));
const dbDuplicates = records.length - inserted;
const breakdown = countVehicleKinds(records, plateMap);
return c.json({
ok: true,
filename,
batchId,
parsed: parsed.length,
fileDuplicates,
inserted,
dbDuplicates,
breakdown,
});
}
return result;
}
async function buildPlateLookup(plates: Set<string>): Promise<Map<string, string>> {
if (plates.size === 0) return new Map();
const arr = Array.from(plates);
const placeholders = arr.map(() => '?').join(',');
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT plate_number, CAST(id AS CHAR) AS truck_id
FROM vehicle_info
WHERE del_flag = '0' AND plate_number IN (${placeholders})`,
arr,
);
const map = new Map<string, string>();
for (const r of rows) {
if (r.plate_number && r.truck_id) map.set(String(r.plate_number).toUpperCase(), String(r.truck_id));
}
return map;
}
// =========================================================
// POST /api/ele/import — 上传 xlsx 文件
// =========================================================
app.post('/import', async (c) => {
await ensureChargeRecordTable();
const form = await c.req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return c.json({ ok: false, message: '未上传文件' }, 400);
}
const filename = file.name || 'unnamed.xlsx';
const buf = await file.arrayBuffer();
let parsed: ParsedRow[];
try {
parsed = parseSheet(buf);
} catch (e) {
console.error('parseSheet error:', e);
return c.json({ ok: false, message: '解析失败:文件格式不正确' }, 400);
}
if (parsed.length === 0) {
return c.json({ ok: false, message: '未识别到任何记录(请确认表头含「订单编号」与「车牌号」)' }, 400);
}
// 文件内去重
const dedupMap = new Map<string, ParsedRow>();
for (const p of parsed) dedupMap.set(p.orderNo, p);
const records = Array.from(dedupMap.values());
const fileDuplicates = parsed.length - records.length;
// 系统车辆匹配
const allPlates = new Set<string>();
for (const r of records) {
if (r.values.plate) allPlates.add(r.values.plate);
if (r.values.judgedPlate) allPlates.add(r.values.judgedPlate);
}
const plateMap = await buildPlateLookup(allPlates);
const batchId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const importedAt = new Date();
// 批量 INSERT IGNORE 实现订单编号 UNIQUE 去重
const sql = `INSERT IGNORE INTO bi_ele_charge_record
(order_no, station_no, station_name, terminal_name, region, city, district,
operating_company, station_type, order_status, charge_form,
start_time, end_time, duration_min, kwh, e_fee, service_fee, fee,
plate, judged_plate, vin, customer_name, customer_phone, enterprise_name,
matched_truck_id, matched_plate, vehicle_kind, raw_json,
batch_id, imported_at)
VALUES ?`;
const values = records.map(r => {
const plate = r.values.plate || r.values.judgedPlate;
const matchedId = plate ? plateMap.get(plate) || null : null;
// 命中系统车辆=internal;其余(含车牌为空)一律 external
const kind = matchedId ? 'internal' : 'external';
return [
r.orderNo,
r.values.stationNo, r.values.stationName, r.values.terminalName,
r.values.region, r.values.city, r.values.district,
r.values.operatingCompany, r.values.stationType,
r.values.orderStatus, r.values.chargeForm,
r.values.startTime, r.values.endTime, r.values.duration,
r.values.kwh, r.values.eFee, r.values.serviceFee, r.values.fee,
r.values.plate, r.values.judgedPlate, r.values.vin,
r.values.customerName, r.values.customerPhone, r.values.enterpriseName,
matchedId, matchedId ? plate : null, kind,
JSON.stringify(r.raw),
batchId, importedAt,
];
});
const [result] = await pool.query<ResultSetHeader>(sql, [values]);
const inserted = result.affectedRows;
const dbDuplicates = records.length - inserted;
// GET /api/ele/list — 分页列表(最新优先)
app.get('/list', async (c) => {
await ensureTable();
const page = Math.max(1, Number(c.req.query('page')) || 1);
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
const filter = buildListFilter({
kind: c.req.query('kind'),
batchId: c.req.query('batchId'),
search: c.req.query('search'),
});
// 统计内/外(无车牌也算外部)
let internal = 0, external = 0;
for (const r of records) {
const plate = r.values.plate || r.values.judgedPlate;
if (plate && plateMap.has(plate)) internal++;
else external++;
}
return c.json({
ok: true,
filename,
batchId,
parsed: parsed.length,
fileDuplicates,
inserted,
dbDuplicates,
breakdown: { internal, external },
const offset = (page - 1) * limit;
const items = await listChargeRecords(db, { ...filter, limit, offset });
const total = await countChargeRecords(db, filter);
return c.json({ items, total, page, limit, totalPages: Math.ceil(total / limit) });
});
});
// =========================================================
// GET /api/ele/list — 分页列表(最新优先)
// =========================================================
app.get('/list', async (c) => {
await ensureChargeRecordTable();
const page = Math.max(1, Number(c.req.query('page')) || 1);
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
const kind = c.req.query('kind') || '';
const batchId = c.req.query('batchId') || '';
const search = c.req.query('search') || '';
// GET /api/ele/batches — 批次列表
app.get('/batches', async (c) => {
await ensureTable();
return c.json({ items: await listBatches(db) });
});
const where: string[] = ['1=1'];
const params: (string | number)[] = [];
if (kind === 'internal' || kind === 'external') {
where.push('vehicle_kind = ?');
params.push(kind);
}
if (batchId) {
where.push('batch_id = ?');
params.push(batchId);
}
if (search) {
where.push('(order_no LIKE ? OR plate LIKE ? OR station_name LIKE ?)');
const q = `%${search}%`;
params.push(q, q, q);
}
// GET /api/ele/aggregate — 聚合统计
app.get('/aggregate', async (c) => {
await ensureTable();
const [overall, daily] = await Promise.all([
aggregateByVehicleKind(db),
aggregateDaily30d(db),
]);
return c.json({ overall, daily });
});
}
const offset = (page - 1) * limit;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, order_no, station_name, terminal_name, region, city,
start_time, end_time, duration_min, kwh, fee, e_fee, service_fee,
plate, judged_plate, customer_name, vehicle_kind,
batch_id, imported_at
FROM bi_ele_charge_record
WHERE ${where.join(' AND ')}
ORDER BY start_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
);
const [countRows] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total FROM bi_ele_charge_record WHERE ${where.join(' AND ')}`,
params,
);
const total = Number(countRows[0]?.total || 0);
return c.json({ items: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
});
// =========================================================
// GET /api/ele/batches — 批次列表
// =========================================================
app.get('/batches', async (c) => {
await ensureChargeRecordTable();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT batch_id,
MIN(imported_at) AS imported_at,
COUNT(*) AS records,
SUM(CASE WHEN vehicle_kind='internal' THEN 1 ELSE 0 END) AS internal_count,
SUM(CASE WHEN vehicle_kind='external' THEN 1 ELSE 0 END) AS external_count,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY batch_id
ORDER BY imported_at DESC
LIMIT 50`,
);
return c.json({ items: rows });
});
// =========================================================
// GET /api/ele/aggregate — 聚合统计
// =========================================================
app.get('/aggregate', async (c) => {
await ensureChargeRecordTable();
// 全量分类汇总
const [overallRows] = await pool.query<RowDataPacket[]>(
`SELECT vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
GROUP BY vehicle_kind`,
);
// 近 30 日按日
const [dailyRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
vehicle_kind,
COUNT(*) AS records,
ROUND(SUM(kwh), 2) AS total_kwh,
ROUND(SUM(fee), 2) AS total_fee
FROM bi_ele_charge_record
WHERE start_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(start_time, '%Y-%m-%d'), vehicle_kind
ORDER BY date DESC`,
);
return c.json({ overall: overallRows, daily: dailyRows });
});
/** 生产用路由器:绑定真实连接池与建表函数。 */
export function createEleRouter(): Hono {
const app = new Hono();
registerEleRoutes(app, { db: pool, ensureTable: ensureChargeRecordTable });
return app;
}
const app = createEleRouter();
export default app;