feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,4 +1,48 @@
|
||||
import type { HydrogenOverviewResponse } from '../api';
|
||||
import type {
|
||||
HydrogenCustomerRow,
|
||||
HydrogenKpi,
|
||||
HydrogenMonthlyPoint,
|
||||
HydrogenRegionShare,
|
||||
HydrogenStationFull,
|
||||
} from '../types';
|
||||
|
||||
export type OverviewScope = 'global' | 'station';
|
||||
export type OverviewMetricKey = 'yearKg' | 'yearFee' | 'yearProfit' | 'monthKg' | 'todayKg';
|
||||
export type OverviewDrillKind = 'metric' | 'month' | 'station' | 'customer' | 'region';
|
||||
|
||||
export interface OverviewDrillRequest {
|
||||
kind: OverviewDrillKind;
|
||||
key: string;
|
||||
label: string;
|
||||
entityId?: number;
|
||||
}
|
||||
|
||||
export interface OverviewDrillMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'default' | 'blue' | 'green' | 'amber' | 'red';
|
||||
}
|
||||
|
||||
export interface OverviewDrillColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface OverviewDrillPayload {
|
||||
kind: OverviewDrillKind;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
metrics: OverviewDrillMetric[];
|
||||
columns: OverviewDrillColumn[];
|
||||
rows: Record<string, string | number>[];
|
||||
emptyMessage?: string;
|
||||
primaryActionLabel?: string;
|
||||
groupBy?: 'station' | 'customer' | 'vehicle';
|
||||
groupByOptions?: Array<'station' | 'customer' | 'vehicle'>;
|
||||
rowActionLabel?: string;
|
||||
}
|
||||
|
||||
export function formatKg(kg: number): { value: string; unit: string } {
|
||||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||||
@@ -22,6 +66,185 @@ export function formatYuan(yuan: number): { value: string; unit: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function formatKgText(value: number): string {
|
||||
const formatted = formatKg(value);
|
||||
return `${formatted.value} ${formatted.unit}`;
|
||||
}
|
||||
|
||||
function formatYuanText(value: number): string {
|
||||
const formatted = formatYuan(value);
|
||||
return `¥${formatted.value} ${formatted.unit}`;
|
||||
}
|
||||
|
||||
const METRIC_LABELS: Record<OverviewMetricKey, string> = {
|
||||
yearKg: '累计加氢量',
|
||||
yearFee: '累计加氢费',
|
||||
yearProfit: '客户单毛利',
|
||||
monthKg: '本月加氢',
|
||||
todayKg: '本日加氢',
|
||||
};
|
||||
|
||||
/**
|
||||
* 总览接口只返回聚合值。弹层先完整呈现可核验汇总,订单树由父页面在接入
|
||||
* 明细接口后通过 onDrillRequest 承接,避免用推算值冒充真实订单。
|
||||
*/
|
||||
export function buildMetricDrillPayload(kpi: HydrogenKpi, key: OverviewMetricKey): OverviewDrillPayload {
|
||||
const customerCost = Math.max(0, kpi.yearFee - kpi.ourYearFee);
|
||||
const common = {
|
||||
kind: 'metric' as const,
|
||||
title: METRIC_LABELS[key],
|
||||
subtitle: '汇总口径穿透 · 当前筛选范围',
|
||||
columns: [],
|
||||
rows: [],
|
||||
emptyMessage: '当前总览接口未返回站点、客户、车牌及单笔订单层级;汇总值已按真实账本展示。',
|
||||
};
|
||||
|
||||
if (key === 'yearKg') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '累计加氢量', value: formatKgText(kpi.yearKg), tone: 'blue' },
|
||||
{ label: '我司车辆', value: formatKgText(kpi.ourYearKg) },
|
||||
{ label: '客户车辆', value: formatKgText(kpi.customerYearKg) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (key === 'yearFee') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '累计加氢费', value: formatYuanText(kpi.yearFee), tone: 'blue' },
|
||||
{ label: '我司承担', value: formatYuanText(kpi.ourYearFee) },
|
||||
{ label: '客户承担', value: formatYuanText(customerCost) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (key === 'yearProfit') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '客户单毛利', value: formatYuanText(kpi.yearProfit), tone: kpi.yearProfit >= 0 ? 'green' : 'red' },
|
||||
{ label: '客户收入', value: formatYuanText(kpi.yearRevenue) },
|
||||
{ label: '客户成本', value: formatYuanText(customerCost) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (key === 'monthKg') {
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '本月加氢量', value: formatKgText(kpi.monthKg), tone: 'amber' },
|
||||
{ label: '本月加氢费', value: formatYuanText(kpi.monthFee) },
|
||||
{ label: '占年比', value: `${kpi.yearKg > 0 ? (kpi.monthKg / kpi.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
metrics: [
|
||||
{ label: '本日加氢量', value: formatKgText(kpi.todayKg), tone: 'blue' },
|
||||
{ label: '本日加氢费', value: formatYuanText(kpi.todayFee) },
|
||||
{ label: '占月比', value: `${kpi.monthKg > 0 ? (kpi.todayKg / kpi.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMonthDrillPayload(month: HydrogenMonthlyPoint): OverviewDrillPayload {
|
||||
const lingniuKg = month.lingniuKg ?? 0;
|
||||
const externalKg = month.externalKg ?? Math.max(0, month.kg - lingniuKg);
|
||||
return {
|
||||
kind: 'month',
|
||||
title: `${month.month} 月度经营明细`,
|
||||
subtitle: '月度聚合 · 当前车辆范围',
|
||||
metrics: [
|
||||
{ label: '加氢总量', value: formatKgText(month.kg), tone: 'blue' },
|
||||
{ label: '成本支出', value: formatYuanText(month.fee), tone: 'amber' },
|
||||
{ label: '客户收入', value: formatYuanText(month.revenue), tone: 'green' },
|
||||
{ label: '客户单毛利', value: formatYuanText(month.profit), tone: month.profit >= 0 ? 'green' : 'red' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'category', label: '车辆范围' },
|
||||
{ key: 'kg', label: '加氢量', align: 'right' },
|
||||
{ key: 'share', label: '占比', align: 'right' },
|
||||
],
|
||||
rows: [
|
||||
{ category: '羚牛车辆', kg: formatKgText(lingniuKg), share: `${month.kg > 0 ? (lingniuKg / month.kg * 100).toFixed(1) : '0.0'}%` },
|
||||
{ category: '外部车辆', kg: formatKgText(externalKg), share: `${month.kg > 0 ? (externalKg / month.kg * 100).toFixed(1) : '0.0'}%` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStationDrillPayload(station: HydrogenStationFull): OverviewDrillPayload {
|
||||
return {
|
||||
kind: 'station',
|
||||
title: `「${station.name}」加氢汇总明细`,
|
||||
subtitle: `${station.province || '未归属省份'} · 当前统计周期`,
|
||||
metrics: [
|
||||
{ label: '加氢量', value: formatKgText(station.kg), tone: 'blue' },
|
||||
{ label: '氢费收入', value: formatYuanText(station.revenue), tone: 'green' },
|
||||
{ label: '加氢量占比', value: `${(station.share * 100).toFixed(1)}%` },
|
||||
{ label: '收入占比', value: `${(station.revenueShare * 100).toFixed(1)}%` },
|
||||
],
|
||||
columns: [],
|
||||
rows: [],
|
||||
emptyMessage: '当前总览接口未返回该站按日、客户及车辆流水;可切换到单站视图继续查看真实数据。',
|
||||
primaryActionLabel: '进入单站视图',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomerDrillPayload(customer: HydrogenCustomerRow): OverviewDrillPayload {
|
||||
const payerLabel = customer.payer === 'lingniu' ? '羚牛承担' : customer.payer === 'mixed' ? '混合承担' : '客户承担';
|
||||
return {
|
||||
kind: 'customer',
|
||||
title: `「${customer.name}」客户账单明细`,
|
||||
subtitle: `${payerLabel} · 当前统计周期`,
|
||||
metrics: [
|
||||
{ label: '加氢量', value: formatKgText(customer.kg), tone: 'blue' },
|
||||
{ label: '成本支出', value: formatYuanText(customer.cost), tone: 'amber' },
|
||||
{ label: '应收', value: formatYuanText(customer.revenue), tone: 'green' },
|
||||
{ label: '价差', value: formatYuanText(customer.revenue - customer.cost), tone: customer.revenue - customer.cost >= 0 ? 'green' : 'red' },
|
||||
],
|
||||
columns: [],
|
||||
rows: [],
|
||||
emptyMessage: '当前总览接口未返回该客户按日、车牌及单笔加氢流水。',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRegionDrillPayload(
|
||||
region: HydrogenRegionShare,
|
||||
stations: HydrogenStationFull[],
|
||||
granularity: 'province' | 'city',
|
||||
): OverviewDrillPayload {
|
||||
const matchedStations = granularity === 'province'
|
||||
? stations.filter(station => (station.province?.trim() || '未归属') === region.region)
|
||||
: [];
|
||||
return {
|
||||
kind: 'region',
|
||||
title: `${region.region}加氢区域明细`,
|
||||
subtitle: `${granularity === 'province' ? '省级' : '市级'}口径 · 当前统计周期`,
|
||||
metrics: [
|
||||
{ label: '区域加氢量', value: formatKgText(region.kg), tone: 'blue' },
|
||||
{ label: '全局占比', value: `${(region.share * 100).toFixed(1)}%` },
|
||||
{ label: '已关联站点', value: `${matchedStations.length} 站` },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'station', label: '加氢站' },
|
||||
{ key: 'province', label: '所属省份' },
|
||||
{ key: 'kg', label: '加氢量', align: 'right' },
|
||||
{ key: 'share', label: '区域内占比', align: 'right' },
|
||||
],
|
||||
rows: matchedStations.map(station => ({
|
||||
station: station.name,
|
||||
province: station.province || '未归属',
|
||||
kg: formatKgText(station.kg),
|
||||
share: `${region.kg > 0 ? (station.kg / region.kg * 100).toFixed(1) : '0.0'}%`,
|
||||
})),
|
||||
emptyMessage: granularity === 'city'
|
||||
? '当前站点汇总接口未返回城市字段,无法将市级汇总继续关联到具体站点。'
|
||||
: '当前区域暂无可关联站点。',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRelative(timestamp: number, now = Date.now()): string {
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 5) return '刚刚';
|
||||
@@ -65,7 +288,7 @@ export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
|
||||
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
|
||||
: null,
|
||||
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
|
||||
profitYield: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
|
||||
customerGrossMarginPct: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
|
||||
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
|
||||
monthlyDual: monthly.map(month => ({
|
||||
...month,
|
||||
|
||||
Reference in New Issue
Block a user