feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-08-20 13:59:03 +08:00
parent 37a9f303ec
commit 62efef0ab9
72 changed files with 36773 additions and 965 deletions
+3 -1
View File
@@ -32,7 +32,8 @@ export function registerElectricOverviewRoute(
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee,
DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestChargeTime
FROM bi_ele_charge_record`,
);
const k = kpiRows[0] ?? {};
@@ -94,6 +95,7 @@ export function registerElectricOverviewRoute(
return {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr,
latestChargeTime: typeof k.latestChargeTime === 'string' ? k.latestChargeTime : null,
};
}, { force });
return c.json(data);
+45
View File
@@ -0,0 +1,45 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type pool from '../../db.js';
import type { cached } from './cache.js';
export interface EtcOverviewDependencies {
pool: Pick<typeof pool, 'query'>;
cached: typeof cached;
}
// ETC 仅聚合已入库的账单与通行明细;空表应明确返回空状态,不能伪造经营数据。
export function registerEtcOverviewRoute(
app: Hono,
{ pool, cached }: EtcOverviewDependencies,
) {
app.get('/etc/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('etc/overview', async () => {
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT
(SELECT COUNT(*) FROM etc_toll_record WHERE del_flag = '0') AS tollRecordCount,
(SELECT COUNT(DISTINCT plate_number) FROM etc_toll_record WHERE del_flag = '0') AS vehicleCount,
(SELECT ROUND(SUM(total_amount), 2) FROM etc_toll_record WHERE del_flag = '0') AS totalAmount,
(SELECT DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') FROM etc_toll_record WHERE del_flag = '0') AS latestTollTime,
(SELECT COUNT(*) FROM energy_etc_bill WHERE del_flag = '0') AS billCount,
(SELECT ROUND(SUM(receivable_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS receivableAmount,
(SELECT ROUND(SUM(paid_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS paidAmount`,
);
const row = rows[0] ?? {};
const tollRecordCount = Number(row.tollRecordCount) || 0;
const billCount = Number(row.billCount) || 0;
return {
tollRecordCount,
vehicleCount: Number(row.vehicleCount) || 0,
totalAmount: Number(row.totalAmount) || 0,
latestTollTime: typeof row.latestTollTime === 'string' ? row.latestTollTime : null,
billCount,
receivableAmount: Number(row.receivableAmount) || 0,
paidAmount: Number(row.paidAmount) || 0,
hasData: tollRecordCount > 0 || billCount > 0,
};
}, { force });
return c.json(data);
});
}
+903
View File
@@ -0,0 +1,903 @@
import type { Hono } from "hono";
import type { RowDataPacket } from "mysql2";
import type hydrogenPool from "../../hydrogen-db.js";
import {
HYDROGEN_BASE_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
} from "./constants.js";
/**
* The BI v2 contract is intentionally independent of the legacy hydrogen
* endpoints. It is shaped around the prototype screens so the client never
* has to merge multiple historical responses or recompute a business metric.
*/
export interface HydrogenBiV2Dependencies {
hydrogenPool: Pick<typeof hydrogenPool, "query">;
}
type VehicleScope = "all" | "lingniu" | "external";
type VerifyScope = "all" | "verified";
type AmountScope = "all" | "customer" | "company" | "other";
type GroupBy = "station" | "customer" | "date" | "vehicle" | "record";
type RegionGranularity = "province" | "city";
type Filter = {
startDate: string;
endDate: string;
// Station IDs are bigint values. Keep them as strings end-to-end: converting
// them to JavaScript Number merges distinct OneOS station IDs above 2^53.
stationId: string | null;
customerId: number | null;
customerName: string | null;
plateNo: string | null;
date: string | null;
month: string | null;
region: string | null;
regionGranularity: RegionGranularity;
vehicleScope: VehicleScope;
verifyScope: VerifyScope;
clauses: string[];
params: unknown[];
};
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
function validDate(value: string | undefined): value is string {
if (!value || !DATE_PATTERN.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
!Number.isNaN(parsed.getTime()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function endOfMonth(month: string) {
const [year, monthNumber] = month.split("-").map(Number);
return new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
}
function stationRegionSql(granularity: RegionGranularity) {
// new_hydrogen_site is the OneOS station master (453 active stations). The
// historical hydrogen_station table only contains 89 records, so it cannot
// be used to decide a ledger station's province/city.
const districtColumn = granularity === "province" ? "rs.province" : "rs.city";
const stationName = `COALESCE(
(SELECT COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''))
FROM new_hydrogen_site rs
WHERE rs.id = b.station_id AND rs.del_flag = '0'
LIMIT 1),
b.station_name,
''
)`;
const fallback =
granularity === "province"
? `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '浙江省'
WHEN ${stationName} LIKE '%广州%' OR ${stationName} LIKE '%佛山%' THEN '广东省'
WHEN ${stationName} LIKE '%成都%' THEN '四川省'
WHEN ${stationName} LIKE '%昆山%' THEN '江苏省'
ELSE '未归属省份' END`
: `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '嘉兴市'
WHEN ${stationName} LIKE '%广州%' THEN '广州市'
WHEN ${stationName} LIKE '%佛山%' THEN '佛山市'
WHEN ${stationName} LIKE '%成都%' THEN '成都市'
WHEN ${stationName} LIKE '%昆山%' THEN '昆山市'
ELSE '未归属区域' END`;
return `COALESCE(
(SELECT NULLIF(rd.NAME, '')
FROM new_hydrogen_site rs
LEFT JOIN common_district rd
ON CONVERT(rd.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(${districtColumn} USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND rd.STATUS = 'VALID'
WHERE rs.id = b.station_id AND rs.del_flag = '0'
LIMIT 1),
${fallback}
)`;
}
function todayYmd() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
function dateForYear(year: number, edge: "start" | "end") {
return `${year}-${edge === "start" ? "01-01" : "12-31"}`;
}
function validYear(value: string | undefined) {
const year = Number(value);
// 原型允许查看 2020 起的历史年份。即使真实台账尚未覆盖某一年,
// 也必须保持该筛选范围并返回空聚合,不能静默回退到当前年。
return Number.isInteger(year) && year >= 2020 && year <= 2100 ? year : null;
}
function resolveFilter(query: (key: string) => string | undefined): Filter {
const requestedYear = validYear(query("year"));
const currentYear = new Date().getFullYear();
const year = requestedYear ?? currentYear;
const exactDate = validDate(query("date")) ? query("date")! : null;
const requestedMonth = MONTH_PATTERN.test(query("month") ?? "")
? query("month")!
: null;
const startDate =
exactDate ??
(requestedMonth
? `${requestedMonth}-01`
: validDate(query("startDate"))
? query("startDate")!
: dateForYear(year, "start"));
// A current-year dashboard must never render future zero months/days by
// default. Historical years retain their complete calendar range.
const endDate =
exactDate ??
(requestedMonth
? endOfMonth(requestedMonth)
: validDate(query("endDate"))
? query("endDate")!
: year === currentYear
? todayYmd()
: dateForYear(year, "end"));
const [safeStart, safeEnd] =
startDate <= endDate ? [startDate, endDate] : [endDate, startDate];
const stationValue = query("stationId");
const stationId =
stationValue !== undefined && /^\d+$/.test(stationValue)
? stationValue
: null;
const customerValue = query("customerId");
const customerRaw = Number(customerValue);
const customerId =
customerValue !== undefined &&
Number.isInteger(customerRaw) &&
customerRaw >= 0
? customerRaw
: null;
const customerName =
(query("customerName") ?? "").trim().slice(0, 100) || null;
const plateNo = (query("plateNo") ?? "").trim().slice(0, 32) || null;
const region = (query("region") ?? "").trim().slice(0, 64) || null;
const regionGranularity: RegionGranularity =
query("regionGranularity") === "province" ? "province" : "city";
const vehicleScope: VehicleScope =
query("vehicleScope") === "lingniu" || query("vehicleScope") === "external"
? (query("vehicleScope") as VehicleScope)
: "all";
const verifyScope: VerifyScope =
query("verifyScope") === "verified" ? "verified" : "all";
const clauses = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= ?`,
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
];
const params: unknown[] = [
safeStart < HYDROGEN_MIN_DATE ? HYDROGEN_MIN_DATE : safeStart,
safeEnd,
];
if (stationId !== null) {
clauses.push("COALESCE(b.station_id, 0) = ?");
params.push(stationId);
}
if (customerId !== null) {
clauses.push("COALESCE(b.system_customer_id, b.customer_id, 0) = ?");
params.push(customerId);
}
if (customerName) {
clauses.push(
"COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') = ?",
);
params.push(customerName);
}
if (plateNo) {
clauses.push("COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?");
params.push(plateNo);
}
if (region) {
clauses.push(`${stationRegionSql(regionGranularity)} = ?`);
params.push(region);
}
if (vehicleScope === "lingniu") clauses.push("b.vehicle_id IS NOT NULL");
if (vehicleScope === "external") clauses.push("b.vehicle_id IS NULL");
if (verifyScope === "verified")
clauses.push(
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'",
);
return {
startDate: safeStart,
endDate: safeEnd,
stationId,
customerId,
customerName,
plateNo,
date: exactDate,
month: requestedMonth,
region,
regionGranularity,
vehicleScope,
verifyScope,
clauses,
params,
};
}
function where(filter: Filter) {
return filter.clauses.join(" AND ");
}
function filterContext(filter: Filter) {
return {
startDate: filter.startDate,
endDate: filter.endDate,
stationId: filter.stationId,
customerId: filter.customerId,
customerName: filter.customerName,
plateNo: filter.plateNo,
date: filter.date,
month: filter.month,
region: filter.region,
regionGranularity: filter.regionGranularity,
vehicleScope: filter.vehicleScope,
verifyScope: filter.verifyScope,
};
}
function number(value: unknown, digits = 2) {
const numeric = Number(value) || 0;
const factor = 10 ** digits;
return Math.round(numeric * factor) / factor;
}
// 承担方必须以账本专用字段 settlement_type 为准,不能由对客单价/金额反推。
// hydrogen_fuel_ledger_log 中的业务映射:1 = 客户承担,2 = 我司承担,3 = 客户自行结算。
// 其余值(含 3、4、NULL)统一归入“其他”,避免把未知/自行结算误计为我司承担。
const CUSTOMER_BEARING_ORDER = "b.settlement_type = 1";
const COMPANY_BEARING_ORDER = "b.settlement_type = 2";
const OTHER_BEARING_ORDER =
"(b.settlement_type NOT IN (1, 2) OR b.settlement_type IS NULL)";
function resolveAmountScope(value: string | undefined): AmountScope {
return value === "customer" || value === "company" || value === "other"
? value
: "all";
}
function formatWatermark(value: unknown) {
return value === null || value === undefined ? null : String(value);
}
function monthKeys(start: string, end: string) {
const list: string[] = [];
const cursor = new Date(`${start.slice(0, 7)}-01T00:00:00`);
const finish = new Date(`${end.slice(0, 7)}-01T00:00:00`);
while (cursor <= finish && list.length <= 36) {
list.push(
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`,
);
cursor.setMonth(cursor.getMonth() + 1);
}
return list;
}
function dateKeys(start: string, end: string) {
const list: string[] = [];
const cursor = new Date(`${start}T00:00:00`);
const finish = new Date(`${end}T00:00:00`);
while (cursor <= finish && list.length <= 400) {
list.push(
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}`,
);
cursor.setDate(cursor.getDate() + 1);
}
return list;
}
async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
const [yearRows, stationRows, watermarkRows] = await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT YEAR(${HYDROGEN_LOCAL}) AS value,
DATE_FORMAT(MIN(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS startDate,
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS endDate
FROM ${HYDROGEN_TABLE}
WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${HYDROGEN_LOCAL})
ORDER BY value DESC`,
[HYDROGEN_MIN_DATE],
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT CAST(s.id AS CHAR) AS id,
COALESCE(NULLIF(s.site_short_name, ''), NULLIF(s.site_name, ''), CONCAT('站点 #', s.id)) AS name,
COALESCE(NULLIF(p.NAME, ''), '未归属') AS province,
COALESCE(NULLIF(ct.NAME, ''), '未归属') AS city
FROM new_hydrogen_site s
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
WHERE s.del_flag = '0'
ORDER BY name`,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
FROM ${HYDROGEN_TABLE}
WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ?`,
[HYDROGEN_MIN_DATE],
),
]);
return {
years: yearRows[0].map((row) => ({
value: Number(row.value),
startDate: String(row.startDate),
endDate: String(row.endDate),
})),
stations: stationRows[0].map((row) => ({
id: String(row.id),
name: String(row.name),
province: String(row.province),
city: String(row.city),
})),
watermark: {
ledgerAt: formatWatermark(watermarkRows[0][0]?.ledgerAt),
paymentAt: null,
},
};
}
async function overview(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
) {
const sqlWhere = where(filter);
const month = filter.endDate.slice(0, 7);
const [summaryRows, monthlyRows, stationRows, customerRows] =
await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS totalCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS totalRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS monthKg,
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.cost_total ELSE 0 END), 0), 2) AS monthCost,
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS todayKg,
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.cost_total ELSE 0 END), 0), 2) AS todayCost,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}`,
[month, month, filter.endDate, filter.endDate, ...filter.params],
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m')
ORDER BY month`,
filter.params,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province,
COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount
FROM ${HYDROGEN_TABLE} b
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
WHERE ${sqlWhere}
GROUP BY COALESCE(b.station_id, 0)
ORDER BY kg DESC`,
filter.params,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
ORDER BY kg DESC
LIMIT 200`,
filter.params,
),
]);
const summary: RowDataPacket = summaryRows[0][0] ?? ({} as RowDataPacket);
const totalKg = number(summary.totalKg, 3);
const totalRevenue = number(summary.totalRevenue);
const customerRevenue = number(summary.customerRevenue);
const customerCost = number(summary.customerCost);
const monthlyMap = new Map(
monthlyRows[0].map((row) => [String(row.month), row]),
);
const monthly = monthKeys(filter.startDate, filter.endDate).map((key) => {
const row = monthlyMap.get(key);
const revenue = number(row?.revenue);
const customerMonthRevenue = number(row?.customerRevenue);
const cost = number(row?.cost);
const customerMonthCost = number(row?.customerCost);
const companyMonthCost = number(row?.companyCost);
const otherMonthCost = number(row?.otherCost);
return {
month: key,
totalKg: number(row?.totalKg, 3),
lingniuKg: number(row?.lingniuKg, 3),
externalKg: number(row?.externalKg, 3),
cost,
revenue,
customerRevenue: customerMonthRevenue,
customerCost: customerMonthCost,
companyCost: companyMonthCost,
otherCost: otherMonthCost,
customerGrossProfit: number(customerMonthRevenue - customerMonthCost),
};
});
const stations = stationRows[0].map((row) => ({
id: String(row.id),
name: String(row.name),
province: String(row.province),
city: String(row.city),
kg: number(row.kg, 3),
lingniuKg: number(row.lingniuKg, 3),
externalKg: number(row.externalKg, 3),
cost: number(row.cost),
revenue: number(row.revenue),
customerRevenue: number(row.customerRevenue),
customerCost: number(row.customerCost),
companyCost: number(row.companyCost),
otherCost: number(row.otherCost),
recordCount: Number(row.recordCount) || 0,
customerCount: Number(row.customerCount) || 0,
share: totalKg ? number((Number(row.kg) / totalKg) * 100, 2) : 0,
}));
// The station aggregation above already joins the district dimension. Reusing
// it avoids a second full-ledger scan with a correlated subquery for regions.
const regionMap = new Map<string, number>();
for (const station of stations) {
const rawRegion =
filter.regionGranularity === "province" ? station.province : station.city;
const region =
rawRegion === "未归属"
? filter.regionGranularity === "province"
? "未归属省份"
: "未归属区域"
: rawRegion;
regionMap.set(region, (regionMap.get(region) || 0) + station.kg);
}
const regionSum = [...regionMap.values()].reduce((sum, kg) => sum + kg, 0);
const regions = [...regionMap.entries()]
.map(([region, kg]) => ({
region,
kg: number(kg, 3),
share: regionSum ? number((kg / regionSum) * 100, 2) : 0,
}))
.sort((left, right) => right.kg - left.kg);
return {
range: { startDate: filter.startDate, endDate: filter.endDate },
watermark: { ledgerAt: formatWatermark(summary.ledgerAt), paymentAt: null },
filters: filterContext(filter),
kpis: {
totalKg,
totalCost: number(summary.totalCost),
customerBearingKg: number(summary.customerBearingKg, 3),
companyBearingKg: number(summary.companyBearingKg, 3),
otherBearingKg: number(summary.otherBearingKg, 3),
totalRevenue,
customerRevenue,
customerCost,
companyCost: number(summary.companyCost),
otherCost: number(summary.otherCost),
customerGrossProfit: number(customerRevenue - customerCost),
monthKg: number(summary.monthKg, 3),
monthCost: number(summary.monthCost),
monthShareOfRange: totalKg
? number((number(summary.monthKg, 3) / totalKg) * 100, 2)
: 0,
todayKg: number(summary.todayKg, 3),
todayCost: number(summary.todayCost),
month: filter.endDate.slice(0, 7),
todayShareOfMonth: number(summary.monthKg, 3)
? number(
(number(summary.todayKg, 3) / number(summary.monthKg, 3)) * 100,
2,
)
: 0,
recordCount: Number(summary.recordCount) || 0,
stationCount: Number(summary.stationCount) || 0,
},
monthly,
topStations: stations.slice(0, 5),
stations,
regions,
customers: customerRows[0].map((row) => {
const customerBearingKg = number(row.customerBearingKg, 3);
const companyBearingKg = number(row.companyBearingKg, 3);
const otherBearingKg = number(row.otherBearingKg, 3);
return {
id: Number(row.id) || 0,
name: String(row.name),
kg: number(row.kg, 3),
customerBearingKg,
companyBearingKg,
otherBearingKg,
bearer:
otherBearingKg > 0
? "other"
: customerBearingKg > 0 && companyBearingKg > 0
? "both"
: customerBearingKg > 0
? "customer"
: "company",
cost: number(row.cost),
revenue: number(row.revenue),
customerRevenue: number(row.customerRevenue),
customerCost: number(row.customerCost),
companyCost: number(row.companyCost),
otherCost: number(row.otherCost),
recordCount: Number(row.recordCount) || 0,
};
}),
};
}
async function daily(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
) {
const sqlWhere = where(filter);
const [rows, watermarkRows] = await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date`,
filter.params,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
FROM ${HYDROGEN_TABLE} b WHERE ${sqlWhere}`,
filter.params,
),
]);
const map = new Map(rows[0].map((row) => [String(row.date), row]));
let previousKg = 0;
const days = dateKeys(filter.startDate, filter.endDate).map((date) => {
const row = map.get(date);
const kg = number(row?.kg, 3);
const chainPct = previousKg
? number(((kg - previousKg) / previousKg) * 100, 2)
: 0;
previousKg = kg;
return {
date,
kg,
lingniuKg: number(row?.lingniuKg, 3),
externalKg: number(row?.externalKg, 3),
cost: number(row?.cost),
recordCount: Number(row?.recordCount) || 0,
stationCount: Number(row?.stationCount) || 0,
chainPct,
};
});
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
return {
range: { startDate: filter.startDate, endDate: filter.endDate },
watermark: {
ledgerAt: formatWatermark(watermarkRows[0][0]?.ledgerAt),
paymentAt: null,
},
filters: filterContext(filter),
kpis: {
totalKg: number(totalKg, 3),
totalCost: number(totalCost),
averageDailyKg: number(totalKg / Math.max(1, days.length), 3),
stationCount: Math.max(0, ...days.map((row) => row.stationCount)),
activeDays: days.filter((row) => row.kg > 0).length,
},
trend: days,
days: [...days].reverse(),
};
}
/**
* Prototype daily detail is a real hierarchy, not a flattened table: one
* business day expands into station, customer and then the existing record
* drill. Records remain paged through /drill to keep a busy day bounded.
*/
async function dailyTree(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
) {
const sqlWhere = where(filter);
const [stationRows, customerRows] = await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${sqlWhere}
GROUP BY COALESCE(b.station_id, 0)
ORDER BY kg DESC`,
filter.params,
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS stationId,
COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${sqlWhere}
GROUP BY COALESCE(b.station_id, 0), COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
ORDER BY stationId, kg DESC`,
filter.params,
),
]);
const customersByStation = new Map<
string,
Array<{
id: number;
name: string;
kg: number;
cost: number;
recordCount: number;
}>
>();
customerRows[0].forEach((row) => {
const stationId = String(row.stationId);
const list = customersByStation.get(stationId) || [];
list.push({
id: Number(row.id) || 0,
name: String(row.name),
kg: number(row.kg, 3),
cost: number(row.cost),
recordCount: Number(row.recordCount) || 0,
});
customersByStation.set(stationId, list);
});
return {
date: filter.startDate,
stations: stationRows[0].map((row) => ({
id: String(row.id),
name: String(row.name),
kg: number(row.kg, 3),
cost: number(row.cost),
recordCount: Number(row.recordCount) || 0,
customers: customersByStation.get(String(row.id)) || [],
})),
};
}
async function drill(
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
filter: Filter,
groupBy: GroupBy,
page: number,
pageSize: number,
amountScope: AmountScope,
) {
const sqlWhere = where(filter);
const scopedWhere =
amountScope === "customer"
? `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`
: amountScope === "company"
? `${sqlWhere} AND ${COMPANY_BEARING_ORDER}`
: amountScope === "other"
? `${sqlWhere} AND ${OTHER_BEARING_ORDER}`
: sqlWhere;
const params = filter.params;
const offset = (page - 1) * pageSize;
const groupSelect =
groupBy === "station"
? "CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id, COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name, COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province, COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city"
: groupBy === "customer"
? "COALESCE(b.system_customer_id, b.customer_id, 0) AS id, COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name, NULL AS province, NULL AS city"
: groupBy === "date"
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS name, NULL AS province, NULL AS city`
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id, COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name, NULL AS province, NULL AS city";
const groupExpression =
groupBy === "station"
? "COALESCE(b.station_id, 0)"
: groupBy === "customer"
? "COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')"
: groupBy === "date"
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC";
const [summaryRows, groupRows, recordRows] = await Promise.all([
hydrogenPool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
SUM(CASE WHEN NULLIF(b.record_source, '') IS NOT NULL OR b.source IS NOT NULL THEN 1 ELSE 0 END) AS traceableRecordCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}`,
params,
),
groupBy === "record"
? Promise.resolve([[] as RowDataPacket[]])
: hydrogenPool.query<RowDataPacket[]>(
`SELECT ${groupSelect},
COUNT(*) AS recordCount, COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg, ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
WHERE ${scopedWhere}
GROUP BY ${groupExpression}
ORDER BY ${groupOrder} LIMIT ? OFFSET ?`,
[...params, pageSize, offset],
),
hydrogenPool.query<RowDataPacket[]>(
`SELECT b.id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS time, b.order_no AS orderNo,
CAST(COALESCE(b.station_id, 0) AS CHAR) AS stationId, COALESCE(NULLIF(b.station_name, ''), '未关联站点') AS stationName,
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, COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus, b.vehicle_id AS vehicleId,
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg, ROUND(COALESCE(b.cost_price, 0), 2) AS unitPrice,
ROUND(COALESCE(b.cost_total, 0), 2) AS cost, ROUND(COALESCE(b.fee_total, 0), 2) AS revenue
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC LIMIT ? OFFSET ?`,
[...params, pageSize, offset],
),
]);
const summary = summaryRows[0][0] ?? {};
return {
groupBy,
amountScope,
filters: filterContext(filter),
summary: {
recordCount: Number(summary.recordCount) || 0,
stationCount: Number(summary.stationCount) || 0,
customerCount: Number(summary.customerCount) || 0,
traceableRecordCount: Number(summary.traceableRecordCount) || 0,
kg: number(summary.kg, 3),
cost: number(summary.cost),
revenue: number(summary.revenue),
},
groups: groupRows[0].map((row) => ({
id: String(row.id),
name: String(row.name),
province: row.province ? String(row.province) : null,
city: row.city ? String(row.city) : null,
recordCount: Number(row.recordCount) || 0,
stationCount: Number(row.stationCount) || 0,
customerCount: Number(row.customerCount) || 0,
kg: number(row.kg, 3),
cost: number(row.cost),
revenue: number(row.revenue),
lingniuKg: number(row.lingniuKg, 3),
externalKg: number(row.externalKg, 3),
})),
records: recordRows[0].map((row) => ({
id: String(row.id),
time: String(row.time),
orderNo: String(row.orderNo || ""),
stationId: String(row.stationId),
stationName: String(row.stationName),
customerId: Number(row.customerId) || 0,
customerName: String(row.customerName),
plateNo: String(row.plateNo),
source: String(row.source),
verifyStatus: String(row.verifyStatus),
vehicleScope:
row.vehicleId === null || row.vehicleId === undefined
? "external"
: "lingniu",
kg: number(row.kg, 3),
unitPrice: number(row.unitPrice),
cost: number(row.cost),
revenue: number(row.revenue),
})),
page: { page, pageSize, hasMore: recordRows[0].length === pageSize },
};
}
export function registerHydrogenBiV2Routes(
app: Hono,
{ hydrogenPool }: HydrogenBiV2Dependencies,
) {
app.get("/h2/v2/meta", async (c) => c.json(await meta(hydrogenPool)));
app.get("/h2/v2/overview", async (c) =>
c.json(
await overview(
hydrogenPool,
resolveFilter((key) => c.req.query(key)),
),
),
);
app.get("/h2/v2/daily", async (c) =>
c.json(
await daily(
hydrogenPool,
resolveFilter((key) => c.req.query(key)),
),
),
);
app.get("/h2/v2/daily-tree", async (c) => {
const date = c.req.query("date");
if (!validDate(date))
return c.json({ message: "date 必须为有效的 YYYY-MM-DD" }, 400);
const filter = resolveFilter((key) =>
key === "date" ? date : c.req.query(key),
);
return c.json(await dailyTree(hydrogenPool, filter));
});
app.get("/h2/v2/drill", async (c) => {
const groupBy: GroupBy = [
"station",
"customer",
"date",
"vehicle",
"record",
].includes(c.req.query("groupBy") ?? "")
? (c.req.query("groupBy") as GroupBy)
: "station";
const requestedPage = Number(c.req.query("page"));
const page =
Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
const requestedSize = Number(c.req.query("pageSize"));
const pageSize =
Number.isInteger(requestedSize) && requestedSize > 0
? Math.min(requestedSize, 200)
: 100;
return c.json(
await drill(
hydrogenPool,
resolveFilter((key) => c.req.query(key)),
groupBy,
page,
pageSize,
resolveAmountScope(c.req.query("amountScope")),
),
);
});
}
@@ -0,0 +1,153 @@
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_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_BASE_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);
});
}
+24 -7
View File
@@ -31,20 +31,24 @@ export function registerHydrogenDailyRoute(
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 verifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}${verifyScope === 'verified' ? '&verify=verified' : ''}`, 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'),
customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id'),
...(verifyScope === 'verified'
? ["LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'"]
: []),
].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,
@@ -52,6 +56,7 @@ export function registerHydrogenDailyRoute(
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,
ROUND(SUM(b.cost_total), 2) AS fee,
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b
@@ -64,21 +69,30 @@ export function registerHydrogenDailyRoute(
// 站点环比:同站点上一条记录的 kg
// 按 stationId 分组、按日期升序计算
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
type StationRow = {
date: string;
stationId: number;
name: string;
kg: number;
fee: 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,
fee: Number(r.fee) || 0,
pricePerKg: Number(r.pricePerKg) || 0,
}));
// 计算日级总量 + 日级环比
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
const dayMap = new Map<string, { totalKg: number; totalFee: number; stations: typeof flat }>();
for (const s of flat) {
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, totalFee: 0, stations: [] });
const e = dayMap.get(s.date)!;
e.totalKg += s.kg;
e.totalFee += s.fee;
e.stations.push(s);
}
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
@@ -115,13 +129,16 @@ export function registerHydrogenDailyRoute(
return {
date,
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
totalFee: info ? Math.round(info.totalFee * 100) / 100 : 0,
chainPct: dayChainPct.get(date) ?? 0,
customerType: customer,
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
id: s.stationId,
name: s.name,
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
kg: Math.round(s.kg * 100) / 100,
fee: Math.round(s.fee * 100) / 100,
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
}))
: [],
@@ -0,0 +1,271 @@
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';
export interface HydrogenOverviewDetailDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
type VehicleScope = 'all' | 'lingniu' | 'external';
type VerifyScope = 'all' | 'verified';
type GroupBy = 'station' | 'customer' | 'vehicle';
export function registerHydrogenOverviewDetailRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenOverviewDetailDependencies,
) {
app.get('/hydrogen/overview-detail', async (c) => {
const requestedYear = Number(c.req.query('year'));
const year = Number.isInteger(requestedYear) && requestedYear >= 2020 && requestedYear <= 2100
? requestedYear
: new Date().getFullYear();
const scopeParam = c.req.query('vehicleScope');
const vehicleScope: VehicleScope = scopeParam === 'lingniu' || scopeParam === 'external' ? scopeParam : 'all';
const verifyScope: VerifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
const stationValue = Number(c.req.query('stationId'));
const stationId = Number.isInteger(stationValue) && stationValue > 0 ? stationValue : null;
const month = /^\d{4}-\d{2}$/.test(c.req.query('month') || '') ? c.req.query('month')! : null;
const date = /^\d{4}-\d{2}-\d{2}$/.test(c.req.query('date') || '') ? c.req.query('date')! : null;
const customerName = (c.req.query('customerName') || '').trim().slice(0, 128) || null;
const customerValue = Number(c.req.query('customerId'));
const customerId = Number.isInteger(customerValue) && customerValue >= 0 ? customerValue : null;
const plateNo = (c.req.query('plateNo') || '').trim().slice(0, 32) || null;
const region = (c.req.query('region') || '').trim().slice(0, 64) || null;
const regionGranularity = c.req.query('regionGranularity') === 'city' ? 'city' : 'province';
const groupByParam = c.req.query('groupBy');
const groupBy: GroupBy | null = groupByParam === 'station' || groupByParam === 'customer' || groupByParam === 'vehicle' ? groupByParam : null;
const requestedLimit = Number(c.req.query('limit'));
// 普通下钻避免一次返回过多账本;导出明确申请时,仍限制在可控范围。
const recordLimit = Number.isInteger(requestedLimit) && requestedLimit > 0
? Math.min(requestedLimit, 20_000)
: 2_000;
const force = c.req.query('force') === '1';
const clauses = [HYDROGEN_BASE_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `YEAR(b.${HYDROGEN_LOCAL}) = ?`];
const params: unknown[] = [HYDROGEN_MIN_DATE, year];
if (vehicleScope === 'lingniu') clauses.push('b.vehicle_id IS NOT NULL');
if (vehicleScope === 'external') clauses.push('b.vehicle_id IS NULL');
if (verifyScope === 'verified') {
clauses.push("LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'");
}
if (stationId) { clauses.push('b.station_id = ?'); params.push(stationId); }
if (month) { clauses.push(`DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ?`); params.push(month); }
if (date) { clauses.push(`DATE(b.${HYDROGEN_LOCAL}) = ?`); params.push(date); }
if (customerName) {
clauses.push(`COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') = ?`);
params.push(customerName);
}
if (customerId !== null) {
clauses.push('COALESCE(b.system_customer_id, b.customer_id, 0) = ?');
params.push(customerId);
}
if (plateNo) {
clauses.push(`COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?`);
params.push(plateNo);
}
if (region) {
clauses.push(regionGranularity === 'city'
? `COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') = ?`
: `COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') = ?`);
params.push(region);
}
const cacheKey = [
`hydrogen/overview-detail?year=${year}`,
`vehicle=${vehicleScope}`,
verifyScope !== 'all' ? `verify=${verifyScope}` : '',
stationId ? `station=${stationId}` : '',
month ? `month=${month}` : '',
date ? `date=${date}` : '',
customerName ? `customer=${customerName}` : '',
customerId !== null ? `customerId=${customerId}` : '',
plateNo ? `plate=${plateNo}` : '',
region ? `${regionGranularity}=${region}` : '',
groupBy ? `groupBy=${groupBy}` : '',
].filter(Boolean).join('&');
const data = await cached(cacheKey, async () => {
const [summaryRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT CONCAT(
COALESCE(b.system_customer_id, b.customer_id, 0), '|',
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
)) AS customerCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district dp
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dp.STATUS = 'VALID'
LEFT JOIN common_district dc
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dc.STATUS = 'VALID'
WHERE ${clauses.join(' AND ')}`,
params,
);
// 总览第一层先返回业务维度汇总;只有用户点选某一行时,才读取账本流水。
const groupSelect = groupBy === 'station'
? `COALESCE(b.station_id, 0) AS id,
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS name,
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
COUNT(*) AS recordCount,
1 AS stationCount,
COUNT(DISTINCT CONCAT(COALESCE(b.system_customer_id, b.customer_id, 0), '|', COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户'))) AS customerCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue`
: groupBy === 'customer'
? `COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
NULL AS province,
NULL AS city,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
1 AS customerCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue`
: `COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id,
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name,
NULL AS province,
NULL AS city,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT CONCAT(COALESCE(b.system_customer_id, b.customer_id, 0), '|', COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户'))) AS customerCount,
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
CASE WHEN MAX(CASE WHEN b.vehicle_id IS NULL THEN 0 ELSE 1 END) = 1 THEN 'lingniu' ELSE 'external' END AS vehicleScope,
COALESCE(NULLIF(MAX(b.record_source), ''), CAST(MAX(b.source) AS CHAR), '未知来源') AS source,
COALESCE(NULLIF(MAX(b.verify_status), ''), 'UNVERIFIED') AS verifyStatus`;
const groupBySql = groupBy === 'station'
? `COALESCE(b.station_id, 0), COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点'), COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属'), COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属')`
: groupBy === 'customer'
? `COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')`
: `COALESCE(NULLIF(b.license_plate, ''), '无车牌')`;
const [groupRows] = groupBy
? await hydrogenPool.query<RowDataPacket[]>(
`SELECT ${groupSelect}
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district dp
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dp.STATUS = 'VALID'
LEFT JOIN common_district dc
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dc.STATUS = 'VALID'
WHERE ${clauses.join(' AND ')}
GROUP BY ${groupBySql}
ORDER BY kg DESC, recordCount DESC
LIMIT 200`,
params,
)
: [[] as RowDataPacket[]];
const [rows] = groupBy ? [[] as RowDataPacket[]] : await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.id,
b.order_no AS orderNo,
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS refuelTime,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS stationName,
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
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.amount_kg, 0), 3) AS kg,
ROUND(COALESCE(b.cost_price, 0), 2) AS costPrice,
ROUND(COALESCE(b.cost_total, 0), 2) AS cost,
ROUND(COALESCE(b.customer_price, 0), 2) AS customerPrice,
ROUND(COALESCE(b.fee_total, 0), 2) AS revenue,
b.payment_status AS paymentStatus
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district dp
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dp.STATUS = 'VALID'
LEFT JOIN common_district dc
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dc.STATUS = 'VALID'
WHERE ${clauses.join(' AND ')}
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC
LIMIT ${recordLimit}`,
params,
);
const groups = groupRows.map(row => ({
id: groupBy === 'vehicle' ? String(row.id || '无车牌') : Number(row.id) || 0,
name: String(row.name || (groupBy === 'station' ? '未关联站点' : groupBy === 'customer' ? '未关联客户' : '无车牌')),
province: row.province === null || row.province === undefined ? null : String(row.province),
city: row.city === null || row.city === undefined ? null : String(row.city),
recordCount: Number(row.recordCount) || 0,
stationCount: Number(row.stationCount) || 0,
customerCount: Number(row.customerCount) || 0,
kg: Number(row.kg) || 0,
cost: Number(row.cost) || 0,
customerCost: Number(row.customerCost) || 0,
revenue: Number(row.revenue) || 0,
vehicleScope: row.vehicleScope === 'lingniu' ? 'lingniu' : row.vehicleScope === 'external' ? 'external' : undefined,
source: row.source === null || row.source === undefined ? undefined : String(row.source),
verifyStatus: row.verifyStatus === null || row.verifyStatus === undefined ? undefined : String(row.verifyStatus),
}));
const records = rows.map(row => ({
id: Number(row.id),
orderNo: String(row.orderNo || ''),
refuelTime: String(row.refuelTime),
stationId: Number(row.stationId) || 0,
stationName: String(row.stationName),
province: String(row.province),
city: String(row.city),
customerId: Number(row.customerId) || 0,
customerName: String(row.customerName),
plateNo: String(row.plateNo),
vehicleScope: row.vehicleId === null || row.vehicleId === undefined ? 'external' as const : 'lingniu' as const,
source: String(row.source),
verifyStatus: String(row.verifyStatus),
kg: Number(row.kg) || 0,
costPrice: Number(row.costPrice) || 0,
cost: Number(row.cost) || 0,
customerPrice: Number(row.customerPrice) || 0,
revenue: Number(row.revenue) || 0,
paymentStatus: row.paymentStatus === null || row.paymentStatus === undefined ? null : Number(row.paymentStatus),
}));
const summaryRow = summaryRows[0] || {};
const recordCount = Number(summaryRow.recordCount) || 0;
return {
filter: { year, vehicleScope, verifyScope, stationId, month, date, customerName, customerId, plateNo, region, regionGranularity, groupBy },
groupBy,
groups,
truncated: !groupBy && recordCount > rows.length,
summary: {
recordCount,
stationCount: Number(summaryRow.stationCount) || 0,
customerCount: Number(summaryRow.customerCount) || 0,
kg: Number(summaryRow.kg) || 0,
cost: Number(summaryRow.cost) || 0,
customerCost: Number(summaryRow.customerCost) || 0,
revenue: Number(summaryRow.revenue) || 0,
},
records,
};
}, { force });
return c.json(data);
});
}
+130 -29
View File
@@ -15,7 +15,68 @@ export interface HydrogenOverviewDependencies {
cached: typeof cached;
}
// 氢能总览:KPI + Top5 + 区域占比。
type VehicleScope = 'all' | 'lingniu' | 'external';
type VerifyScope = 'all' | 'verified';
interface OverviewFilter {
cacheKey: string;
ledgerSql: string;
billSql: string;
params: number[];
stationId: number | null;
vehicleScope: VehicleScope;
verifyScope: VerifyScope;
}
function overviewFilter(
stationIdParam: string | undefined,
vehicleScopeParam: string | undefined,
verifyScopeParam: string | undefined,
): OverviewFilter {
const stationId = Number(stationIdParam);
const safeStationId = Number.isInteger(stationId) && stationId > 0 ? stationId : null;
const vehicleScope: VehicleScope = vehicleScopeParam === 'lingniu' || vehicleScopeParam === 'external'
? vehicleScopeParam
: 'all';
const verifyScope: VerifyScope = verifyScopeParam === 'verified' ? 'verified' : 'all';
const clauses: string[] = [];
const params: number[] = [];
if (safeStationId) {
clauses.push('station_id = ?');
params.push(safeStationId);
}
if (vehicleScope === 'lingniu') clauses.push('vehicle_id IS NOT NULL');
if (vehicleScope === 'external') clauses.push('vehicle_id IS NULL');
// Only use the ledger's persisted verification state; never infer verification
// from import source or payment status.
if (verifyScope === 'verified') {
clauses.push("LOWER(COALESCE(NULLIF(TRIM(verify_status), ''), 'unverified')) = 'verified'");
}
const suffix = clauses.length ? ` AND ${clauses.join(' AND ')}` : '';
const billSuffix = suffix
.replaceAll('station_id', 'b.station_id')
.replaceAll('vehicle_id', 'b.vehicle_id')
.replaceAll('verify_status', 'b.verify_status');
const cacheParts = [
safeStationId ? `station=${safeStationId}` : '',
vehicleScope !== 'all' ? `vehicle=${vehicleScope}` : '',
verifyScope !== 'all' ? `verify=${verifyScope}` : '',
].filter(Boolean);
return {
cacheKey: cacheParts.length ? `&${cacheParts.join('&')}` : '',
ledgerSql: suffix,
billSql: billSuffix,
params,
stationId: safeStationId,
vehicleScope,
verifyScope,
};
}
// 氢能总览:KPI + 趋势 + 站点/区域/客户汇总。筛选只使用账本中可验证的站点和车辆归属字段。
export function registerHydrogenOverviewRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenOverviewDependencies,
@@ -23,26 +84,35 @@ export function registerHydrogenOverviewRoute(
app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const filter = overviewFilter(c.req.query('stationId'), c.req.query('vehicleScope'), c.req.query('verifyScope'));
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
const data = await cached(`hydrogen/overview?year=${requestedYear}${filter.cacheKey}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
`SELECT YEAR(${HYDROGEN_LOCAL}) AS y,
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${HYDROGEN_LOCAL})
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
// 数据源时间来自账本,不等同于前端请求完成时间。
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
// The watermark is calculated with the active scope below. The year list
// remains global metadata so users can still select a year with no matching
// verified/vehicle/station records.
let latestLedgerTime: string | null = null;
const isCurrentYear = year === todayYear;
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
// yearProfit 是客户单毛利:仅以存在对客价/对客收入的订单成本抵扣对客收入。
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT
`SELECT DATE_FORMAT(MAX(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? THEN ${HYDROGEN_LOCAL} END), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
@@ -80,13 +150,14 @@ export function registerHydrogenOverviewRoute(
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`,
[year, year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
HYDROGEN_MIN_DATE],
HYDROGEN_MIN_DATE, ...filter.params],
);
const k = kpiRows[0] ?? {};
latestLedgerTime = typeof k.latestLedgerTime === 'string' ? k.latestLedgerTime : null;
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
@@ -128,15 +199,16 @@ export function registerHydrogenOverviewRoute(
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
rank: i + 1,
id: Number(r.id) || 0,
name: r.name as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
@@ -149,21 +221,28 @@ export function registerHydrogenOverviewRoute(
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
COALESCE(MAX(d.NAME), '未归属') AS province,
SUM(b.amount_kg) AS kg,
SUM(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'
LEFT JOIN common_district d
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND d.STATUS = 'VALID'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
id: Number(r.id) || 0,
name: r.name as string,
province: r.province as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
@@ -174,6 +253,7 @@ export function registerHydrogenOverviewRoute(
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN NULLIF(TRIM(d.NAME), '') IS NOT NULL THEN d.NAME
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
@@ -181,18 +261,23 @@ export function registerHydrogenOverviewRoute(
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
ELSE '未归属区域'
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
-- 历史站点城市编码与行政区字典的排序规则不同,显式统一避免线上连接报错。
LEFT JOIN common_district d
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND d.STATUS = 'VALID'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
@@ -212,61 +297,77 @@ export function registerHydrogenOverviewRoute(
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(CASE WHEN vehicle_id IS NOT NULL THEN amount_kg ELSE 0 END), 2) AS lingniuKg,
ROUND(SUM(CASE WHEN vehicle_id IS NULL THEN amount_kg ELSE 0 END), 2) AS externalKg,
ROUND(SUM(cost_total), 2) AS fee,
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
const monthMap = new Map<string, { kg: number; lingniuKg: number; externalKg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
lingniuKg: Number(r.lingniuKg) || 0,
externalKg: Number(r.externalKg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
const monthly: { month: string; kg: number; lingniuKg: number; externalKg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
const v = monthMap.get(key) || { kg: 0, lingniuKg: 0, externalKg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, lingniuKg: v.lingniuKg, externalKg: v.externalKg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top(指定年份;按加氢量降序,前 30)
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
// 客户账单 Top(指定年份;按加氢量降序,前 30)
// 同一客户可能同时存在客户承担与羚牛承担订单,必须显示为混合,不能按任一条记录覆盖。
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
ELSE 'customer' END AS payer,
CASE
WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0 THEN 1 ELSE 0 END) > 0 THEN 'mixed'
WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0 THEN 'customer'
ELSE 'lingniu'
END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
payer: (r.payer as string) === 'lingniu'
? 'lingniu' as const
: (r.payer as string) === 'mixed'
? 'mixed' as const
: 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
return { kpi, top5, regions, monthly, customers, stations, availableYears, year, latestLedgerTime, filter: {
stationId: filter.stationId,
vehicleScope: filter.vehicleScope,
verifyScope: filter.verifyScope,
} };
}, { force });
return c.json(data);
});
@@ -0,0 +1,119 @@
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);
});
}
@@ -0,0 +1,263 @@
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_TABLE } from './constants.js';
import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js';
export interface HydrogenStationBoardDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
function safeStationId(value: string | undefined): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function numberValue(value: unknown): number {
return Number(value) || 0;
}
// 单站经营看板只做只读聚合。站点列表保留区间内零业务站点,便于核对站点覆盖范围。
export function registerHydrogenStationBoardRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenStationBoardDependencies,
) {
app.get('/hydrogen/station-board', async (c) => {
const range = resolveDateRange(
'last15',
c.req.query('startDate'),
c.req.query('endDate'),
);
const stationId = safeStationId(c.req.query('stationId'));
const force = c.req.query('force') === '1';
const cacheKey = `hydrogen/station-board?start=${range.start}&end=${range.end}${stationId ? `&station=${stationId}` : ''}`;
const data = await cached(cacheKey, async () => {
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT s.id,
COALESCE(NULLIF(s.station_short_name, ''), NULLIF(s.station_name, ''), CONCAT('未知站点 #', s.id)) AS name,
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
ROUND(COALESCE(SUM(b.amount_kg), 0), 2) AS kg,
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS fee,
COUNT(b.id) AS recordCount,
DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
FROM hydrogen_station s
LEFT JOIN ${HYDROGEN_TABLE} b
ON b.station_id = s.id
AND ${HYDROGEN_BASE_WHERE_B}
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
LEFT JOIN common_district dp
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dp.STATUS = 'VALID'
LEFT JOIN common_district dc
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND dc.STATUS = 'VALID'
WHERE s.del_flag = '0'
GROUP BY s.id, s.station_short_name, s.station_name, dp.NAME, s.province, dc.NAME, s.city
ORDER BY kg DESC, name ASC`,
[range.start, range.end],
);
const [paymentRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT station_id AS stationId,
ROUND(SUM(amount), 2) AS amount,
COUNT(*) AS paymentCount,
DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
FROM hydrogen_station_payment
WHERE del_flag = '0'
AND payment_date >= ?
AND payment_date <= ?
GROUP BY station_id`,
[range.start, range.end],
);
const [summaryDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`,
[range.start, range.end],
);
const [stationDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS stationId,
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(SUM(b.amount_kg), 2) AS kg
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY stationId ASC, date ASC`,
[range.start, range.end],
);
const [summaryPaymentDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
ROUND(SUM(amount), 2) AS amount,
COUNT(*) AS paymentCount
FROM hydrogen_station_payment
WHERE del_flag = '0'
AND payment_date >= ?
AND payment_date <= ?
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
ORDER BY date ASC`,
[range.start, range.end],
);
const paymentByStation = new Map(paymentRows.map(row => [numberValue(row.stationId), row]));
const dailyKgByStation = new Map<number, Map<string, number>>();
for (const row of stationDailyRows) {
const id = numberValue(row.stationId);
const values = dailyKgByStation.get(id) ?? new Map<string, number>();
values.set(String(row.date), numberValue(row.kg));
dailyKgByStation.set(id, values);
}
const totalKg = stationRows.reduce((sum, row) => sum + numberValue(row.kg), 0);
const stations = stationRows.map(row => {
const id = numberValue(row.id);
const payment = paymentByStation.get(id);
return {
id,
name: String(row.name),
province: String(row.province),
city: String(row.city),
kg: numberValue(row.kg),
fee: numberValue(row.fee),
recordCount: numberValue(row.recordCount),
paymentAmount: numberValue(payment?.amount),
paymentCount: numberValue(payment?.paymentCount),
share: totalKg > 0 ? numberValue(row.kg) / totalKg : 0,
latestLedgerTime: typeof row.latestLedgerTime === 'string' ? row.latestLedgerTime : null,
latestPaymentDate: typeof payment?.latestPaymentDate === 'string' ? payment.latestPaymentDate : null,
dailyKg: enumerateDateRange(range.start, range.end).map(date => ({
date,
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
})),
};
});
let selected = null;
if (stationId) {
const [dailyRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
ROUND(AVG(NULLIF(b.cost_price, 0)), 2) AS avgPrice,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.station_id = ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`,
[stationId, range.start, range.end],
);
const [dailyPaymentRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
ROUND(SUM(amount), 2) AS amount,
COUNT(*) AS paymentCount
FROM hydrogen_station_payment
WHERE del_flag = '0'
AND station_id = ?
AND payment_date >= ?
AND payment_date <= ?
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
ORDER BY date ASC`,
[stationId, range.start, range.end],
);
const paymentByDate = new Map(dailyPaymentRows.map(row => [String(row.date), row]));
const ledgerByDate = new Map(dailyRows.map(row => [String(row.date), row]));
let previousKg = 0;
const daily = enumerateDateRange(range.start, range.end).map(date => {
const ledger = ledgerByDate.get(date);
const kg = numberValue(ledger?.kg);
const payment = paymentByDate.get(date);
const result = {
date,
kg,
fee: numberValue(ledger?.fee),
avgPrice: numberValue(ledger?.avgPrice),
recordCount: numberValue(ledger?.recordCount),
changeKg: kg - previousKg,
paymentAmount: numberValue(payment?.amount),
paymentCount: numberValue(payment?.paymentCount),
};
previousKg = kg;
return result;
});
const [customerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
COALESCE(NULLIF(MAX(b.system_customer_name), ''), NULLIF(MAX(b.customer_name), ''), '未关联客户') AS customerName,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.station_id = ?
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), COALESCE(b.system_customer_id, b.customer_id, 0)
ORDER BY month ASC, kg DESC`,
[stationId, range.end, range.end],
);
selected = {
daily,
customerMonths: customerMonthRows.map(row => ({
month: String(row.month),
customerName: String(row.customerName),
kg: numberValue(row.kg),
fee: numberValue(row.fee),
recordCount: numberValue(row.recordCount),
})),
};
}
const paymentTotal = stations.reduce((sum, station) => sum + station.paymentAmount, 0);
const summaryLedgerByDate = new Map(summaryDailyRows.map(row => [String(row.date), row]));
const summaryPaymentByDate = new Map(summaryPaymentDailyRows.map(row => [String(row.date), row]));
const summaryDaily = enumerateDateRange(range.start, range.end).map(date => {
const ledger = summaryLedgerByDate.get(date);
const payment = summaryPaymentByDate.get(date);
return {
date,
kg: numberValue(ledger?.kg),
fee: numberValue(ledger?.fee),
recordCount: numberValue(ledger?.recordCount),
paymentAmount: numberValue(payment?.amount),
paymentCount: numberValue(payment?.paymentCount),
};
});
const latestLedgerTime = stations
.map(station => station.latestLedgerTime)
.filter((value): value is string => Boolean(value))
.sort()
.at(-1) ?? null;
return {
range,
summary: {
stationCount: stations.length,
activeStationCount: stations.filter(station => station.kg > 0).length,
totalKg,
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
recordCount: stations.reduce((sum, station) => sum + station.recordCount, 0),
paymentAmount: paymentTotal,
paymentCount: stations.reduce((sum, station) => sum + station.paymentCount, 0),
latestLedgerTime,
daily: summaryDaily,
},
stations,
selected,
};
}, { force });
return c.json(data);
});
}
+12
View File
@@ -8,8 +8,14 @@ import {
registerElectricMonthlyRoute,
registerElectricOverviewRoute,
} from './electric.js';
import { registerEtcOverviewRoute } from './etc.js';
import { registerHydrogenDailyRoute } from './hydrogen-daily.js';
import { registerHydrogenDailyDetailRoute } from './hydrogen-daily-detail.js';
import { registerHydrogenOverviewRoute } from './hydrogen-overview.js';
import { registerHydrogenOverviewDetailRoute } from './hydrogen-overview-detail.js';
import { registerHydrogenSettlementRoute } from './hydrogen-settlement.js';
import { registerHydrogenStationBoardRoute } from './hydrogen-station-board.js';
import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js';
const app = new Hono();
@@ -26,8 +32,14 @@ app.use('*', async (c, next) => {
const cacheDependency = { cached };
registerHydrogenOverviewRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenOverviewDetailRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenDailyRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenDailyDetailRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenSettlementRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenStationBoardRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenBiV2Routes(app, { hydrogenPool });
registerElectricOverviewRoute(app, { ...cacheDependency, pool });
registerElectricMonthlyRoute(app, { ...cacheDependency, pool });
registerEtcOverviewRoute(app, { ...cacheDependency, pool });
export default app;
+3 -3
View File
@@ -7,9 +7,9 @@ import {
resolveDateRange,
} from './query-model.js';
test('客户范围继续让外部车辆返回空数据', () => {
assert.equal(customerClause('external'), '1=0');
assert.equal(customerClause('lingniu'), '1=1');
test('车辆归属按账本 vehicle_id 区分羚牛和外部车辆', () => {
assert.equal(customerClause('external'), 'vehicle_id IS NULL');
assert.equal(customerClause('lingniu'), 'vehicle_id IS NOT NULL');
assert.equal(customerClause('all'), '1=1');
});
+4 -3
View File
@@ -8,10 +8,11 @@ export interface EnergyDateRange {
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据
// 车辆归属以账本 vehicle_id 为准,与氢能总览的筛选口径一致
// 账本未关联车辆的记录归入外部车辆,避免在日报中遗漏真实加氢数据。
export function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
if (customer === 'external') return 'vehicle_id IS NULL';
if (customer === 'lingniu') return 'vehicle_id IS NOT NULL';
return '1=1';
}
File diff suppressed because it is too large Load Diff