feat: reconcile electric daily totals
This commit is contained in:
@@ -169,6 +169,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
### 阶段 C:数据可信度
|
### 阶段 C:数据可信度
|
||||||
|
|
||||||
- 已完成电能数据截至时间和实际趋势月展示。
|
- 已完成电能数据截至时间和实际趋势月展示。
|
||||||
|
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||||
- 已完成氢能结构化故障状态与重试体验。
|
- 已完成氢能结构化故障状态与重试体验。
|
||||||
- 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。
|
- 已完成里程今日仪表快照复用:普通查询命中分钟级快照,手动刷新生成一致的分页快照。
|
||||||
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
|
- 已在各 BI 模块头部提供当前数据源的真实请求状态、失败率和最后成功/失败时间。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { motion, AnimatePresence } from 'motion/react';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
import { fetchElectricMonthly, fetchElectricOrders } from './api';
|
import { fetchElectricMonthly, fetchElectricOrders } from './api';
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
normalizeDateRange,
|
normalizeDateRange,
|
||||||
type DateRangeMode,
|
type DateRangeMode,
|
||||||
} from './date-range';
|
} from './date-range';
|
||||||
|
import { reconcileElectricDaily } from './electric-reconciliation';
|
||||||
|
|
||||||
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
|
const VEHICLE_SCOPE_OPTIONS: readonly AnalysisScopeOption<ElectricVehicleScope>[] = [
|
||||||
{ id: 'all', label: '全部车辆' },
|
{ id: 'all', label: '全部车辆' },
|
||||||
@@ -284,6 +285,9 @@ export default function ElectricDaily() {
|
|||||||
const abnormalBg = isAbnormal
|
const abnormalBg = isAbnormal
|
||||||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||||||
: 'bg-slate-50/50';
|
: 'bg-slate-50/50';
|
||||||
|
const reconciliation = orders?.date === d.date
|
||||||
|
? reconcileElectricDaily(d, orders, vehicleScope)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={d.date} className="border-t border-slate-100">
|
<div key={d.date} className="border-t border-slate-100">
|
||||||
<button
|
<button
|
||||||
@@ -304,6 +308,25 @@ export default function ElectricDaily() {
|
|||||||
</button>
|
</button>
|
||||||
{selectedDate === d.date && (
|
{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">
|
<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 ? (
|
{ordersError ? (
|
||||||
<ErrorState message={ordersError} variant="inline" />
|
<ErrorState message={ordersError} variant="inline" />
|
||||||
) : !orders || orders.date !== d.date ? (
|
) : !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