feat: drill hydrogen trends into orders

This commit is contained in:
kkfluous
2026-08-07 16:29:26 +08:00
parent 263d8c6245
commit a8f7548314
10 changed files with 432 additions and 14 deletions
+112
View File
@@ -7,6 +7,8 @@ import {
parseHydrogenCustomerKind,
parseElectricVehicleScope,
parseHydrogenCustomerName,
parseHydrogenOrderLimit,
parseHydrogenOrderPage,
parseHydrogenStationId,
parseEnergyDate,
parseEtcLimit,
@@ -538,6 +540,116 @@ app.get('/hydrogen/daily', async (c) => {
return c.json(data);
});
// =========================================================
// 氢能订单下钻:指定自然日 + 车辆归属 + 可选站点/客户
// =========================================================
app.get('/hydrogen/orders', async (c) => {
const date = parseEnergyDate(c.req.query('date'));
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
const stationIdParam = c.req.query('stationId');
const customerNameParam = c.req.query('customerName');
const stationId = parseHydrogenStationId(stationIdParam);
const customerName = parseHydrogenCustomerName(customerNameParam);
if (stationIdParam !== undefined && stationId === null) {
return c.json({ error: 'stationId 必须是非负整数' }, 400);
}
if (customerNameParam !== undefined && customerName === null) {
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
}
const page = parseHydrogenOrderPage(c.req.query('page'));
const limit = parseHydrogenOrderLimit(c.req.query('limit'));
const offset = (page - 1) * limit;
const whereParts = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= ?`,
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
];
const params: Array<string | number> = [date, date];
if (stationId !== null) {
whereParts.push('COALESCE(b.station_id, 0) = ?');
params.push(stationId);
}
if (customerName !== null) {
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
params.push(customerName);
}
const where = whereParts.join(' AND ');
const data = await cached(
`hydrogen/orders?date=${date}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}&page=${page}&limit=${limit}`,
async () => {
const hydrogenPool = getHydrogenPool();
const [[summaryRows], [detailRows]] = await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount,
SUM(b.amount_kg) AS totalKg,
SUM(b.cost_total) AS totalCost,
SUM(b.fee_total) AS totalRevenue
FROM ${HYDROGEN_TABLE} b
WHERE ${where}`,
params,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS refuelTime,
COALESCE(NULLIF(TRIM(b.plate_number), ''), '未识别车辆') AS plate,
COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') AS customerName,
COALESCE(s.station_short_name, s.station_name, NULLIF(TRIM(b.station_name), ''),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS stationName,
b.amount_kg AS amountKg,
b.cost_price AS costPrice,
b.cost_total AS costTotal,
b.customer_price AS customerPrice,
b.fee_total AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
ORDER BY b.${HYDROGEN_LOCAL} DESC,
b.plate_number ASC,
b.station_id ASC,
b.amount_kg DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]);
const summary = summaryRows[0] ?? {};
const recordCount = Number(summary.recordCount) || 0;
return {
date,
vehicleScope: customer,
stationId,
customerName,
page,
limit,
recordCount,
totalPages: Math.max(1, Math.ceil(recordCount / limit)),
totalKg: Math.round((Number(summary.totalKg) || 0) * 100) / 100,
totalCost: Math.round((Number(summary.totalCost) || 0) * 100) / 100,
totalRevenue: Math.round((Number(summary.totalRevenue) || 0) * 100) / 100,
items: detailRows.map((row, index) => ({
rowNumber: offset + index + 1,
refuelTime: String(row.refuelTime),
plate: String(row.plate),
customerName: String(row.customerName),
stationName: String(row.stationName),
amountKg: Math.round((Number(row.amountKg) || 0) * 100) / 100,
costPrice: Math.round((Number(row.costPrice) || 0) * 100) / 100,
costTotal: Math.round((Number(row.costTotal) || 0) * 100) / 100,
customerPrice: Math.round((Number(row.customerPrice) || 0) * 100) / 100,
revenue: Math.round((Number(row.revenue) || 0) * 100) / 100,
})),
};
},
{ source: 'hydrogenDatabase' },
);
return c.json(data);
});
// =========================================================
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
// =========================================================
+14
View File
@@ -5,6 +5,8 @@ import {
parseHydrogenCustomerKind,
parseElectricVehicleScope,
parseHydrogenCustomerName,
parseHydrogenOrderLimit,
parseHydrogenOrderPage,
parseHydrogenStationId,
parseEnergyDate,
parseEtcLimit,
@@ -43,6 +45,14 @@ test('normalizes bounded customer names for exact matching', () => {
assert.equal(parseHydrogenCustomerName(undefined), null);
});
test('bounds hydrogen order pagination', () => {
assert.equal(parseHydrogenOrderPage('2'), 2);
assert.equal(parseHydrogenOrderPage('0'), 1);
assert.equal(parseHydrogenOrderLimit('10'), 20);
assert.equal(parseHydrogenOrderLimit('500'), 100);
assert.equal(parseHydrogenOrderLimit(undefined), 20);
});
test('rejects invalid entity filters before querying hydrogen data', async () => {
const invalidStation = await app.request('/hydrogen/daily?stationId=-1');
assert.equal(invalidStation.status, 400);
@@ -51,6 +61,10 @@ test('rejects invalid entity filters before querying hydrogen data', async () =>
const invalidCustomer = await app.request(`/hydrogen/daily?customerName=${'x'.repeat(129)}`);
assert.equal(invalidCustomer.status, 400);
assert.deepEqual(await invalidCustomer.json(), { error: 'customerName 必须是 1-128 个字符' });
const invalidOrderDate = await app.request('/hydrogen/orders?date=2026-02-31');
assert.equal(invalidOrderDate.status, 400);
assert.deepEqual(await invalidOrderDate.json(), { error: 'date 必须是有效的 YYYY-MM-DD 日期' });
});
test('parses only valid energy calendar dates', () => {
+10
View File
@@ -24,6 +24,16 @@ export function parseHydrogenCustomerName(value: string | undefined): string | n
return normalized.length > 0 && normalized.length <= 128 ? normalized : null;
}
export function parseHydrogenOrderPage(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
}
export function parseHydrogenOrderLimit(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? Math.min(Math.max(parsed, 20), 100) : 20;
}
const YMD_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function parseEnergyDate(value: string | undefined): string | null {