Files
ln-bi/src/server/routes/energy/hydrogen-daily-detail.ts
T
kfluousandHiFox Agent a177781fe7
ci/woodpecker/push/woodpecker Pipeline was canceled
feat(energy): integrate self-operated station customers and receipts
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
2026-09-05 17:03:15 +08:00

154 lines
7.3 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_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
import { customerClause, type CustomerKind } from './query-model.js';
export interface HydrogenDailyDetailDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
const YMD = /^\d{4}-\d{2}-\d{2}$/;
export function registerHydrogenDailyDetailRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenDailyDetailDependencies,
) {
app.get('/hydrogen/daily-detail', async (c) => {
const date = c.req.query('date') || '';
if (!YMD.test(date)) return c.json({ error: 'date must be YYYY-MM-DD' }, 400);
const customer = (c.req.query('customer') || 'all') as CustomerKind;
const verifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
const force = c.req.query('force') === '1';
const stationValue = Number(c.req.query('stationId'));
const stationId = Number.isInteger(stationValue) && stationValue > 0 ? stationValue : null;
const stationClause = stationId ? ' AND b.station_id = ?' : '';
const params: unknown[] = [date, ...(stationId ? [stationId] : [])];
const data = await cached(
`hydrogen/daily-detail?date=${date}&customer=${customer}${stationId ? `&station=${stationId}` : ''}${verifyScope === 'verified' ? '&verify=verified' : ''}`,
async () => {
const [rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.id,
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%H:%i') AS time,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS stationName,
COALESCE(s.station_type, 'unknown') AS stationType,
COALESCE(b.system_customer_id, b.customer_id, 0) AS customerId,
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS customerName,
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS plateNo,
b.vehicle_id AS vehicleId,
COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus,
ROUND(COALESCE(b.cost_price, 0), 2) AS unitPrice,
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg,
ROUND(COALESCE(b.cost_total, 0), 2) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND DATE(b.${HYDROGEN_LOCAL}) = ?
AND ${customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id')}${verifyScope === 'verified' ? " AND LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'" : ''}${stationClause}
ORDER BY b.${HYDROGEN_LOCAL} ASC, b.id ASC
LIMIT 3000`,
params,
);
type Vehicle = {
id: string;
time: string;
plateNo: string;
vehicleScope: 'lingniu' | 'external';
source: string;
verifyStatus: string;
unitPrice: number;
kg: number;
fee: number;
};
type Customer = { id: number; name: string; kg: number; fee: number; vehicles: Vehicle[] };
type Station = { id: number; name: string; stationType: string; kg: number; fee: number; customers: Map<number, Customer> };
const stations = new Map<number, Station>();
for (const row of rows) {
const currentStationId = Number(row.stationId) || 0;
const currentCustomerId = Number(row.customerId) || 0;
let station = stations.get(currentStationId);
if (!station) {
station = { id: currentStationId, name: String(row.stationName), stationType: String(row.stationType), kg: 0, fee: 0, customers: new Map() };
stations.set(currentStationId, station);
}
let customerRow = station.customers.get(currentCustomerId);
if (!customerRow) {
customerRow = { id: currentCustomerId, name: String(row.customerName), kg: 0, fee: 0, vehicles: [] };
station.customers.set(currentCustomerId, customerRow);
}
const kg = Number(row.kg) || 0;
const fee = Number(row.fee) || 0;
station.kg += kg;
station.fee += fee;
customerRow.kg += kg;
customerRow.fee += fee;
customerRow.vehicles.push({
// Ledger ids can exceed Number.MAX_SAFE_INTEGER. Keep the database
// identifier lossless so the final vehicle/source drill-down has
// stable React keys.
id: String(row.id),
time: String(row.time),
plateNo: String(row.plateNo),
vehicleScope: row.vehicleId === null || row.vehicleId === undefined ? 'external' : 'lingniu',
source: String(row.source),
verifyStatus: String(row.verifyStatus),
unitPrice: Number(row.unitPrice) || 0,
kg,
fee,
});
}
// `new_hydrogen_site_balance_record` is the only persisted station-balance
// source. It is intentionally queried separately so a missing balance record
// stays null instead of becoming a fabricated zero balance.
const stationIds = [...stations.keys()].filter(id => id > 0);
const balances = new Map<number, { amount: number; effectiveTime: string | null }>();
if (stationIds.length > 0) {
const placeholders = stationIds.map(() => '?').join(', ');
const [balanceRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT r.site_id AS stationId,
r.ending_balance AS amount,
DATE_FORMAT(r.effective_time, '%Y-%m-%d %H:%i:%s') AS effectiveTime
FROM new_hydrogen_site_balance_record r
INNER JOIN (
SELECT site_id, MAX(effective_time) AS maxEffectiveTime
FROM new_hydrogen_site_balance_record
WHERE del_flag = '0' AND effective_time < DATE_ADD(?, INTERVAL 1 DAY)
AND site_id IN (${placeholders})
GROUP BY site_id
) latest ON latest.site_id = r.site_id AND latest.maxEffectiveTime = r.effective_time
WHERE r.del_flag = '0'`,
[date, ...stationIds],
);
for (const row of balanceRows) {
balances.set(Number(row.stationId), {
amount: Number(row.amount),
effectiveTime: row.effectiveTime === null || row.effectiveTime === undefined ? null : String(row.effectiveTime),
});
}
}
return {
date,
recordCount: rows.length,
stations: [...stations.values()].map(station => ({
...station,
balance: balances.get(station.id)?.amount ?? null,
balanceEffectiveTime: balances.get(station.id)?.effectiveTime ?? null,
customers: [...station.customers.values()],
})),
};
},
{ force },
);
return c.json(data);
});
}