feat: reconcile electric daily totals
This commit is contained in:
@@ -169,6 +169,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
||||
### 阶段 C:数据可信度
|
||||
|
||||
- 已完成电能数据截至时间和实际趋势月展示。
|
||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||
- 已完成氢能结构化故障状态与重试体验。
|
||||
- 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。
|
||||
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BatteryCharging, CalendarDays, ChevronRight, MapPin, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
|
||||
import { BatteryCharging, CalendarDays, CheckCircle2, ChevronRight, MapPin, Plug, TrendingUp, TriangleAlert, Truck, Wallet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly, fetchElectricOrders } from './api';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
normalizeDateRange,
|
||||
type DateRangeMode,
|
||||
} from './date-range';
|
||||
import { reconcileElectricDaily } from './electric-reconciliation';
|
||||
|
||||
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
|
||||
{ id: 'all', label: '全部车辆' },
|
||||
@@ -284,6 +285,9 @@ export default function ElectricDaily() {
|
||||
const abnormalBg = isAbnormal
|
||||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||
: 'bg-slate-50/50';
|
||||
const reconciliation = orders?.date === d.date
|
||||
? reconcileElectricDaily(d, orders, vehicleScope)
|
||||
: null;
|
||||
return (
|
||||
<div key={d.date} className="border-t border-slate-100">
|
||||
<button
|
||||
@@ -304,6 +308,25 @@ export default function ElectricDaily() {
|
||||
</button>
|
||||
{selectedDate === d.date && (
|
||||
<div id={`electric-orders-${d.date}`} className="border-t border-blue-100 bg-blue-50/40 px-3 py-3 md:pl-9">
|
||||
{orders && reconciliation && (
|
||||
<div
|
||||
role={reconciliation.matches ? 'status' : 'alert'}
|
||||
className={`mb-2 flex items-start gap-2 rounded-lg border px-3 py-2 text-[10px] font-bold ${
|
||||
reconciliation.matches
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800'
|
||||
}`}
|
||||
>
|
||||
{reconciliation.matches
|
||||
? <CheckCircle2 size={14} className="mt-0.5 shrink-0" />
|
||||
: <TriangleAlert size={14} className="mt-0.5 shrink-0" />}
|
||||
<span>
|
||||
{reconciliation.matches
|
||||
? `口径核对通过:日汇总与 ${orders.recordCount} 笔订单全量合计一致`
|
||||
: `口径存在差异:电量 ${reconciliation.kwhDifference >= 0 ? '+' : ''}${reconciliation.kwhDifference.toFixed(2)} 度 · 费用 ${reconciliation.feeDifference >= 0 ? '+' : ''}${reconciliation.feeDifference.toFixed(2)} 元${!reconciliation.dateMatches || !reconciliation.scopeMatches ? ' · 日期或车辆范围不一致' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ordersError ? (
|
||||
<ErrorState message={ordersError} variant="inline" />
|
||||
) : !orders || orders.date !== d.date ? (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reconcileElectricDaily } from './electric-reconciliation.js';
|
||||
import type { ElectricChargeOrderResponse, ElectricDailyRow } from './types.js';
|
||||
|
||||
const daily: ElectricDailyRow = {
|
||||
date: '2026-07-01',
|
||||
kwh: 2736.67,
|
||||
fee: 552.92,
|
||||
chainPct: 0,
|
||||
};
|
||||
|
||||
const orders: ElectricChargeOrderResponse = {
|
||||
date: '2026-07-01',
|
||||
vehicleScope: 'all',
|
||||
recordCount: 29,
|
||||
totalKwh: 2736.67,
|
||||
totalFee: 552.92,
|
||||
truncated: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
test('matches a daily aggregate to the full order summary at hundredth precision', () => {
|
||||
assert.deepEqual(reconcileElectricDaily(daily, orders, 'all'), {
|
||||
matches: true,
|
||||
dateMatches: true,
|
||||
scopeMatches: true,
|
||||
kwhDifference: 0,
|
||||
feeDifference: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports amount, date, and scope differences without averaging detail rows', () => {
|
||||
const result = reconcileElectricDaily(
|
||||
daily,
|
||||
{ ...orders, date: '2026-07-02', vehicleScope: 'lingniu', totalKwh: 2736.68, totalFee: 550 },
|
||||
'all',
|
||||
);
|
||||
assert.equal(result.matches, false);
|
||||
assert.equal(result.dateMatches, false);
|
||||
assert.equal(result.scopeMatches, false);
|
||||
assert.equal(result.kwhDifference, 0.01);
|
||||
assert.equal(result.feeDifference, -2.92);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ElectricChargeOrderResponse, ElectricDailyRow, ElectricVehicleScope } from './types';
|
||||
|
||||
export interface ElectricDailyReconciliation {
|
||||
matches: boolean;
|
||||
dateMatches: boolean;
|
||||
scopeMatches: boolean;
|
||||
kwhDifference: number;
|
||||
feeDifference: number;
|
||||
}
|
||||
|
||||
function hundredths(value: number): number {
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
export function reconcileElectricDaily(
|
||||
daily: ElectricDailyRow,
|
||||
orders: ElectricChargeOrderResponse,
|
||||
expectedScope: ElectricVehicleScope,
|
||||
): ElectricDailyReconciliation {
|
||||
const dateMatches = daily.date === orders.date;
|
||||
const scopeMatches = expectedScope === orders.vehicleScope;
|
||||
const kwhDifferenceInHundredths = hundredths(orders.totalKwh) - hundredths(daily.kwh);
|
||||
const feeDifferenceInHundredths = hundredths(orders.totalFee) - hundredths(daily.fee);
|
||||
|
||||
return {
|
||||
matches: dateMatches
|
||||
&& scopeMatches
|
||||
&& kwhDifferenceInHundredths === 0
|
||||
&& feeDifferenceInHundredths === 0,
|
||||
dateMatches,
|
||||
scopeMatches,
|
||||
kwhDifference: kwhDifferenceInHundredths / 100,
|
||||
feeDifference: feeDifferenceInHundredths / 100,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user