feat: reconcile hydrogen daily totals
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user