feat: reconcile hydrogen daily totals

This commit is contained in:
kkfluous
2026-08-07 17:12:07 +08:00
parent 003a0b580c
commit 5ea788e565
4 changed files with 145 additions and 7 deletions
+2
View File
@@ -58,6 +58,7 @@ export default function HydrogenDaily() {
? drillContext.customerName ? drillContext.customerName
: undefined; : undefined;
const selectedDate = drillContext.selectedDate; const selectedDate = drillContext.selectedDate;
const selectedDailyRow = rows?.find(row => row.date === selectedDate);
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => { const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
const url = buildHydrogenDrillUrl(window.location, next); const url = buildHydrogenDrillUrl(window.location, next);
@@ -386,6 +387,7 @@ export default function HydrogenDaily() {
{selectedDate && ( {selectedDate && (
<HydrogenOrders <HydrogenOrders
date={selectedDate} date={selectedDate}
dailyRow={selectedDailyRow}
vehicleScope={vehicleScope} vehicleScope={vehicleScope}
stationId={selectedStationId} stationId={selectedStationId}
customerName={selectedCustomerName} customerName={selectedCustomerName}
+41 -7
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, ReceiptText, X } from 'lucide-react'; import { CheckCircle2, ChevronLeft, ChevronRight, ReceiptText, TriangleAlert, X } from 'lucide-react';
import Blur from '../../components/Blur'; import Blur from '../../components/Blur';
import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface'; import { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
import { fetchHydrogenOrders } from './api'; import { fetchHydrogenOrders } from './api';
import type { CustomerType, HydrogenOrderResponse } from './types'; import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
import { reconcileHydrogenDaily } from './hydrogen-reconciliation';
function fmtMoney(value: number): string { function fmtMoney(value: number): string {
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
@@ -11,12 +12,14 @@ function fmtMoney(value: number): string {
export default function HydrogenOrders({ export default function HydrogenOrders({
date, date,
dailyRow,
vehicleScope, vehicleScope,
stationId, stationId,
customerName, customerName,
onClose, onClose,
}: { }: {
date: string; date: string;
dailyRow?: HydrogenDailyRow;
vehicleScope: CustomerType; vehicleScope: CustomerType;
stationId?: number; stationId?: number;
customerName?: string; customerName?: string;
@@ -52,6 +55,16 @@ export default function HydrogenOrders({
: stationId !== undefined : stationId !== undefined
? `站点 #${stationId}` ? `站点 #${stationId}`
: vehicleScope === 'external' ? '外部车辆' : '羚牛车辆'; : vehicleScope === 'external' ? '外部车辆' : '羚牛车辆';
const reconciliation = data && dailyRow
? reconcileHydrogenDaily(dailyRow, data, vehicleScope, stationId, customerName)
: null;
const grainDifferences = reconciliation
? [
!reconciliation.dateMatches && '日期',
!reconciliation.scopeMatches && '车辆范围',
!reconciliation.entityMatches && '站点/客户范围',
].filter(Boolean).join('、')
: '';
return ( return (
<section className="overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm"> <section className="overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm">
@@ -74,11 +87,32 @@ export default function HydrogenOrders({
</header> </header>
{data ? ( {data ? (
<div className="grid grid-cols-3 gap-2 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[10px] font-bold md:px-4"> <>
<div className="min-w-0"><span className="text-slate-400"> / </span><div className="mt-1 break-words text-slate-700">{data.recordCount} · {data.totalKg.toLocaleString('zh-CN')} Kg</div></div> <div className="grid grid-cols-3 gap-2 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[10px] font-bold md:px-4">
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-slate-700">{fmtMoney(data.totalCost)}</div></div> <div className="min-w-0"><span className="text-slate-400"> / </span><div className="mt-1 break-words text-slate-700">{data.recordCount} · {data.totalKg.toLocaleString('zh-CN')} Kg</div></div>
<div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-emerald-700">{fmtMoney(data.totalRevenue)}</div></div> <div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-slate-700">{fmtMoney(data.totalCost)}</div></div>
</div> <div className="min-w-0"><span className="text-slate-400"></span><div className="mt-1 break-all text-emerald-700">{fmtMoney(data.totalRevenue)}</div></div>
</div>
{reconciliation && (
<div
role={reconciliation.matches ? 'status' : 'alert'}
className={`flex items-start gap-2 border-b px-3 py-2 text-[10px] font-bold md:px-4 ${
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
? `口径核对通过:日汇总与 ${data.recordCount} 笔订单全量加氢量一致`
: `口径存在差异:加氢量 ${reconciliation.kgDifference >= 0 ? '+' : ''}${reconciliation.kgDifference.toFixed(2)} Kg${grainDifferences ? ` · ${grainDifferences}不一致` : ''}`}
</span>
</div>
)}
</>
) : null} ) : null}
{error ? ( {error ? (
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileHydrogenDaily } from './hydrogen-reconciliation.js';
import type { HydrogenDailyRow, HydrogenOrderResponse } from './types.js';
const daily: HydrogenDailyRow = {
date: '2026-07-01',
totalKg: 123.45,
chainPct: 0,
customerType: 'lingniu',
stations: [],
};
const orders: HydrogenOrderResponse = {
date: '2026-07-01',
vehicleScope: 'lingniu',
stationId: 18,
customerName: null,
page: 1,
limit: 20,
recordCount: 3,
totalPages: 1,
totalKg: 123.45,
totalCost: 1000,
totalRevenue: 1200,
items: [],
};
test('matches daily hydrogen totals at the same date, scope, and entity grain', () => {
assert.deepEqual(reconcileHydrogenDaily(daily, orders, 'lingniu', 18), {
matches: true,
dateMatches: true,
scopeMatches: true,
entityMatches: true,
kgDifference: 0,
});
});
test('reports amount and filter-grain differences independently', () => {
const result = reconcileHydrogenDaily(
daily,
{
...orders,
date: '2026-07-02',
vehicleScope: 'external',
stationId: null,
customerName: '武汉客户',
totalKg: 123.44,
},
'lingniu',
18,
);
assert.equal(result.matches, false);
assert.equal(result.dateMatches, false);
assert.equal(result.scopeMatches, false);
assert.equal(result.entityMatches, false);
assert.equal(result.kgDifference, -0.01);
});
test('matches an exact customer drill without requiring a station', () => {
const customerOrders = { ...orders, stationId: null, customerName: '武汉客户' };
assert.equal(
reconcileHydrogenDaily(daily, customerOrders, 'lingniu', undefined, '武汉客户').matches,
true,
);
});
@@ -0,0 +1,35 @@
import type { CustomerType, HydrogenDailyRow, HydrogenOrderResponse } from './types';
export interface HydrogenDailyReconciliation {
matches: boolean;
dateMatches: boolean;
scopeMatches: boolean;
entityMatches: boolean;
kgDifference: number;
}
function hundredths(value: number): number {
return Math.round(value * 100);
}
export function reconcileHydrogenDaily(
daily: HydrogenDailyRow,
orders: HydrogenOrderResponse,
expectedScope: CustomerType,
expectedStationId?: number,
expectedCustomerName?: string,
): HydrogenDailyReconciliation {
const dateMatches = daily.date === orders.date;
const scopeMatches = expectedScope === orders.vehicleScope;
const entityMatches = (expectedStationId ?? null) === orders.stationId
&& (expectedCustomerName ?? null) === orders.customerName;
const kgDifferenceInHundredths = hundredths(orders.totalKg) - hundredths(daily.totalKg);
return {
matches: dateMatches && scopeMatches && entityMatches && kgDifferenceInHundredths === 0,
dateMatches,
scopeMatches,
entityMatches,
kgDifference: kgDifferenceInHundredths / 100,
};
}