feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-08-20 13:59:03 +08:00
parent 37a9f303ec
commit 62efef0ab9
72 changed files with 36773 additions and 965 deletions
+130 -29
View File
@@ -15,7 +15,68 @@ export interface HydrogenOverviewDependencies {
cached: typeof cached;
}
// 氢能总览:KPI + Top5 + 区域占比。
type VehicleScope = 'all' | 'lingniu' | 'external';
type VerifyScope = 'all' | 'verified';
interface OverviewFilter {
cacheKey: string;
ledgerSql: string;
billSql: string;
params: number[];
stationId: number | null;
vehicleScope: VehicleScope;
verifyScope: VerifyScope;
}
function overviewFilter(
stationIdParam: string | undefined,
vehicleScopeParam: string | undefined,
verifyScopeParam: string | undefined,
): OverviewFilter {
const stationId = Number(stationIdParam);
const safeStationId = Number.isInteger(stationId) && stationId > 0 ? stationId : null;
const vehicleScope: VehicleScope = vehicleScopeParam === 'lingniu' || vehicleScopeParam === 'external'
? vehicleScopeParam
: 'all';
const verifyScope: VerifyScope = verifyScopeParam === 'verified' ? 'verified' : 'all';
const clauses: string[] = [];
const params: number[] = [];
if (safeStationId) {
clauses.push('station_id = ?');
params.push(safeStationId);
}
if (vehicleScope === 'lingniu') clauses.push('vehicle_id IS NOT NULL');
if (vehicleScope === 'external') clauses.push('vehicle_id IS NULL');
// Only use the ledger's persisted verification state; never infer verification
// from import source or payment status.
if (verifyScope === 'verified') {
clauses.push("LOWER(COALESCE(NULLIF(TRIM(verify_status), ''), 'unverified')) = 'verified'");
}
const suffix = clauses.length ? ` AND ${clauses.join(' AND ')}` : '';
const billSuffix = suffix
.replaceAll('station_id', 'b.station_id')
.replaceAll('vehicle_id', 'b.vehicle_id')
.replaceAll('verify_status', 'b.verify_status');
const cacheParts = [
safeStationId ? `station=${safeStationId}` : '',
vehicleScope !== 'all' ? `vehicle=${vehicleScope}` : '',
verifyScope !== 'all' ? `verify=${verifyScope}` : '',
].filter(Boolean);
return {
cacheKey: cacheParts.length ? `&${cacheParts.join('&')}` : '',
ledgerSql: suffix,
billSql: billSuffix,
params,
stationId: safeStationId,
vehicleScope,
verifyScope,
};
}
// 氢能总览:KPI + 趋势 + 站点/区域/客户汇总。筛选只使用账本中可验证的站点和车辆归属字段。
export function registerHydrogenOverviewRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenOverviewDependencies,
@@ -23,26 +84,35 @@ export function registerHydrogenOverviewRoute(
app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const filter = overviewFilter(c.req.query('stationId'), c.req.query('vehicleScope'), c.req.query('verifyScope'));
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
const data = await cached(`hydrogen/overview?year=${requestedYear}${filter.cacheKey}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
`SELECT YEAR(${HYDROGEN_LOCAL}) AS y,
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${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);
// The watermark is calculated with the active scope below. The year list
// remains global metadata so users can still select a year with no matching
// verified/vehicle/station records.
let latestLedgerTime: string | null = null;
const isCurrentYear = year === todayYear;
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)。
// yearProfit 是客户单毛利:仅以存在对客价/对客收入的订单成本抵扣对客收入。
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT
`SELECT DATE_FORMAT(MAX(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? THEN ${HYDROGEN_LOCAL} END), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
@@ -80,13 +150,14 @@ export function registerHydrogenOverviewRoute(
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`,
[year, 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],
HYDROGEN_MIN_DATE, ...filter.params],
);
const k = kpiRows[0] ?? {};
latestLedgerTime = typeof k.latestLedgerTime === 'string' ? k.latestLedgerTime : null;
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
@@ -128,15 +199,16 @@ export function registerHydrogenOverviewRoute(
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
rank: i + 1,
id: Number(r.id) || 0,
name: r.name as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
@@ -149,21 +221,28 @@ export function registerHydrogenOverviewRoute(
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
COALESCE(MAX(d.NAME), '未归属') AS province,
SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district d
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND d.STATUS = 'VALID'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
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 => ({
id: Number(r.id) || 0,
name: r.name as string,
province: r.province as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
@@ -174,6 +253,7 @@ export function registerHydrogenOverviewRoute(
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN NULLIF(TRIM(d.NAME), '') IS NOT NULL THEN d.NAME
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
@@ -181,18 +261,23 @@ export function registerHydrogenOverviewRoute(
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
ELSE '未归属区域'
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
-- 历史站点城市编码与行政区字典的排序规则不同,显式统一避免线上连接报错。
LEFT JOIN common_district d
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND d.STATUS = 'VALID'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
@@ -212,61 +297,77 @@ export function registerHydrogenOverviewRoute(
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(CASE WHEN vehicle_id IS NOT NULL THEN amount_kg ELSE 0 END), 2) AS lingniuKg,
ROUND(SUM(CASE WHEN vehicle_id IS NULL THEN amount_kg ELSE 0 END), 2) AS externalKg,
ROUND(SUM(cost_total), 2) AS fee,
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
const monthMap = new Map<string, { kg: number; lingniuKg: number; externalKg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
lingniuKg: Number(r.lingniuKg) || 0,
externalKg: Number(r.externalKg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
const monthly: { month: string; kg: number; lingniuKg: number; externalKg: 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, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
const v = monthMap.get(key) || { kg: 0, lingniuKg: 0, externalKg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, lingniuKg: v.lingniuKg, externalKg: v.externalKg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top(指定年份;按加氢量降序,前 30)
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
// 客户账单 Top(指定年份;按加氢量降序,前 30)。
// 同一客户可能同时存在客户承担与羚牛承担订单,必须显示为混合,不能按任一条记录覆盖。
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
ELSE 'customer' END AS payer,
CASE
WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0 THEN 1 ELSE 0 END) > 0 THEN 'mixed'
WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0 THEN 'customer'
ELSE 'lingniu'
END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
payer: (r.payer as string) === 'lingniu'
? 'lingniu' as const
: (r.payer as string) === 'mixed'
? 'mixed' 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 { kpi, top5, regions, monthly, customers, stations, availableYears, year, latestLedgerTime, filter: {
stationId: filter.stationId,
vehicleScope: filter.vehicleScope,
verifyScope: filter.verifyScope,
} };
}, { force });
return c.json(data);
});