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; 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(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 = {}; 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): Promise> { if (plates.size === 0) return new Map(); const arr = Array.from(plates); const placeholders = arr.map(() => '?').join(','); const [rows] = await pool.query( `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(); 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(); 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(); 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(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( `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( `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( `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( `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( `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;