Files
ln-bi/src/modules/energy/hydrogen-daily/model.ts
T
kkfluous 62efef0ab9
ci/woodpecker/push/woodpecker Pipeline was successful
feat(energy): rebuild hydrogen BI board and drill-through
2026-08-20 13:59:03 +08:00

209 lines
7.2 KiB
TypeScript

import type { HydrogenDailyRow } from '../types';
export {
formatYmd,
getQuickRange,
getRangeModeLabel,
normalizeRange,
QUICK_PICK_OPTIONS,
type RangeMode,
} from '../daily-range/model';
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
const source = rows ?? [];
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
const totalFee = source.reduce((total, row) => total + row.totalFee, 0);
const activeDays = source.filter(row => row.totalKg > 0).length;
const stationIds = new Set<number>();
source.forEach(row => row.stations.forEach(station => stationIds.add(station.id)));
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
null,
);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>(
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
null,
);
return {
trendData,
totalKg,
totalFee,
activeDays,
stationCount: stationIds.size,
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
avgPrice: totalKg > 0 ? totalFee / totalKg : 0,
peakDay,
lowDay,
zeroDays: source.filter(row => row.totalKg === 0).length,
};
}
export interface HydrogenDailyStationOption {
id: number;
name: string;
totalKg: number;
totalFee: number;
}
export type HydrogenDailyVehicleScope = 'all' | 'lingniu' | 'external';
export type HydrogenDailyBoardScope = 'global' | 'station';
export interface HydrogenDailyTrendPoint {
date: string;
totalKg: number;
lingniuKg: number;
externalKg: number;
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/**
* “全部车辆”由羚牛与外部车辆两次真实查询合并得到。合并只累加账本
* 已返回的加氢量和成本,不引入原型里的演示数据。
*/
export function mergeHydrogenDailyRows(
lingniuRows: HydrogenDailyRow[] | null,
externalRows: HydrogenDailyRow[] | null,
): HydrogenDailyRow[] | null {
if (lingniuRows === null || externalRows === null) return null;
const rowsByDate = new Map<string, HydrogenDailyRow[]>();
for (const row of [...lingniuRows, ...externalRows]) {
const rows = rowsByDate.get(row.date) ?? [];
rows.push(row);
rowsByDate.set(row.date, rows);
}
const merged = [...rowsByDate.entries()].map(([date, rows]) => {
const stationsById = new Map<number, HydrogenDailyRow['stations'][number]>();
for (const row of rows) {
for (const station of row.stations) {
const current = stationsById.get(station.id);
const kg = round2((current?.kg ?? 0) + station.kg);
const fee = round2((current?.fee ?? 0) + station.fee);
stationsById.set(station.id, {
id: station.id,
name: station.name || current?.name || `站点 #${station.id}`,
kg,
fee,
// 跨车辆归属聚合后展示实际成本加权均价。
pricePerKg: kg > 0 ? round2(fee / kg) : 0,
chainPct: 0,
});
}
}
const stations = [...stationsById.values()].sort((left, right) => right.kg - left.kg);
return {
date,
totalKg: round2(stations.reduce((sum, station) => sum + station.kg, 0)),
totalFee: round2(stations.reduce((sum, station) => sum + station.fee, 0)),
chainPct: 0,
customerType: 'lingniu' as const,
stations,
};
});
return recomputeHydrogenDailyChains(merged);
}
function recomputeHydrogenDailyChains(rows: HydrogenDailyRow[]): HydrogenDailyRow[] {
const ascending = [...rows]
.map(row => ({ ...row, stations: row.stations.map(station => ({ ...station })) }))
.sort((left, right) => left.date.localeCompare(right.date));
let previousTotalKg = 0;
const stationPreviousKg = new Map<number, number>();
for (const row of ascending) {
row.chainPct = previousTotalKg > 0 ? (row.totalKg - previousTotalKg) / previousTotalKg : 0;
previousTotalKg = row.totalKg;
for (const station of row.stations) {
const previousStationKg = stationPreviousKg.get(station.id) ?? 0;
station.chainPct = previousStationKg > 0 ? (station.kg - previousStationKg) / previousStationKg : 0;
stationPreviousKg.set(station.id, station.kg);
}
}
return ascending.sort((left, right) => right.date.localeCompare(left.date));
}
/** Build one stable station selector from the current date range. */
export function getHydrogenDailyStations(rows: HydrogenDailyRow[] | null): HydrogenDailyStationOption[] {
const stations = new Map<number, HydrogenDailyStationOption>();
for (const row of rows ?? []) {
for (const station of row.stations) {
const current = stations.get(station.id);
stations.set(station.id, {
id: station.id,
name: station.name,
totalKg: (current?.totalKg ?? 0) + station.kg,
totalFee: (current?.totalFee ?? 0) + station.fee,
});
}
}
return [...stations.values()].sort((left, right) => right.totalKg - left.totalKg || left.name.localeCompare(right.name));
}
/**
* Daily endpoint returns all stations in the selected date range. This keeps
* station drill-down local and recomputes each day's totals and chain change.
*/
export function filterHydrogenRowsByStation(
rows: HydrogenDailyRow[] | null,
stationId: number | null,
): HydrogenDailyRow[] | null {
if (rows === null || stationId === null) return rows;
const filtered = rows.map(row => {
const stations = row.stations.filter(station => station.id === stationId);
return {
...row,
totalKg: stations.reduce((sum, station) => sum + station.kg, 0),
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
stations,
};
});
return recomputeHydrogenDailyChains(filtered);
}
/** Build the prototype's blue/orange stacked daily series from real queries. */
export function buildHydrogenDailyTrend(
lingniuRows: HydrogenDailyRow[] | null,
externalRows: HydrogenDailyRow[] | null,
vehicleScope: HydrogenDailyVehicleScope,
stationId: number | null,
): HydrogenDailyTrendPoint[] {
if (lingniuRows === null || externalRows === null) return [];
const lingniu = filterHydrogenRowsByStation(lingniuRows, stationId) ?? [];
const external = filterHydrogenRowsByStation(externalRows, stationId) ?? [];
const byDate = new Map<string, HydrogenDailyTrendPoint>();
for (const row of lingniu) {
byDate.set(row.date, {
date: row.date,
lingniuKg: vehicleScope === 'external' ? 0 : row.totalKg,
externalKg: 0,
totalKg: vehicleScope === 'external' ? 0 : row.totalKg,
});
}
for (const row of external) {
const current = byDate.get(row.date) ?? {
date: row.date,
lingniuKg: 0,
externalKg: 0,
totalKg: 0,
};
const externalKg = vehicleScope === 'lingniu' ? 0 : row.totalKg;
current.externalKg = externalKg;
current.totalKg = round2(current.lingniuKg + externalKg);
byDate.set(row.date, current);
}
return [...byDate.values()].sort((left, right) => left.date.localeCompare(right.date));
}