Files
ln-bi/src/server/routes/energy/repository.ts
T
dsh-agent 9750d35ba4 refactor(stage15): 氢能 v2 移入 repository,能源域完成并加入守护清单
改动
- hydrogen-bi-v2.ts 的 14 条查询移入 energy/repository.ts(SQL 与参数表达式逐条对应)。
- 片段组装也随之搬走,因为它们本质是 SQL 构造,留在路由里会让"路由不含 SQL"形同虚设:
  resolvedWhere / stationMasterRegionSql / buildFilterClauses / buildGroupSelect /
  buildGroupExpression / buildScopedWhere。
- 承担方映射片段(CUSTOMER/COMPANY/OTHER_BEARING_ORDER)移入 constants.ts 供多处共享。
- 结果形状从 mysql2 的 [rows, fields] 改为直接返回 rows,路由侧 13 处 [0] 索引同步调整。
- 架构守护的"已完整分层"清单加入 energy(现为 7 个域)。

提取方式与一次失误
- 查询文本用"反引号定界 + 括号配对"扫描提取。第一次用纯括号配对扫描时,
  因为把 SQL 文本里的括号也算了进去,导致调用点跨度越界、文件被改坏;
  已从 git 恢复并改用反引号定界(并对跨度做了"严格递增且不重叠"的断言)后一次通过。
- 中途还有一次变量写错文件(把 repository 内容写进了路由文件),
  被随后的正常写入覆盖,未造成实际损坏;已核对两个文件的首行与关键符号确认无误。

验证
- energy/routes.test.ts 的 13 个用例(含氢能 v2 的口径、利润、账单、路由顺序、
  SQL 与参数指纹)在提取前后全部通过。
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。
2026-09-11 10:53:56 +08:00

772 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { RowDataPacket } from 'mysql2';
import { dateRangeClause, type CustomerKind } from './query-model.js';
import {
COMPANY_BEARING_ORDER,
CUSTOMER_BEARING_ORDER,
EXTERNAL_CUSTOMER_MAP_CTE,
OTHER_BEARING_ORDER,
HYDROGEN_BASE_WHERE,
HYDROGEN_BASE_WHERE_B,
HYDROGEN_FUEL_ONLY_WHERE,
HYDROGEN_FUEL_ONLY_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
HYDROGEN_TENANT_ID,
MANUAL_RECHARGE_SOURCE,
NORMALIZED_PLATE,
NORMALIZED_PLATE_UNICODE,
} from './constants.js';
/**
* 能源域的数据访问。
*
* 电能 / ETC / 单站日报 / 氢能 v2 的 SQL 都集中在这里;路由只做校验、编排与口径计算。
* SQL 文本与参数顺序由 routes.test.ts 的契约测试锁定(该文件对多处查询做了指纹断言)。
*/
/** 只需要 query 能力的连接(主库或氢能库)。 */
export interface Database {
query<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
// ---------------------------------------------------------------------------
// ETC
// ---------------------------------------------------------------------------
/** ETC 台账与账单汇总;空表返回全 0,由路由判定 hasData。 */
export async function loadEtcOverview(db: Database): Promise<Record<string, unknown>> {
const [rows] = await db.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`,
);
return (rows[0] ?? {}) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// 电能
// ---------------------------------------------------------------------------
/** 电能总览 KPI(全量 + 本月 + 本日)。 */
export async function loadElectricKpi(db: Database): Promise<Record<string, unknown>> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT
SUM(kwh) AS totalKwh,
SUM(fee) AS totalFee,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN kwh ELSE 0 END) AS monthKwh,
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,
DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestChargeTime
FROM bi_ele_charge_record`,
);
return (rows[0] ?? {}) as Record<string, unknown>;
}
/** 本月逐日电量/费用(柱图)。 */
export async function loadElectricCurrentMonthTrend(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
GROUP BY date
ORDER BY date ASC`,
);
return rows;
}
/** 本月无数据时的降级:最近一个有数据的自然月。 */
export async function loadElectricLatestMonthTrend(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
)
GROUP BY date
ORDER BY date ASC`,
);
return rows;
}
/** 昨日电量(用于本日环比)。 */
export async function loadElectricPreviousDayKwh(db: Database): Promise<number> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT SUM(kwh) AS kwh
FROM bi_ele_charge_record
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
);
return Number(rows[0]?.kwh) || 0;
}
/** 车辆归属筛选片段。internal=我司,external=外部;其余不过滤。 */
export function electricKindClause(customer: CustomerKind): string {
if (customer === 'lingniu') return `vehicle_kind = 'internal'`;
if (customer === 'external') return `vehicle_kind = 'external'`;
return '1=1';
}
/** 按日电量/费用(区间)。 */
export async function loadElectricDailyRows(
db: Database,
args: { customer: CustomerKind; start: string; end: string },
): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE ${electricKindClause(args.customer)}
AND ${dateRangeClause('start_time')}
GROUP BY date`,
[args.start, args.end],
);
return rows;
}
// ---------------------------------------------------------------------------
// 氢能单站日报(/hydrogen/station-board
// ---------------------------------------------------------------------------
/** 区间内有有效加氢记录的站点列表。 */
export async function loadStationList(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.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 b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
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`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
/** 区间内现结流水(按站点汇总)。 */
export async function loadRangePayments(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.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`, [start, end]);
return rows;
}
/** 全部站点逐日汇总。 */
export async function loadSummaryDaily(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.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 b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
/** 站点逐日汇总。 */
export async function loadStationDaily(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.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 b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY stationId ASC, date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
/** 全部站点逐日现结。 */
export async function loadSummaryPaymentDaily(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.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`, [start, end]);
return rows;
}
/** 指定站点逐日加氢。 */
export async function loadStationDailyForStation(db: Database, args: { stationId: number; start: string; end: string }): Promise<RowDataPacket[]> {
const { stationId, start, end } = args;
const [rows] = await db.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.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, start, end]);
return rows;
}
/** 指定站点逐日现结。 */
export async function loadStationPaymentDaily(db: Database, args: { stationId: number; start: string; end: string }): Promise<RowDataPacket[]> {
const { stationId, start, end } = args;
const [rows] = await db.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, start, end]);
return rows;
}
/** 指定站点的客户月度汇总。 */
export async function loadCustomerMonth(db: Database, args: { stationId: number; end: string }): Promise<RowDataPacket[]> {
const { stationId, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`${EXTERNAL_CUSTOMER_MAP_CTE}
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_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
LEFT JOIN external_vehicle_map m
ON CONVERT(${NORMALIZED_PLATE('b.license_plate')} USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(m.normalizedPlate USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
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(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户')
ORDER BY month ASC, kg DESC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, end, end]);
return rows;
}
/** 指定站点的外部客户月度汇总(含映射 CTE)。 */
export async function loadExternalCustomerMonth(db: Database, args: { stationId: number; end: string }): Promise<RowDataPacket[]> {
const { stationId, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`${EXTERNAL_CUSTOMER_MAP_CTE}
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
m.customerName,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(COALESCE(b.fee_total, b.cost_total)), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
INNER JOIN external_vehicle_map m
ON ${NORMALIZED_PLATE_UNICODE('b.license_plate')}
= m.normalizedPlate
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
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'), m.customerName
ORDER BY month ASC, kg DESC, m.customerName ASC`, [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, end, end]);
return rows;
}
/** 外部客户进账(自动 + 手工充值合并)。 */
export async function loadExternalReceipts(db: Database, args: { start: string; end: string }): Promise<RowDataPacket[]> {
const { start, end } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT * FROM (
SELECT CONCAT('auto:', CAST(id AS CHAR)) AS id,
DATE_FORMAT(summary_date, '%Y-%m-%d') AS date,
customer_name AS customerName,
amount,
pay_method AS payMethod,
source,
spot_record_count AS sourceRecordCount,
DATE_FORMAT(COALESCE(refresh_time, update_time), '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM hydrogen_external_recharge_daily_summary
WHERE tenant_id = ?
AND summary_date >= ?
AND summary_date <= ?
UNION ALL
SELECT CONCAT('manual:', CAST(id AS CHAR)) AS id,
DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
COALESCE(NULLIF(customer_name, ''), '未关联客户') AS customerName,
fee_total AS amount,
recharge_pay_method AS payMethod,
record_source AS source,
1 AS sourceRecordCount,
DATE_FORMAT(update_time, '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND tenant_id = ?
AND record_source = ?
AND ${HYDROGEN_LOCAL} >= ?
AND ${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
) externalReceipts
ORDER BY date DESC, customerName ASC, id ASC`, [HYDROGEN_TENANT_ID, start, end, HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
return rows;
}
// ---------------------------------------------------------------------------
// 氢能 v2/h2/v2/*
// ---------------------------------------------------------------------------
export type RegionGranularity = 'province' | 'city';
/** 区域筛选所需的最小输入(与路由的 Filter 结构兼容)。 */
export interface RegionFilter {
clauses: string[];
params: unknown[];
region: string | null;
regionGranularity: RegionGranularity;
}
export function stationMasterRegionSql(granularity: RegionGranularity) {
const districtColumn = granularity === "province" ? "rs.province" : "rs.city";
const stationName = "COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_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(NULLIF(rd.NAME, ''), ${fallback})`;
}
export async function resolvedWhere(
db: Database,
filter: RegionFilter,
) {
const clauses = [...filter.clauses];
const params = [...filter.params];
if (filter.region) {
// Resolve the small station master once. The old implementation ran two
// correlated station/district subqueries for every ledger row and repeated
// that work in summary, grouping and record queries.
const districtColumn =
filter.regionGranularity === "province" ? "rs.province" : "rs.city";
const [rows] = await db.query<RowDataPacket[]>(
`SELECT CAST(rs.id AS CHAR) AS id
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.del_flag = '0'
AND ${stationMasterRegionSql(filter.regionGranularity)} = ?`,
[filter.region],
);
const stationIds = [...new Set(rows.map((row) => String(row.id)))];
if (stationIds.length === 0) {
clauses.push("1 = 0");
} else {
clauses.push(`b.station_id IN (${stationIds.map(() => "?").join(", ")})`);
params.push(...stationIds);
}
}
return { sql: clauses.join(" AND "), params };
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadMetaYears(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.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 ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${HYDROGEN_LOCAL})
ORDER BY value DESC`, [HYDROGEN_MIN_DATE]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadMetaStations(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.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'
AND EXISTS (
SELECT 1
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND b.station_id = s.id
AND COALESCE(b.amount_kg, 0) > 0
)
ORDER BY name`, []);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadMetaWatermark(db: Database): Promise<RowDataPacket[]> {
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?`, [HYDROGEN_MIN_DATE]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewSummary(db: Database, args: { sqlWhere: string; params: unknown[]; month: string; endDate: string }): Promise<RowDataPacket[]> {
const { sqlWhere, params, month, endDate } = args;
const [rows] = await db.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, endDate, endDate, ...params]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewMonthly(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.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`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewStations(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.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)
HAVING SUM(COALESCE(b.amount_kg, 0)) > 0
ORDER BY kg DESC`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadOverviewCustomers(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.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`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyRows(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.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`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyWatermark(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
FROM ${HYDROGEN_TABLE} b WHERE ${sqlWhere}`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyTreeStations(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.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`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDailyTreeCustomers(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { sqlWhere, params } = args;
const [rows] = await db.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`, params);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDrillSummary(db: Database, args: { scopedWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
const { scopedWhere, params } = args;
const [rows] = await db.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);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDrillGroups(db: Database, args: { scopedWhere: string; params: unknown[]; groupSelect: string; groupExpression: string; groupHaving: string; groupOrder: string; pageSize: number; offset: number }): Promise<RowDataPacket[]> {
const { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT ${groupSelect},
GROUP_CONCAT(DISTINCT COALESCE(CAST(b.settlement_type AS CHAR), 'unknown') ORDER BY COALESCE(CAST(b.settlement_type AS CHAR), 'unknown')) AS settlementTypes,
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}
${groupHaving}
ORDER BY ${groupOrder} LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
return rows;
}
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
export async function loadDrillRecords(db: Database, args: { scopedWhere: string; params: unknown[]; pageSize: number; offset: number }): Promise<RowDataPacket[]> {
const { scopedWhere, params, pageSize, offset } = args;
const [rows] = await db.query<RowDataPacket[]>(`SELECT b.id, b.settlement_type AS settlementType, 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]);
return rows;
}
/** 下钻的金额口径片段:在基础 WHERE 之上追加承担方条件。 */
export function buildScopedWhere(sqlWhere: string, amountScope: string): string {
if (amountScope === 'customer') return `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`;
if (amountScope === 'company') return `${sqlWhere} AND ${COMPANY_BEARING_ORDER}`;
if (amountScope === 'other') return `${sqlWhere} AND ${OTHER_BEARING_ORDER}`;
return sqlWhere;
}
/** 下钻分组列(站点 / 客户 / 日期 / 车牌)。 */
export function buildGroupSelect(groupBy: string): string {
if (groupBy === 'station') return "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";
if (groupBy === 'customer') return "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";
if (groupBy === 'date') return `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`;
return "COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id, COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name, NULL AS province, NULL AS city";
}
/** 下钻分组表达式(与 buildGroupSelect 必须成对使用)。 */
export function buildGroupExpression(groupBy: string): string {
if (groupBy === 'station') return 'COALESCE(b.station_id, 0)';
if (groupBy === 'customer') return "COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')";
if (groupBy === 'date') return `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`;
return "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
}
/** 由筛选条件构建 WHERE 片段与参数(顺序即 SQL 参数顺序,由契约测试锁定)。 */
export function buildFilterClauses(input: {
startDate: string;
endDate: string;
stationId: string | null;
customerId: number | null;
customerName: string | null;
plateNo: string | null;
vehicleScope: string;
verifyScope: string;
}): { clauses: string[]; params: unknown[] } {
const { startDate: safeStart, endDate: safeEnd, stationId, customerId, customerName, plateNo, vehicleScope, verifyScope } = input;
const clauses = [
HYDROGEN_FUEL_ONLY_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 (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 (verifyScope === "unverified")
clauses.push(
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) <> 'verified'",
"b.refuel_time >= '2026-05-01'",
);
return { clauses, params };
}