refactor: define hydrogen BI metric semantics
This commit is contained in:
@@ -213,22 +213,22 @@ export default function HydrogenOverview() {
|
|||||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||||
iconBg="bg-blue-50"
|
iconBg="bg-blue-50"
|
||||||
accentClass="text-slate-800"
|
accentClass="text-slate-800"
|
||||||
label="累计加氢费"
|
label="累计加氢成本"
|
||||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||||
rows={[
|
rows={[
|
||||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
{ label: '公司单成本', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
{ label: '客户单成本', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||||
iconBg="bg-emerald-50"
|
iconBg="bg-emerald-50"
|
||||||
accentClass={profitColor}
|
accentClass={profitColor}
|
||||||
label="时享加氢获利"
|
label="客户单毛利"
|
||||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||||
rows={[
|
rows={[
|
||||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
{ label: '客户单成本', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
@@ -292,7 +292,7 @@ export default function HydrogenOverview() {
|
|||||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
<div className="text-[11px] font-black text-slate-400">客户单毛利率</div>
|
||||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
{profitYield.toFixed(1)}%
|
{profitYield.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +302,7 @@ export default function HydrogenOverview() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||||
时享获利 {yearProfitFmt.value}{yearProfitFmt.unit} · 客户收入 {yearRevenueFmt.value}{yearRevenueFmt.unit}
|
客户单毛利 {yearProfitFmt.value}{yearProfitFmt.unit} · 客户收入 {yearRevenueFmt.value}{yearRevenueFmt.unit}
|
||||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,21 @@ test('returns the published mileage metric contract', async () => {
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(payload.catalogVersion, 1);
|
assert.equal(payload.catalogVersion, 2);
|
||||||
assert.deepEqual(payload.domains, ['mileage']);
|
assert.deepEqual(payload.domains, ['mileage']);
|
||||||
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns the published hydrogen metric contract', async () => {
|
||||||
|
const response = await app.request('/metrics?domain=hydrogen');
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(payload.domains, ['hydrogen']);
|
||||||
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'hydrogen.customer_revenue'));
|
||||||
|
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'hydrogen.customer_order_gross_profit'));
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects unpublished metric domains', async () => {
|
test('rejects unpublished metric domains', async () => {
|
||||||
const response = await app.request('/metrics?domain=energy');
|
const response = await app.request('/metrics?domain=energy');
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,18 @@ test('lists mileage metrics without exposing the catalog array', () => {
|
|||||||
assert.ok(metrics.every(metric => metric.domain === 'mileage'));
|
assert.ok(metrics.every(metric => metric.domain === 'mileage'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('publishes hydrogen cost, revenue, and gross profit as separate metrics', () => {
|
||||||
|
const metrics = listMetricDefinitions('hydrogen');
|
||||||
|
const byId = new Map(metrics.map(metric => [metric.id, metric]));
|
||||||
|
|
||||||
|
assert.ok(metrics.length >= 8);
|
||||||
|
assert.equal(byId.get('hydrogen.refuel_cost')?.formula, "SUM(cost_total) WHERE del_flag = '0'");
|
||||||
|
assert.equal(byId.get('hydrogen.customer_revenue')?.formula, "SUM(fee_total) WHERE del_flag = '0'");
|
||||||
|
assert.equal(byId.get('hydrogen.customer_order_gross_profit')?.aggregation, 'derived');
|
||||||
|
});
|
||||||
|
|
||||||
test('validates only published metric domains', () => {
|
test('validates only published metric domains', () => {
|
||||||
assert.equal(isMetricDomain('mileage'), true);
|
assert.equal(isMetricDomain('mileage'), true);
|
||||||
|
assert.equal(isMetricDomain('hydrogen'), true);
|
||||||
assert.equal(isMetricDomain('unknown'), false);
|
assert.equal(isMetricDomain('unknown'), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type MetricDomain = 'mileage';
|
export type MetricDomain = 'mileage' | 'hydrogen';
|
||||||
export type MetricUnit = 'km' | 'percent' | 'vehicle' | 'timestamp';
|
export type MetricUnit = 'km' | 'kg' | 'cny' | 'percent' | 'vehicle' | 'station' | 'order' | 'timestamp';
|
||||||
export type MetricAggregation = 'sum' | 'weighted-ratio' | 'distinct-count' | 'latest';
|
export type MetricAggregation = 'sum' | 'count' | 'derived' | 'weighted-ratio' | 'distinct-count' | 'latest';
|
||||||
export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness';
|
export type MetricTimeSemantics = 'flow' | 'snapshot' | 'freshness';
|
||||||
|
|
||||||
export interface MetricDefinition {
|
export interface MetricDefinition {
|
||||||
@@ -14,10 +14,10 @@ export interface MetricDefinition {
|
|||||||
formula: string;
|
formula: string;
|
||||||
sources: readonly string[];
|
sources: readonly string[];
|
||||||
dimensions: readonly string[];
|
dimensions: readonly string[];
|
||||||
drillEntity: 'vehicle' | 'assessment-target';
|
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const METRIC_CATALOG_VERSION = 1;
|
export const METRIC_CATALOG_VERSION = 2;
|
||||||
|
|
||||||
const MILEAGE_METRICS: readonly MetricDefinition[] = [
|
const MILEAGE_METRICS: readonly MetricDefinition[] = [
|
||||||
{
|
{
|
||||||
@@ -87,7 +87,117 @@ const MILEAGE_METRICS: readonly MetricDefinition[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const METRIC_CATALOG: readonly MetricDefinition[] = MILEAGE_METRICS;
|
const HYDROGEN_METRICS: readonly MetricDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'hydrogen.refuel_mass',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢量',
|
||||||
|
description: '有效加氢账本订单的实际加注质量之和。',
|
||||||
|
unit: 'kg',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(amount_kg) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.refuel_cost',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢成本',
|
||||||
|
description: '有效加氢订单记录的站点结算成本之和,不代表客户应收。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(cost_total) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.customer_revenue',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '客户收入',
|
||||||
|
description: '有效加氢订单账本中客户应收金额 fee_total 之和。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(fee_total) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-customer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.customer_order_cost',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '客户单成本',
|
||||||
|
description: '存在客户单价或客户应收金额的订单,其站点结算成本之和。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'sum',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "SUM(cost_total) WHERE del_flag = '0' AND (customer_price > 0 OR fee_total > 0)",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-customer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.customer_order_gross_profit',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '客户单毛利',
|
||||||
|
description: '客户收入减去客户单对应的站点结算成本,不包含公司承担订单及其他经营费用。',
|
||||||
|
unit: 'cny',
|
||||||
|
aggregation: 'derived',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: 'hydrogen.customer_revenue - hydrogen.customer_order_cost',
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer'],
|
||||||
|
drillEntity: 'hydrogen-customer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.refuel_order_count',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢订单数',
|
||||||
|
description: '有效加氢账本订单数量。',
|
||||||
|
unit: 'order',
|
||||||
|
aggregation: 'count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(*) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer', 'plate'],
|
||||||
|
drillEntity: 'hydrogen-order',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.station_count',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '有加注站点数',
|
||||||
|
description: '所选时间和筛选范围内产生有效加氢订单的去重站点数量。',
|
||||||
|
unit: 'station',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(DISTINCT station_id) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger', 'hydrogen_station'],
|
||||||
|
dimensions: ['date', 'region', 'payer'],
|
||||||
|
drillEntity: 'hydrogen-station',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydrogen.vehicle_count',
|
||||||
|
domain: 'hydrogen',
|
||||||
|
label: '加氢车辆数',
|
||||||
|
description: '所选时间和筛选范围内产生有效加氢订单的去重车牌数量。',
|
||||||
|
unit: 'vehicle',
|
||||||
|
aggregation: 'distinct-count',
|
||||||
|
timeSemantics: 'flow',
|
||||||
|
formula: "COUNT(DISTINCT plate_number) WHERE del_flag = '0'",
|
||||||
|
sources: ['hydrogen_fuel_ledger'],
|
||||||
|
dimensions: ['date', 'station', 'region', 'customer', 'payer'],
|
||||||
|
drillEntity: 'vehicle',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const METRIC_CATALOG: readonly MetricDefinition[] = [
|
||||||
|
...MILEAGE_METRICS,
|
||||||
|
...HYDROGEN_METRICS,
|
||||||
|
];
|
||||||
|
|
||||||
export function isMetricDomain(value: string): value is MetricDomain {
|
export function isMetricDomain(value: string): value is MetricDomain {
|
||||||
return METRIC_CATALOG.some(metric => metric.domain === value);
|
return METRIC_CATALOG.some(metric => metric.domain === value);
|
||||||
|
|||||||
Reference in New Issue
Block a user