feat(energy): 氢能总览补全维度(5KPI+收支+客户/加氢站全量+年份切换)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

按 BI 页面 (https://bi.lnh2e.com/lingniu/decision/link/0iqP) 完整还原:
- 5 张 KPI:累计加氢量 / 累计加氢费 / 时享加氢获利 / 本月加氢 / 本日加氢
- 月度收支对比柱图:成本支出 vs 客户收入双柱
- 加氢站加氢汇总(全量 55 站):加氢量+占比+氢费收入+收入占比,进度条
- 客户账单 Top 30:承担方 / 加氢量 / 成本支出 / 应收
- 年份切换(2025/2026),全量数据按选定年份重算
- 关键修正:用 cost_type 区分客户单/我司单(cost_type=2 客户单,cost_type=3 我司单),获利口径与 BI 对齐

后端 /hydrogen/overview 重写:
- 增加 customers/stations/availableYears/year 字段
- KPI 含 yearProfit/monthProfit/todayProfit
- monthly 含 fee/revenue/profit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-30 16:43:05 +08:00
parent ad8ec50038
commit 6ad4b5e2a4
4 changed files with 394 additions and 64 deletions

View File

@@ -60,52 +60,99 @@ function enumerateDates(range: Range): string[] {
// 氢能 总览KPI + Top5 + 区域占比
// =========================================================
app.get('/hydrogen/overview', async (c) => {
const data = await cached('hydrogen/overview', async () => {
// KPI年/月/日 + 我方/客户分解 + 累计羚牛承担)
const yearParam = c.req.query('year');
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await pool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
const isCurrentYear = year === todayYear;
// KPI按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN hydrogen_quantity ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE())
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_expense ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NOT NULL
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
THEN cost_expense ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN customer_expense ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
THEN hydrogen_quantity ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NOT NULL
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 3
THEN cost_expense ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = YEAR(CURDATE()) AND truck_id IS NULL
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND cost_type = 2
THEN hydrogen_quantity ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN hydrogen_quantity ELSE 0 END) AS monthKg,
SUM(CASE WHEN DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_expense ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') AND cost_type = 2
THEN cost_expense ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN customer_expense ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN hydrogen_quantity ELSE 0 END) AS todayKg,
SUM(CASE WHEN DATE(${HYDROGEN_LOCAL}) = CURDATE()
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_expense ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE() AND cost_type = 2
THEN cost_expense ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN customer_expense ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN truck_id IS NOT NULL
THEN hydrogen_quantity ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN truck_id IS NOT NULL
THEN cost_expense ELSE 0 END) AS lingniuBornFee
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0 AND hydrogen_time >= ?`,
[HYDROGEN_MIN_DATE],
WHERE is_deleted = 0 AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
HYDROGEN_MIN_DATE],
);
const k = kpiRows[0] ?? {};
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
const monthFee = Number(k.monthFee) || 0;
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
const monthRevenue = Number(k.monthRevenue) || 0;
const todayFee = Number(k.todayFee) || 0;
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
const todayRevenue = Number(k.todayRevenue) || 0;
const kpi = {
yearKg: Number(k.yearKg) || 0,
yearFee: Number(k.yearFee) || 0,
yearFee,
yearRevenue,
yearProfit: yearRevenue - yearCustomerCost,
ourYearKg: Number(k.ourYearKg) || 0,
ourYearFee: Number(k.ourYearFee) || 0,
customerYearKg: Number(k.customerYearKg) || 0,
monthKg: Number(k.monthKg) || 0,
monthFee: Number(k.monthFee) || 0,
monthFee,
monthRevenue,
monthProfit: monthRevenue - monthCustomerCost,
todayKg: Number(k.todayKg) || 0,
todayFee: Number(k.todayFee) || 0,
todayFee,
todayRevenue,
todayProfit: todayRevenue - todayCustomerCost,
lingniuBornKg: Number(k.lingniuBornKg) || 0,
lingniuBornFee: Number(k.lingniuBornFee) || 0,
};
// Top5 加氢站(本年
// Top5 加氢站(指定年份
const [top5Rows] = await pool.query<RowDataPacket[]>(
`SELECT b.hydrogen_station_id AS id,
COALESCE(MAX(s.short_name), MAX(s.name),
@@ -120,12 +167,12 @@ app.get('/hydrogen/overview', async (c) => {
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
WHERE b.is_deleted = 0
AND b.hydrogen_time >= ?
AND YEAR(b.hydrogen_time) = YEAR(CURDATE())
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.hydrogen_station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE],
[HYDROGEN_MIN_DATE, year],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
@@ -136,7 +183,38 @@ app.get('/hydrogen/overview', async (c) => {
share: (Number(r.kg) || 0) / top5KgSum,
}));
// 区域占比(按城市,本年)— 取前 8其余合并为"其他"
// 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await pool.query<RowDataPacket[]>(
`SELECT b.hydrogen_station_id AS id,
COALESCE(MAX(s.short_name), MAX(s.name),
MAX(os.fixed_station_name), MAX(os.station_name),
MAX(i.hydrogen_station_name),
CASE WHEN b.hydrogen_station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.hydrogen_station_id) END) AS name,
SUM(b.hydrogen_quantity) AS kg,
SUM(b.customer_expense) AS revenue
FROM tab_energy_hydrogen_bill b
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
LEFT JOIN tab_import_hydrogen_order i ON i.bill_code = b.bill_code
WHERE b.is_deleted = 0
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.hydrogen_station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
name: r.name as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
}));
// 区域占比(按城市,指定年份)— 取前 8其余合并为"其他"
const [regionRows] = await pool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT REPLACE(REPLACE(SUBSTRING_INDEX(COALESCE(s.city, os.city, '未知'), '-', -1), '市', ''), '省', '') AS region,
@@ -145,12 +223,12 @@ app.get('/hydrogen/overview', async (c) => {
LEFT JOIN tab_hydrogen_site s ON s.id = b.hydrogen_station_id
LEFT JOIN tab_outside_hydrogen_site os ON os.inner_site_id = b.hydrogen_station_id
WHERE b.is_deleted = 0
AND b.hydrogen_time >= ?
AND YEAR(b.hydrogen_time) = YEAR(CURDATE())
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE],
[HYDROGEN_MIN_DATE, year],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
@@ -165,33 +243,66 @@ app.get('/hydrogen/overview', async (c) => {
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
];
// 月度趋势(本年内 12 个月,缺失月补 0
// 月度趋势(指定年份内 12 个月,缺失月补 0含成本/收入/利润
// 利润 = 客户单收入 - 客户单成本(仅 cost_type = 2
const [monthRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(hydrogen_time, '%Y-%m') AS m,
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(hydrogen_quantity), 2) AS kg,
ROUND(SUM(cost_expense), 2) AS fee
ROUND(SUM(cost_expense), 2) AS fee,
ROUND(SUM(CASE WHEN cost_type = 2 THEN cost_expense ELSE 0 END), 2) AS customerCost,
ROUND(SUM(customer_expense), 2) AS revenue
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0
AND hydrogen_time >= ?
AND YEAR(hydrogen_time) = YEAR(CURDATE())
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE],
[HYDROGEN_MIN_DATE, year],
);
const monthMap = new Map<string, { kg: number; fee: number }>();
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, { kg: Number(r.kg) || 0, fee: Number(r.fee) || 0 });
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const year = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const monthly: { month: string; kg: number; fee: number }[] = [];
for (let mi = 1; mi <= currentMonth; mi++) {
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, fee: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee });
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
return { kpi, top5, regions, monthly };
// 客户账单 Top指定年份按加氢量降序前 30
// payercost_type=2 → 客户承担cost_type=3 → 羚牛承担;其他 → 客户(默认)
const [customerRows] = await pool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(cost_type) = 3 AND MIN(cost_type) = 3 THEN 'lingniu'
ELSE 'customer' END AS payer,
SUM(hydrogen_quantity) AS kg,
SUM(cost_expense) AS cost,
SUM(customer_expense) AS revenue
FROM tab_energy_hydrogen_bill
WHERE is_deleted = 0
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
});
return c.json(data);
});