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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user