feat: reconcile hydrogen daily totals
This commit is contained in:
@@ -58,6 +58,7 @@ export default function HydrogenDaily() {
|
||||
? drillContext.customerName
|
||||
: undefined;
|
||||
const selectedDate = drillContext.selectedDate;
|
||||
const selectedDailyRow = rows?.find(row => row.date === selectedDate);
|
||||
|
||||
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
|
||||
const url = buildHydrogenDrillUrl(window.location, next);
|
||||
@@ -386,6 +387,7 @@ export default function HydrogenDaily() {
|
||||
{selectedDate && (
|
||||
<HydrogenOrders
|
||||
date={selectedDate}
|
||||
dailyRow={selectedDailyRow}
|
||||
vehicleScope={vehicleScope}
|
||||
stationId={selectedStationId}
|
||||
customerName={selectedCustomerName}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { EmptyState, ErrorState, LoadingState } from '../../components/ui/surface';
|
||||
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 {
|
||||
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
@@ -11,12 +12,14 @@ function fmtMoney(value: number): string {
|
||||
|
||||
export default function HydrogenOrders({
|
||||
date,
|
||||
dailyRow,
|
||||
vehicleScope,
|
||||
stationId,
|
||||
customerName,
|
||||
onClose,
|
||||
}: {
|
||||
date: string;
|
||||
dailyRow?: HydrogenDailyRow;
|
||||
vehicleScope: CustomerType;
|
||||
stationId?: number;
|
||||
customerName?: string;
|
||||
@@ -52,6 +55,16 @@ export default function HydrogenOrders({
|
||||
: stationId !== undefined
|
||||
? `站点 #${stationId}`
|
||||
: 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 (
|
||||
<section className="overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm">
|
||||
@@ -74,11 +87,32 @@ export default function HydrogenOrders({
|
||||
</header>
|
||||
|
||||
{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="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-all text-emerald-700">{fmtMoney(data.totalRevenue)}</div></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-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-slate-700">{fmtMoney(data.totalCost)}</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}
|
||||
|
||||
{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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user