ci/woodpecker/push/woodpecker Pipeline was canceled
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
154 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
}
|