Files
ln-bi/src/server/routes/ele/index.ts
kkfluous d1d79f1c7c
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(energy): 电能统计切到 bi_ele_charge_record,外部数据接通
- /api/energy/electric/overview & /electric/monthly 不再读 tab_energy_electricity_bill
- 改读 bi_ele_charge_record:kwh/fee/start_time
- 外部/我司用 vehicle_kind 区分(external/internal)
- 电能默认 customer 由 'external' 改 'lingniu',与导入页约定一致
- ElectricDaily 移除「数据对接中…」友好空状态(外部已有数据)

ele 导入页同步收紧:
- 命中系统车辆=internal,未命中(含车牌为空)一律 external
- 移除 unknown 分类、KPI 卡、批次列、过滤按钮、UploadResult 字段
- 历史 unknown 行已 UPDATE 为 external

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:11:52 +08:00

356 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';
import * as XLSX from 'xlsx';
import pool from '../../db.js';
import { ensureChargeRecordTable } from './migration.js';
const app = new Hono();
// 与 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);
}
function safeNum(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
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 };
}
}
return null;
}
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;
};
}
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;
}
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 tab_truck
WHERE is_deleted = 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;
// 统计内/外(无车牌也算外部)
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 },
});
});
// =========================================================
// 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') || '';
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);
}
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 default app;