feat(ele): 充电记录后台导入页面 /ele/import(隐藏入口)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
后端 - 新建 bi_ele_charge_record 表(首次访问自动 CREATE TABLE IF NOT EXISTS) 字段含订单编号(UNIQUE)、电站、时段、电量/费用、车牌/判定车牌、内外部分类、原始 JSON、批次号 - POST /api/ele/import:multipart 上传 xlsx,识别表头自动定位, 文件内 + 数据库双重去重(INSERT IGNORE on UNIQUE order_no) 上传时按 plate/judged_plate 在 tab_truck 中匹配,命中=internal、未命中但有牌=external、无牌=unknown - GET /api/ele/list 分页 + kind/batch/search 过滤 - GET /api/ele/batches 批次汇总(数量、内/外/未知拆分、电量/费用合计) - GET /api/ele/aggregate 全量与近 30 日按日 × 分类聚合 前端 - /ele/import 路径直接渲染 EleImportPage,主导航不显示,需手动输入 URL - 拖拽/点击上传,结果卡展示解析/新增/重复/分类 - KPI 8 卡:总数、内/外/未知记录、累计电量与费用、内/外电量 - 批次列表(点击筛选)+ 最新记录表(kind 切换 + 关键字搜索) - 上传后自动 reload 全部数据 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
356
src/server/routes/ele/index.ts
Normal file
356
src/server/routes/ele/index.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
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;
|
||||
const kind = matchedId ? 'internal' : (plate ? 'external' : 'unknown');
|
||||
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, unknown = 0;
|
||||
for (const r of records) {
|
||||
const plate = r.values.plate || r.values.judgedPlate;
|
||||
if (plate && plateMap.has(plate)) internal++;
|
||||
else if (plate) external++;
|
||||
else unknown++;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
filename,
|
||||
batchId,
|
||||
parsed: parsed.length,
|
||||
fileDuplicates,
|
||||
inserted,
|
||||
dbDuplicates,
|
||||
breakdown: { internal, external, unknown },
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 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' || kind === 'unknown') {
|
||||
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,
|
||||
SUM(CASE WHEN vehicle_kind='unknown' THEN 1 ELSE 0 END) AS unknown_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;
|
||||
49
src/server/routes/ele/migration.ts
Normal file
49
src/server/routes/ele/migration.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import pool from '../../db.js';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_ele_charge_record (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
station_no VARCHAR(64) NULL,
|
||||
station_name VARCHAR(128) NULL,
|
||||
terminal_name VARCHAR(64) NULL,
|
||||
region VARCHAR(64) NULL,
|
||||
city VARCHAR(64) NULL,
|
||||
district VARCHAR(64) NULL,
|
||||
operating_company VARCHAR(128) NULL,
|
||||
station_type VARCHAR(32) NULL,
|
||||
order_status VARCHAR(32) NULL,
|
||||
charge_form VARCHAR(32) NULL,
|
||||
start_time DATETIME NULL,
|
||||
end_time DATETIME NULL,
|
||||
duration_min INT NULL,
|
||||
kwh DECIMAL(10,3) NULL,
|
||||
e_fee DECIMAL(10,2) NULL,
|
||||
service_fee DECIMAL(10,2) NULL,
|
||||
fee DECIMAL(10,2) NULL,
|
||||
plate VARCHAR(32) NULL,
|
||||
judged_plate VARCHAR(32) NULL,
|
||||
vin VARCHAR(64) NULL,
|
||||
customer_name VARCHAR(128) NULL,
|
||||
customer_phone VARCHAR(32) NULL,
|
||||
enterprise_name VARCHAR(128) NULL,
|
||||
matched_truck_id VARCHAR(32) NULL,
|
||||
matched_plate VARCHAR(32) NULL,
|
||||
vehicle_kind ENUM('internal','external','unknown') NOT NULL DEFAULT 'unknown',
|
||||
raw_json JSON NULL,
|
||||
batch_id VARCHAR(64) NOT NULL,
|
||||
imported_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uk_order_no (order_no),
|
||||
KEY idx_start_time (start_time),
|
||||
KEY idx_batch (batch_id),
|
||||
KEY idx_kind (vehicle_kind),
|
||||
KEY idx_plate (plate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
let ensured = false;
|
||||
export async function ensureChargeRecordTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
ensured = true;
|
||||
}
|
||||
Reference in New Issue
Block a user