feat(energy): 电能统计切到 bi_ele_charge_record,外部数据接通
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- /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>
This commit is contained in:
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Upload, FileSpreadsheet, RotateCcw, CheckCircle2, AlertCircle,
|
||||
Truck, ExternalLink, HelpCircle, Layers, Zap,
|
||||
Truck, ExternalLink, Layers, Zap,
|
||||
} from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
@@ -19,7 +19,7 @@ interface UploadResult {
|
||||
fileDuplicates: number;
|
||||
inserted: number;
|
||||
dbDuplicates: number;
|
||||
breakdown: { internal: number; external: number; unknown: number };
|
||||
breakdown: { internal: number; external: number };
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
@@ -44,18 +44,16 @@ interface ListItem {
|
||||
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; }
|
||||
interface OverallRow { vehicle_kind: 'internal' | 'external'; records: number; total_kwh: number; total_fee: number; }
|
||||
interface BatchRow { batch_id: string; imported_at: string; records: number; internal_count: number; external_count: number; total_kwh: number; total_fee: number; }
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
internal: '内部',
|
||||
external: '外部',
|
||||
unknown: '未知',
|
||||
};
|
||||
const KIND_STYLE: Record<string, string> = {
|
||||
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<UploadResult> {
|
||||
@@ -82,7 +80,7 @@ export default function EleImportPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [overall, setOverall] = useState<OverallRow[]>([]);
|
||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external' | 'unknown'>('');
|
||||
const [filter, setFilter] = useState<'' | 'internal' | 'external'>('');
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
@@ -204,7 +202,7 @@ export default function EleImportPage() {
|
||||
<Stat label="新增" value={result.inserted} color="text-blue-600" />
|
||||
<Stat label="重复跳过" value={result.fileDuplicates + result.dbDuplicates} color="text-slate-500" />
|
||||
<Stat label="内部" value={result.breakdown.internal} color="text-blue-600" />
|
||||
<Stat label="外部" value={result.breakdown.external} color="text-amber-600" />
|
||||
<Stat label="外部(含无车牌)" value={result.breakdown.external} color="text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setResult(null)} className="text-slate-300 hover:text-slate-600 text-[10px] font-bold">关闭</button>
|
||||
@@ -227,13 +225,11 @@ export default function EleImportPage() {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 聚合卡 */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<section className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<KpiCard icon={<Layers size={14} />} label="总记录" value={totalRecords.toLocaleString()} />
|
||||
<KpiCard icon={<Truck size={14} />} label="内部记录" value={(overallMap.get('internal')?.records ?? 0).toLocaleString()} accent="blue" />
|
||||
<KpiCard icon={<ExternalLink size={14} />} label="外部记录" value={(overallMap.get('external')?.records ?? 0).toLocaleString()} accent="amber" />
|
||||
<KpiCard icon={<HelpCircle size={14} />} label="未知" value={(overallMap.get('unknown')?.records ?? 0).toLocaleString()} accent="slate" />
|
||||
<KpiCard icon={<Zap size={14} />} label="累计电量" value={`${totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} />
|
||||
<KpiCard icon={<Zap size={14} />} label="累计费用" value={`¥${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`} />
|
||||
<KpiCard icon={<Zap size={14} />} label="内部电量" value={`${(overallMap.get('internal')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="blue" />
|
||||
<KpiCard icon={<Zap size={14} />} label="外部电量" value={`${(overallMap.get('external')?.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} accent="amber" />
|
||||
</section>
|
||||
@@ -255,7 +251,6 @@ export default function EleImportPage() {
|
||||
<th className="px-3 py-2 text-right">总数</th>
|
||||
<th className="px-3 py-2 text-right">内部</th>
|
||||
<th className="px-3 py-2 text-right">外部</th>
|
||||
<th className="px-3 py-2 text-right">未知</th>
|
||||
<th className="px-3 py-2 text-right">电量(度)</th>
|
||||
<th className="px-3 py-2 text-right">费用(元)</th>
|
||||
<th className="px-3 py-2 text-right">批次</th>
|
||||
@@ -272,7 +267,6 @@ export default function EleImportPage() {
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-700">{Number(b.records).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-blue-600 font-bold">{Number(b.internal_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-600 font-bold">{Number(b.external_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-400">{Number(b.unknown_count).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">{Number(b.total_kwh ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 1 })}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-slate-600 tabular-nums">¥{Number(b.total_fee ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-300 font-mono text-[10px]">{b.batch_id.slice(0, 12)}</td>
|
||||
@@ -299,7 +293,7 @@ export default function EleImportPage() {
|
||||
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"
|
||||
/>
|
||||
<div className="flex gap-1 bg-white p-0.5 rounded-lg border border-slate-200">
|
||||
{([['', '全部'], ['internal', '内部'], ['external', '外部'], ['unknown', '未知']] as const).map(([k, label]) => (
|
||||
{([['', '全部'], ['internal', '内部'], ['external', '外部']] as const).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k as typeof filter)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronRight, Plug } from 'lucide-react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
@@ -48,27 +48,7 @@ export default function ElectricDaily() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 外部数据对接中 友好空状态 */}
|
||||
{customer === 'external' && months !== null && months.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-12 flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
|
||||
<Plug size={22} className="text-blue-500" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-700 mb-1">数据对接中…</div>
|
||||
<div className="text-[11px] text-slate-400 max-w-[260px] leading-relaxed">
|
||||
外部充电账单正在与合作方系统打通,上线后此处将展示完整数据
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 月份分组表 */}
|
||||
{!(customer === 'external' && months !== null && months.length === 0) && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||
<span>月份 / 日期</span>
|
||||
@@ -142,7 +122,6 @@ export default function ElectricDaily() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,7 +213,8 @@ app.post('/import', async (c) => {
|
||||
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');
|
||||
// 命中系统车辆=internal;其余(含车牌为空)一律 external
|
||||
const kind = matchedId ? 'internal' : 'external';
|
||||
return [
|
||||
r.orderNo,
|
||||
r.values.stationNo, r.values.stationName, r.values.terminalName,
|
||||
@@ -234,13 +235,12 @@ app.post('/import', async (c) => {
|
||||
const inserted = result.affectedRows;
|
||||
const dbDuplicates = records.length - inserted;
|
||||
|
||||
// 统计内/外/未知
|
||||
let internal = 0, external = 0, unknown = 0;
|
||||
// 统计内/外(无车牌也算外部)
|
||||
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 if (plate) external++;
|
||||
else unknown++;
|
||||
else external++;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -251,7 +251,7 @@ app.post('/import', async (c) => {
|
||||
fileDuplicates,
|
||||
inserted,
|
||||
dbDuplicates,
|
||||
breakdown: { internal, external, unknown },
|
||||
breakdown: { internal, external },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ app.get('/list', async (c) => {
|
||||
|
||||
const where: string[] = ['1=1'];
|
||||
const params: (string | number)[] = [];
|
||||
if (kind === 'internal' || kind === 'external' || kind === 'unknown') {
|
||||
if (kind === 'internal' || kind === 'external') {
|
||||
where.push('vehicle_kind = ?');
|
||||
params.push(kind);
|
||||
}
|
||||
@@ -313,7 +313,6 @@ app.get('/batches', async (c) => {
|
||||
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
|
||||
|
||||
@@ -244,24 +244,21 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 总览:KPI + 本月每日柱图数据
|
||||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
||||
// =========================================================
|
||||
app.get('/electric/overview', async (c) => {
|
||||
const data = await cached('electric/overview', async () => {
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
SUM(charging_degree) AS totalKwh,
|
||||
SUM(cost_expense) AS totalFee,
|
||||
SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN charging_degree ELSE 0 END) AS monthKwh,
|
||||
SUM(CASE WHEN DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN cost_expense ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE()
|
||||
THEN charging_degree ELSE 0 END) AS todayKwh,
|
||||
SUM(CASE WHEN DATE(${ELECTRIC_LOCAL}) = CURDATE()
|
||||
THEN cost_expense ELSE 0 END) AS todayFee
|
||||
FROM tab_energy_electricity_bill
|
||||
WHERE is_deleted = 0`,
|
||||
SUM(kwh) AS totalKwh,
|
||||
SUM(fee) AS totalFee,
|
||||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN kwh ELSE 0 END) AS monthKwh,
|
||||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN fee ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
|
||||
FROM bi_ele_charge_record`,
|
||||
);
|
||||
const k = kpiRows[0] ?? {};
|
||||
const totalKwh = Number(k.totalKwh) || 0;
|
||||
@@ -273,28 +270,24 @@ app.get('/electric/overview', async (c) => {
|
||||
|
||||
// 本月每日(用于柱图)
|
||||
const [trendRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
||||
SUM(charging_degree) AS kwh,
|
||||
SUM(cost_expense) AS fee
|
||||
FROM tab_energy_electricity_bill
|
||||
WHERE is_deleted = 0
|
||||
AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
GROUP BY date
|
||||
ORDER BY date ASC`,
|
||||
);
|
||||
// 若本月无数据(电能数据滞后),降级展示最近一个有数据的自然月
|
||||
// 若本月无数据,降级展示最近一个有数据的自然月
|
||||
let trend = trendRows;
|
||||
if (trend.length === 0) {
|
||||
const [fallback] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
||||
SUM(charging_degree) AS kwh,
|
||||
SUM(cost_expense) AS fee
|
||||
FROM tab_energy_electricity_bill
|
||||
WHERE is_deleted = 0
|
||||
AND DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') = (
|
||||
SELECT DATE_FORMAT(MAX(${ELECTRIC_LOCAL}), '%Y-%m')
|
||||
FROM tab_energy_electricity_bill
|
||||
WHERE is_deleted = 0
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
|
||||
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
|
||||
)
|
||||
GROUP BY date
|
||||
ORDER BY date ASC`,
|
||||
@@ -307,20 +300,17 @@ app.get('/electric/overview', async (c) => {
|
||||
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
|
||||
chainPct: 0,
|
||||
}));
|
||||
// 计算环比
|
||||
for (let i = 1; i < trendArr.length; i++) {
|
||||
const prev = trendArr[i - 1].kwh;
|
||||
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
|
||||
}
|
||||
|
||||
// 今日环比 = 今日 kwh / 上一个有数据的自然日 kwh - 1
|
||||
let todayChainPct = 0;
|
||||
if (todayKwh > 0) {
|
||||
const [prevRow] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT SUM(charging_degree) AS kwh
|
||||
FROM tab_energy_electricity_bill
|
||||
WHERE is_deleted = 0
|
||||
AND DATE(${ELECTRIC_LOCAL}) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||||
`SELECT SUM(kwh) AS kwh
|
||||
FROM bi_ele_charge_record
|
||||
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||||
);
|
||||
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
||||
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||||
@@ -335,27 +325,27 @@ app.get('/electric/overview', async (c) => {
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// 电能 每日:月份分组 + 日级行
|
||||
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||||
// =========================================================
|
||||
app.get('/electric/monthly', async (c) => {
|
||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||
|
||||
const data = await cached(`electric/monthly?customer=${customer}`, async () => {
|
||||
|
||||
const where = [
|
||||
'is_deleted = 0',
|
||||
customerClause('truck_id', customer),
|
||||
].join(' AND ');
|
||||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||||
let kindClause = '1=1';
|
||||
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
||||
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
||||
|
||||
// 取最近 6 个月
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m') AS month,
|
||||
DATE_FORMAT(${ELECTRIC_LOCAL}, '%Y-%m-%d') AS date,
|
||||
SUM(charging_degree) AS kwh,
|
||||
SUM(cost_expense) AS fee
|
||||
FROM tab_energy_electricity_bill
|
||||
WHERE ${where}
|
||||
AND ${ELECTRIC_LOCAL} >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||
`SELECT DATE_FORMAT(start_time, '%Y-%m') AS month,
|
||||
DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||||
SUM(kwh) AS kwh,
|
||||
SUM(fee) AS fee
|
||||
FROM bi_ele_charge_record
|
||||
WHERE ${kindClause}
|
||||
AND start_time >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||
GROUP BY month, date
|
||||
ORDER BY date DESC`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user