146 lines
5.6 KiB
TypeScript
146 lines
5.6 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 {
|
|
HYDROGEN_BASE_WHERE_B,
|
|
HYDROGEN_LOCAL,
|
|
HYDROGEN_MIN_DATE,
|
|
HYDROGEN_TABLE,
|
|
} from './constants.js';
|
|
import {
|
|
customerClause,
|
|
dateRangeClause,
|
|
enumerateDateRange,
|
|
resolveDateRange,
|
|
type CustomerKind,
|
|
type EnergyDateRangeKind as Range,
|
|
} from './query-model.js';
|
|
|
|
export interface HydrogenDailyDependencies {
|
|
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
|
cached: typeof cached;
|
|
}
|
|
|
|
// 氢能每日:日期范围 + 客户类型 + 站点级下钻。
|
|
export function registerHydrogenDailyRoute(
|
|
app: Hono,
|
|
{ hydrogenPool, cached }: HydrogenDailyDependencies,
|
|
) {
|
|
app.get('/hydrogen/daily', async (c) => {
|
|
const range = (c.req.query('range') || 'last15') as Range;
|
|
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
|
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
|
const force = c.req.query('force') === '1';
|
|
|
|
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
|
|
|
|
const where = [
|
|
HYDROGEN_BASE_WHERE_B,
|
|
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
|
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
|
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
|
].join(' AND ');
|
|
|
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
|
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
|
// 单价不重算:直接取账本成本价。
|
|
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
|
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
|
COALESCE(b.station_id, 0) AS stationId,
|
|
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
|
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
|
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
|
ROUND(SUM(b.amount_kg), 2) AS kg,
|
|
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
|
MAX(b.cost_price) AS pricePerKg
|
|
FROM ${HYDROGEN_TABLE} b
|
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
|
WHERE ${where}
|
|
GROUP BY d, COALESCE(b.station_id, 0)
|
|
ORDER BY d DESC, kg DESC`,
|
|
[dateRange.start, dateRange.end],
|
|
);
|
|
|
|
// 站点环比:同站点上一条记录的 kg
|
|
// 按 stationId 分组、按日期升序计算
|
|
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
|
|
const flat: StationRow[] = stationRows.map(r => ({
|
|
date: r.d as string,
|
|
stationId: Number(r.stationId),
|
|
name: r.stationName as string,
|
|
kg: Number(r.kg) || 0,
|
|
pricePerKg: Number(r.pricePerKg) || 0,
|
|
}));
|
|
|
|
// 计算日级总量 + 日级环比
|
|
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
|
|
for (const s of flat) {
|
|
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
|
|
const e = dayMap.get(s.date)!;
|
|
e.totalKg += s.kg;
|
|
e.stations.push(s);
|
|
}
|
|
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
|
|
const dayChainPct = new Map<string, number>();
|
|
let prev = 0;
|
|
for (const d of dates) {
|
|
const cur = dayMap.get(d)!.totalKg;
|
|
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
|
|
prev = cur;
|
|
}
|
|
|
|
// 站点级环比:按 stationId 分组按日期升序
|
|
const stationPrev = new Map<number, number>();
|
|
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
|
|
// 需要按 stationId 分组排序
|
|
const byStation = new Map<number, StationRow[]>();
|
|
for (const s of flat) {
|
|
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
|
|
byStation.get(s.stationId)!.push(s);
|
|
}
|
|
for (const [, list] of byStation) {
|
|
list.sort((a, b) => a.date.localeCompare(b.date));
|
|
let p = 0;
|
|
for (const r of list) {
|
|
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
|
|
p = r.kg;
|
|
}
|
|
}
|
|
|
|
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
|
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
|
const fullDays = allDates.map(date => {
|
|
const info = dayMap.get(date);
|
|
return {
|
|
date,
|
|
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
|
chainPct: dayChainPct.get(date) ?? 0,
|
|
customerType: customer,
|
|
stations: info
|
|
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
|
name: s.name,
|
|
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
|
kg: Math.round(s.kg * 100) / 100,
|
|
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
|
}))
|
|
: [],
|
|
};
|
|
});
|
|
|
|
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
|
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
|
let prevKg = 0;
|
|
for (const d of ascDays) {
|
|
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
|
prevKg = d.totalKg;
|
|
}
|
|
|
|
// 按日期降序返回
|
|
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
|
return result;
|
|
}, { force });
|
|
return c.json(data);
|
|
});
|
|
}
|