Files
ln-bi/src/server/routes/energy/hydrogen-settlement.ts
T
kkfluous 62efef0ab9
ci/woodpecker/push/woodpecker Pipeline was successful
feat(energy): rebuild hydrogen BI board and drill-through
2026-08-20 13:59:03 +08:00

120 lines
4.8 KiB
TypeScript

import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import { resolveDateRange, type EnergyDateRangeKind } from './query-model.js';
type SettlementRange = EnergyDateRangeKind | 'latest' | 'custom';
export interface HydrogenSettlementDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
function toDateString(value: unknown): string | null {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
return null;
}
function minusDays(date: string, days: number): string {
// 用 UTC 正午作为日历运算锚点,避免东八区转 ISO 日期时落到前一天。
const result = new Date(`${date}T12:00:00Z`);
result.setUTCDate(result.getUTCDate() - days);
return result.toISOString().slice(0, 10);
}
function normalizeMatchMode(value: unknown): 'exact' | 'manual' | 'group' | 'unmatched' {
if (value === 'exact' || value === 'manual' || value === 'group') return value;
return 'unmatched';
}
// 站日现结只读台账:数据来自已入库的付款流水,不承担登记或审批职责。
export function registerHydrogenSettlementRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenSettlementDependencies,
) {
app.get('/hydrogen/settlement', async (c) => {
const requestedRange = (c.req.query('range') || 'latest') as SettlementRange;
const force = c.req.query('force') === '1';
const [latestRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
FROM hydrogen_station_payment
WHERE del_flag = '0'`,
);
const latestPaymentDate = toDateString(latestRows[0]?.latestPaymentDate);
const dateRange = requestedRange === 'latest'
? latestPaymentDate
? { start: minusDays(latestPaymentDate, 14), end: latestPaymentDate, mode: 'latest' as const }
: { start: null, end: null, mode: 'latest' as const }
: (() => {
const resolved = resolveDateRange(
requestedRange as EnergyDateRangeKind,
c.req.query('startDate'),
c.req.query('endDate'),
);
return { ...resolved, mode: requestedRange === 'custom' ? 'custom' as const : requestedRange };
})();
if (!dateRange.start || !dateRange.end) {
return c.json({
range: dateRange,
summary: { amount: 0, paymentCount: 0, stationDayCount: 0, stationCount: 0, latestPaymentDate },
rows: [],
});
}
const data = await cached(
`hydrogen/settlement?start=${dateRange.start}&end=${dateRange.end}`,
async () => {
const [rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(p.payment_date, '%Y-%m-%d') AS date,
p.station_id AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(p.raw_station_name), '未匹配加氢站') AS stationName,
ROUND(SUM(p.amount), 2) AS amount,
COUNT(*) AS paymentCount,
CASE
WHEN SUM(p.match_mode = 'exact') = COUNT(*) THEN 'exact'
WHEN SUM(p.match_mode = 'manual') = COUNT(*) THEN 'manual'
WHEN SUM(p.match_mode = 'group') = COUNT(*) THEN 'group'
ELSE 'unmatched'
END AS matchMode
FROM hydrogen_station_payment p
LEFT JOIN hydrogen_station s ON s.id = p.station_id AND s.del_flag = '0'
WHERE p.del_flag = '0'
AND p.payment_date >= ?
AND p.payment_date <= ?
GROUP BY DATE_FORMAT(p.payment_date, '%Y-%m-%d'), p.station_id, p.raw_station_name
ORDER BY date DESC, amount DESC
LIMIT 500`,
[dateRange.start, dateRange.end],
);
const normalizedRows = rows.map(row => ({
date: String(row.date),
stationId: row.stationId === null || row.stationId === undefined ? null : Number(row.stationId),
stationName: String(row.stationName),
amount: Number(row.amount) || 0,
paymentCount: Number(row.paymentCount) || 0,
matchMode: normalizeMatchMode(row.matchMode),
}));
const stationIds = new Set(normalizedRows.map(row => row.stationId ?? `name:${row.stationName}`));
return {
range: dateRange,
summary: {
amount: normalizedRows.reduce((total, row) => total + row.amount, 0),
paymentCount: normalizedRows.reduce((total, row) => total + row.paymentCount, 0),
stationDayCount: normalizedRows.length,
stationCount: stationIds.size,
latestPaymentDate,
},
rows: normalizedRows,
};
},
{ force },
);
return c.json(data);
});
}