From 57fdd346cfc0dcd7f1d90ad43137db8be5c07663 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Wed, 29 Apr 2026 19:02:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(ele):=20=E5=85=85=E7=94=B5=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=90=8E=E5=8F=B0=E5=AF=BC=E5=85=A5=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20/ele/import=EF=BC=88=E9=9A=90=E8=97=8F=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 - 新建 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) --- src/App.tsx | 6 + src/modules/ele/EleImportPage.tsx | 383 +++++++++++++++++++++++++++++ src/server/index.ts | 2 + src/server/routes/ele/index.ts | 356 +++++++++++++++++++++++++++ src/server/routes/ele/migration.ts | 49 ++++ 5 files changed, 796 insertions(+) create mode 100644 src/modules/ele/EleImportPage.tsx create mode 100644 src/server/routes/ele/index.ts create mode 100644 src/server/routes/ele/migration.ts diff --git a/src/App.tsx b/src/App.tsx index 769dda2..dd20f2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import AssetsModule from './modules/assets/AssetsModule'; import MileageModule from './modules/mileage/MileageModule'; import SchedulingModule from './modules/scheduling/SchedulingModule'; import EnergyModule from './modules/energy/EnergyModule'; +import EleImportPage from './modules/ele/EleImportPage'; import AuthProvider from './auth/AuthProvider'; import { useAuth } from './auth/useAuth'; import UnauthorizedPage from './auth/UnauthorizedPage'; @@ -45,6 +46,11 @@ function AuthGate() { return ; } + // 隐藏后端管理页:仅通过 /ele/import 直接访问,主导航不出现 + if (typeof window !== 'undefined' && window.location.pathname === '/ele/import') { + return ; + } + return ; } diff --git a/src/modules/ele/EleImportPage.tsx b/src/modules/ele/EleImportPage.tsx new file mode 100644 index 0000000..150ec52 --- /dev/null +++ b/src/modules/ele/EleImportPage.tsx @@ -0,0 +1,383 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { + Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle, + Truck, ExternalLink, HelpCircle, Layers, Zap, +} from 'lucide-react'; +import { fetchJson } from '../../auth/api-client'; +import { useAuth } from '../../auth/useAuth'; + +function getJwt(): string | null { + return sessionStorage.getItem('bi_jwt'); +} + +interface UploadResult { + ok: boolean; + filename: string; + batchId: string; + parsed: number; + fileDuplicates: number; + inserted: number; + dbDuplicates: number; + breakdown: { internal: number; external: number; unknown: number }; +} + +interface ListItem { + id: number; + order_no: string; + station_name: string | null; + terminal_name: string | null; + region: string | null; + city: string | null; + start_time: string | null; + end_time: string | null; + duration_min: number | null; + kwh: number | null; + fee: number | null; + e_fee: number | null; + service_fee: number | null; + plate: string | null; + judged_plate: string | null; + customer_name: string | null; + vehicle_kind: 'internal' | 'external' | 'unknown'; + batch_id: string; + imported_at: string; +} + +interface OverallRow { vehicle_kind: 'internal' | 'external' | 'unknown'; records: number; total_kwh: number; total_fee: number; } +interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; unknown_count: number; total_kwh: number; total_fee: number; } + +const KIND_LABEL: Record = { + internal: '内部', + external: '外部', + unknown: '未知', +}; +const KIND_STYLE: Record = { + internal: 'bg-blue-50 text-blue-600 border-blue-200', + external: 'bg-amber-50 text-amber-600 border-amber-200', + unknown: 'bg-slate-50 text-slate-500 border-slate-200', +}; + +async function uploadFile(file: File): Promise { + const fd = new FormData(); + fd.append('file', file); + const token = getJwt(); + const res = await fetch('/api/ele/import', { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd, + }); + const json = await res.json(); + if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`); + return json as UploadResult; +} + +export default function EleImportPage() { + const { user } = useAuth(); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [overall, setOverall] = useState([]); + const [batches, setBatches] = useState([]); + const [filter, setFilter] = useState<'' | 'internal' | 'external' | 'unknown'>(''); + const [batchFilter, setBatchFilter] = useState(''); + const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(null); + + const reload = useCallback(async () => { + const params = new URLSearchParams({ page: '1', limit: '50' }); + if (filter) params.set('kind', filter); + if (batchFilter) params.set('batchId', batchFilter); + if (search) params.set('search', search); + const [list, agg, b] = await Promise.all([ + fetchJson<{ items: ListItem[]; total: number }>(`/api/ele/list?${params.toString()}`), + fetchJson<{ overall: OverallRow[] }>(`/api/ele/aggregate`), + fetchJson<{ items: BatchRow[] }>(`/api/ele/batches`), + ]); + setItems(list.items); + setTotal(list.total); + setOverall(agg.overall); + setBatches(b.items); + }, [filter, batchFilter, search]); + + useEffect(() => { + reload().catch(e => console.error(e)); + }, [reload]); + + const handleUpload = async (f: File) => { + setUploading(true); + setError(null); + setResult(null); + try { + const r = await uploadFile(f); + setResult(r); + await reload(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setUploading(false); + } + }; + + const onPick = (f: File | null) => { + setFile(f); + if (f) handleUpload(f); + }; + + const overallMap = new Map(overall.map(o => [o.vehicle_kind, o])); + const totalRecords = overall.reduce((s, o) => s + Number(o.records || 0), 0); + const totalKwh = overall.reduce((s, o) => s + Number(o.total_kwh || 0), 0); + const totalFee = overall.reduce((s, o) => s + Number(o.total_fee || 0), 0); + + return ( +
+
+
+
+
+ +
+
+

充电记录导入

+

每日上传 xlsx · 订单编号去重 · 系统车辆自动匹配

+
+
+ {user?.userName || ''} +
+ + {/* 上传区 */} +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) onPick(f); + }} + onClick={() => inputRef.current?.click()} + className={`bg-white rounded-2xl border-2 border-dashed shadow-sm cursor-pointer transition-all ${ + dragOver ? 'border-blue-400 bg-blue-50/40' : uploading ? 'border-slate-200' : 'border-slate-200 hover:border-blue-300' + }`} + > + onPick(e.target.files?.[0] || null)} + /> +
+
+ {uploading ? : } +
+
+ {uploading ? '正在解析...' : file ? file.name : '点击或拖拽 xlsx 文件到此处'} +
+
+ 支持「充电成功记录明细」格式;订单编号已存在的会自动跳过 +
+
+
+ + {/* 上传结果提示 */} + + {result && ( + + +
+
+ 上传成功:{result.filename} +
+
+ + + + + +
+
+ +
+ )} +
+ + {error && ( + + +
{error}
+ +
+ )} +
+ + {/* 聚合卡 */} +
+ } label="总记录" value={totalRecords.toLocaleString()} /> + } label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" /> + } label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" /> + } label="未知" value={(overallMap.get('unknown')?.records ?? 0).toLocaleString()} accent="slate" /> + } label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} /> + } label="累计费用" value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`} /> + } label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" /> + } label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" /> +
+ + {/* 批次 */} + {batches.length > 0 && ( +
+
+ 最近上传批次 + {batchFilter && ( + + )} +
+
+ + + + + + + + + + + + + + + {batches.map(b => ( + setBatchFilter(batchFilter === b.batch_id ? '' : b.batch_id)} + className={`border-t border-slate-100 cursor-pointer transition-colors ${batchFilter === b.batch_id ? 'bg-blue-50/40' : 'hover:bg-slate-50/60'}`} + > + + + + + + + + + + ))} + +
导入时间总数内部外部未知电量(度)费用(元)批次
{(b.imported_at || '').replace('T', ' ').slice(0, 19)}{Number(b.records).toLocaleString()}{Number(b.internal_count).toLocaleString()}{Number(b.external_count).toLocaleString()}{Number(b.unknown_count).toLocaleString()}{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}{b.batch_id.slice(0, 12)}
+
+
+ )} + + {/* 列表 */} +
+
+ 最新记录 + 共 {total.toLocaleString()} 条 +
+ setSearchInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput); }} + placeholder="搜索订单/车牌/电站" + className="bg-white border border-slate-200 rounded-lg px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-blue-500/20 w-44" + /> +
+ {([['', '全部'], ['internal', '内部'], ['external', '外部'], ['unknown', '未知']] as const).map(([k, label]) => ( + + ))} +
+
+
+ + + + + + + + + + + + + + + {items.map(it => ( + + + + + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
充电时间车牌分类电站 / 终端电量(度)费用(元)时长(分)订单编号
{(it.start_time || '').replace('T', ' ').slice(0, 16)}{it.plate || it.judged_plate || } + + {KIND_LABEL[it.vehicle_kind]} + + {it.station_name || '—'}{it.terminal_name ? ` · ${it.terminal_name}` : ''}{Number(it.kwh ?? 0).toFixed(2)}{Number(it.fee ?? 0).toFixed(2)}{it.duration_min ?? '—'}{it.order_no}
+ + 尚无记录,先上传一份 xlsx 试试 +
+
+
+
+
+ ); +} + +function Stat({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function KpiCard({ icon, label, value, accent = 'slate' }: { icon: React.ReactNode; label: string; value: string; accent?: 'slate' | 'blue' | 'amber' }) { + const accentMap: Record = { + slate: 'text-slate-700', + blue: 'text-blue-600', + amber: 'text-amber-600', + }; + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} diff --git a/src/server/index.ts b/src/server/index.ts index 00ed8f0..a4a03fe 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,6 +7,7 @@ import vehiclesRouter from './routes/vehicles.js'; import mileageRouter from './routes/mileage/index.js'; import schedulingRouter from './routes/scheduling/index.js'; import energyRouter from './routes/energy/index.js'; +import eleRouter from './routes/ele/index.js'; import { ensureSchedulingTables } from './routes/scheduling/db-schema.js'; import authRouter from './auth/login.js'; import { authMiddleware } from './auth/middleware.js'; @@ -27,6 +28,7 @@ app.route('/api/vehicles', vehiclesRouter); app.route('/api/mileage', mileageRouter); app.route('/api/scheduling', schedulingRouter); app.route('/api/energy', energyRouter); +app.route('/api/ele', eleRouter); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); diff --git a/src/server/routes/ele/index.ts b/src/server/routes/ele/index.ts new file mode 100644 index 0000000..6b26105 --- /dev/null +++ b/src/server/routes/ele/index.ts @@ -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; + 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; + 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(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( + `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, + 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( + `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; diff --git a/src/server/routes/ele/migration.ts b/src/server/routes/ele/migration.ts new file mode 100644 index 0000000..6e2c6bb --- /dev/null +++ b/src/server/routes/ele/migration.ts @@ -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 { + if (ensured) return; + await pool.query(CREATE_TABLE_SQL); + ensured = true; +}