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:
@@ -0,0 +1,263 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type { cached } from './cache.js';
|
||||
import { HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
|
||||
import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js';
|
||||
|
||||
export interface HydrogenStationBoardDependencies {
|
||||
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
||||
cached: typeof cached;
|
||||
}
|
||||
|
||||
function safeStationId(value: string | undefined): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
// 单站经营看板只做只读聚合。站点列表保留区间内零业务站点,便于核对站点覆盖范围。
|
||||
export function registerHydrogenStationBoardRoute(
|
||||
app: Hono,
|
||||
{ hydrogenPool, cached }: HydrogenStationBoardDependencies,
|
||||
) {
|
||||
app.get('/hydrogen/station-board', async (c) => {
|
||||
const range = resolveDateRange(
|
||||
'last15',
|
||||
c.req.query('startDate'),
|
||||
c.req.query('endDate'),
|
||||
);
|
||||
const stationId = safeStationId(c.req.query('stationId'));
|
||||
const force = c.req.query('force') === '1';
|
||||
const cacheKey = `hydrogen/station-board?start=${range.start}&end=${range.end}${stationId ? `&station=${stationId}` : ''}`;
|
||||
|
||||
const data = await cached(cacheKey, async () => {
|
||||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT s.id,
|
||||
COALESCE(NULLIF(s.station_short_name, ''), NULLIF(s.station_name, ''), CONCAT('未知站点 #', s.id)) AS name,
|
||||
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
|
||||
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 2) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS fee,
|
||||
COUNT(b.id) AS recordCount,
|
||||
DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
|
||||
FROM hydrogen_station s
|
||||
LEFT JOIN ${HYDROGEN_TABLE} b
|
||||
ON b.station_id = s.id
|
||||
AND ${HYDROGEN_BASE_WHERE_B}
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
LEFT JOIN common_district dp
|
||||
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dp.STATUS = 'VALID'
|
||||
LEFT JOIN common_district dc
|
||||
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dc.STATUS = 'VALID'
|
||||
WHERE s.del_flag = '0'
|
||||
GROUP BY s.id, s.station_short_name, s.station_name, dp.NAME, s.province, dc.NAME, s.city
|
||||
ORDER BY kg DESC, name ASC`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
|
||||
const [paymentRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT station_id AS stationId,
|
||||
ROUND(SUM(amount), 2) AS amount,
|
||||
COUNT(*) AS paymentCount,
|
||||
DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
|
||||
FROM hydrogen_station_payment
|
||||
WHERE del_flag = '0'
|
||||
AND payment_date >= ?
|
||||
AND payment_date <= ?
|
||||
GROUP BY station_id`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
const [summaryDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(b.cost_total), 2) AS fee,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
const [stationDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS stationId,
|
||||
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY stationId ASC, date ASC`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
const [summaryPaymentDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(amount), 2) AS amount,
|
||||
COUNT(*) AS paymentCount
|
||||
FROM hydrogen_station_payment
|
||||
WHERE del_flag = '0'
|
||||
AND payment_date >= ?
|
||||
AND payment_date <= ?
|
||||
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
const paymentByStation = new Map(paymentRows.map(row => [numberValue(row.stationId), row]));
|
||||
const dailyKgByStation = new Map<number, Map<string, number>>();
|
||||
for (const row of stationDailyRows) {
|
||||
const id = numberValue(row.stationId);
|
||||
const values = dailyKgByStation.get(id) ?? new Map<string, number>();
|
||||
values.set(String(row.date), numberValue(row.kg));
|
||||
dailyKgByStation.set(id, values);
|
||||
}
|
||||
const totalKg = stationRows.reduce((sum, row) => sum + numberValue(row.kg), 0);
|
||||
const stations = stationRows.map(row => {
|
||||
const id = numberValue(row.id);
|
||||
const payment = paymentByStation.get(id);
|
||||
return {
|
||||
id,
|
||||
name: String(row.name),
|
||||
province: String(row.province),
|
||||
city: String(row.city),
|
||||
kg: numberValue(row.kg),
|
||||
fee: numberValue(row.fee),
|
||||
recordCount: numberValue(row.recordCount),
|
||||
paymentAmount: numberValue(payment?.amount),
|
||||
paymentCount: numberValue(payment?.paymentCount),
|
||||
share: totalKg > 0 ? numberValue(row.kg) / totalKg : 0,
|
||||
latestLedgerTime: typeof row.latestLedgerTime === 'string' ? row.latestLedgerTime : null,
|
||||
latestPaymentDate: typeof payment?.latestPaymentDate === 'string' ? payment.latestPaymentDate : null,
|
||||
dailyKg: enumerateDateRange(range.start, range.end).map(date => ({
|
||||
date,
|
||||
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
let selected = null;
|
||||
if (stationId) {
|
||||
const [dailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(b.cost_total), 2) AS fee,
|
||||
ROUND(AVG(NULLIF(b.cost_price, 0)), 2) AS avgPrice,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.station_id = ?
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[stationId, range.start, range.end],
|
||||
);
|
||||
const [dailyPaymentRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(amount), 2) AS amount,
|
||||
COUNT(*) AS paymentCount
|
||||
FROM hydrogen_station_payment
|
||||
WHERE del_flag = '0'
|
||||
AND station_id = ?
|
||||
AND payment_date >= ?
|
||||
AND payment_date <= ?
|
||||
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[stationId, range.start, range.end],
|
||||
);
|
||||
const paymentByDate = new Map(dailyPaymentRows.map(row => [String(row.date), row]));
|
||||
const ledgerByDate = new Map(dailyRows.map(row => [String(row.date), row]));
|
||||
let previousKg = 0;
|
||||
const daily = enumerateDateRange(range.start, range.end).map(date => {
|
||||
const ledger = ledgerByDate.get(date);
|
||||
const kg = numberValue(ledger?.kg);
|
||||
const payment = paymentByDate.get(date);
|
||||
const result = {
|
||||
date,
|
||||
kg,
|
||||
fee: numberValue(ledger?.fee),
|
||||
avgPrice: numberValue(ledger?.avgPrice),
|
||||
recordCount: numberValue(ledger?.recordCount),
|
||||
changeKg: kg - previousKg,
|
||||
paymentAmount: numberValue(payment?.amount),
|
||||
paymentCount: numberValue(payment?.paymentCount),
|
||||
};
|
||||
previousKg = kg;
|
||||
return result;
|
||||
});
|
||||
|
||||
const [customerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||
COALESCE(NULLIF(MAX(b.system_customer_name), ''), NULLIF(MAX(b.customer_name), ''), '未关联客户') AS customerName,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(b.cost_total), 2) AS fee,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.station_id = ?
|
||||
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
|
||||
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), COALESCE(b.system_customer_id, b.customer_id, 0)
|
||||
ORDER BY month ASC, kg DESC`,
|
||||
[stationId, range.end, range.end],
|
||||
);
|
||||
selected = {
|
||||
daily,
|
||||
customerMonths: customerMonthRows.map(row => ({
|
||||
month: String(row.month),
|
||||
customerName: String(row.customerName),
|
||||
kg: numberValue(row.kg),
|
||||
fee: numberValue(row.fee),
|
||||
recordCount: numberValue(row.recordCount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const paymentTotal = stations.reduce((sum, station) => sum + station.paymentAmount, 0);
|
||||
const summaryLedgerByDate = new Map(summaryDailyRows.map(row => [String(row.date), row]));
|
||||
const summaryPaymentByDate = new Map(summaryPaymentDailyRows.map(row => [String(row.date), row]));
|
||||
const summaryDaily = enumerateDateRange(range.start, range.end).map(date => {
|
||||
const ledger = summaryLedgerByDate.get(date);
|
||||
const payment = summaryPaymentByDate.get(date);
|
||||
return {
|
||||
date,
|
||||
kg: numberValue(ledger?.kg),
|
||||
fee: numberValue(ledger?.fee),
|
||||
recordCount: numberValue(ledger?.recordCount),
|
||||
paymentAmount: numberValue(payment?.amount),
|
||||
paymentCount: numberValue(payment?.paymentCount),
|
||||
};
|
||||
});
|
||||
const latestLedgerTime = stations
|
||||
.map(station => station.latestLedgerTime)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
return {
|
||||
range,
|
||||
summary: {
|
||||
stationCount: stations.length,
|
||||
activeStationCount: stations.filter(station => station.kg > 0).length,
|
||||
totalKg,
|
||||
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
|
||||
recordCount: stations.reduce((sum, station) => sum + station.recordCount, 0),
|
||||
paymentAmount: paymentTotal,
|
||||
paymentCount: stations.reduce((sum, station) => sum + station.paymentCount, 0),
|
||||
latestLedgerTime,
|
||||
daily: summaryDaily,
|
||||
},
|
||||
stations,
|
||||
selected,
|
||||
};
|
||||
}, { force });
|
||||
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user