diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2577ab2..c3857d4 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -95,12 +95,12 @@ server/
| `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 |
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 |
| `mileage/` | `index.ts`(聚合)+ `monitoring.ts` `targets.ts` `trend.ts` `daily-report.ts` `vehicle-recent.ts` + `*-model.ts` + `cache.ts` `oneos-api.ts` `daily-report-{service,store,scheduler}.ts` | 路由 / 模型 / 服务已分开,**SQL 仍在各路由文件内** |
-| `energy/` | `index.ts`(聚合)+ `electric.ts` `etc.ts` `hydrogen-station-board.ts` + `repository.ts` + `hydrogen-bi-v2.ts`(仍有 SQL)+ `query-model.ts` `cache.ts` `constants.ts` | 🔶 **部分分层**:前三个路由文件的 SQL 已迁入 repository,SQL 片段进了 `constants.ts`;`hydrogen-bi-v2.ts` 仍有 7 条带动态插值的查询,因此该域尚未进守护清单 |
+| `energy/` | `index.ts`(聚合)+ `electric.ts` `etc.ts` `hydrogen-station-board.ts` `hydrogen-bi-v2.ts` + `repository.ts` + `query-model.ts` `cache.ts` `constants.ts` | ✅ 完整分层;WHERE 片段组装(`buildFilterClauses` / `buildGroupSelect` / `buildScopedWhere` / `resolvedWhere`)与共享 SQL 片段也都在 repository / constants |
| `scheduling/` | `index.ts`(聚合)+ `suggestions.ts` `notify.ts` `repository.ts` + `algorithm.ts` `notification-model.ts` | ✅ 完整分层;跨域数据源(里程车辆信息 / OneOS)显式注入 |
| `hydrogen-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;`buildWhere` 片段与参数顺序已锁定 |
| `vehicle-heatmap/` | `routes.ts` `repository.ts` `model.ts`(+ `routes.test.ts`) | ✅ 完整分层;同时覆盖 MySQL(考核批次)与 PG(定位点)两个库 |
-已完整分层的六个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling`)
+已完整分层的七个域(`vehicles` / `ele` / `feedback` / `vehicle-heatmap` / `hydrogen-heatmap` / `scheduling` / `energy`)
由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
@@ -139,8 +139,9 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
诚实记录,避免后来者以为已经做完:
-- **后端仍有 2 个域没拆出 repository**:`energy` / `mileage` 的 SQL 仍在各自的路由文件里
- (形状见上表)。已完成的有 6 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,
+- **后端仍有 1 个域没拆出 repository**:`mileage` 的 SQL 仍分散在 `cache.ts` /
+ `daily-report-store.ts` / `targets.ts` / `monitoring.ts` / `trend.ts` / `vehicle-recent.ts` 等文件里。
+ 已完成的有 7 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,
纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
守卫用的是"语句形状"正则(如 `update
set`)而不是裸关键字,避免把
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
diff --git a/src/architecture.test.ts b/src/architecture.test.ts
index 8a1d33c..ae540eb 100644
--- a/src/architecture.test.ts
+++ b/src/architecture.test.ts
@@ -179,6 +179,7 @@ test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在
"server/routes/vehicle-heatmap",
"server/routes/hydrogen-heatmap",
"server/routes/scheduling",
+ "server/routes/energy",
];
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
// 不应误判,而小写 SQL 也依然能被抓到。
diff --git a/src/server/routes/energy/constants.ts b/src/server/routes/energy/constants.ts
index 2feb27b..c2487c8 100644
--- a/src/server/routes/energy/constants.ts
+++ b/src/server/routes/energy/constants.ts
@@ -37,3 +37,13 @@ export const EXTERNAL_CUSTOMER_MAP_CTE = `
WHERE customerCount = 1
AND customerName IS NOT NULL
)`;
+
+// ---------------------------------------------------------------------------
+// 氢能 v2 承担方映射片段(b.settlement_type:1 客户 / 2 我司 / 其余“其他”)。
+// 与站点区域回退表达式一样,这些片段被多条查询共享,集中放置避免各处复制。
+// ---------------------------------------------------------------------------
+
+export const CUSTOMER_BEARING_ORDER = "b.settlement_type = 1";
+export const COMPANY_BEARING_ORDER = "b.settlement_type = 2";
+export const OTHER_BEARING_ORDER =
+ "(b.settlement_type NOT IN (1, 2) OR b.settlement_type IS NULL)";
diff --git a/src/server/routes/energy/hydrogen-bi-v2.ts b/src/server/routes/energy/hydrogen-bi-v2.ts
index cc6c071..5ef2aaa 100644
--- a/src/server/routes/energy/hydrogen-bi-v2.ts
+++ b/src/server/routes/energy/hydrogen-bi-v2.ts
@@ -2,12 +2,26 @@ import type { Hono } from "hono";
import type { RowDataPacket } from "mysql2";
import type hydrogenPool from "../../db/hydrogen.js";
import {
- HYDROGEN_FUEL_ONLY_WHERE,
- HYDROGEN_FUEL_ONLY_WHERE_B,
- HYDROGEN_LOCAL,
- HYDROGEN_MIN_DATE,
- HYDROGEN_TABLE,
-} from "./constants.js";
+ buildFilterClauses,
+ buildGroupExpression,
+ buildGroupSelect,
+ buildScopedWhere,
+ loadDailyRows,
+ loadDailyTreeCustomers,
+ loadDailyTreeStations,
+ loadDailyWatermark,
+ loadDrillGroups,
+ loadDrillRecords,
+ loadDrillSummary,
+ loadMetaStations,
+ loadMetaWatermark,
+ loadMetaYears,
+ loadOverviewCustomers,
+ loadOverviewMonthly,
+ loadOverviewStations,
+ loadOverviewSummary,
+ resolvedWhere,
+} from "./repository.js";
/**
* The BI v2 contract is intentionally independent of the legacy hydrogen
@@ -60,24 +74,7 @@ function endOfMonth(month: string) {
return new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
}
-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})`;
-}
+
function todayYmd() {
const now = new Date();
@@ -150,44 +147,16 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
query("verifyScope") === "verified" || query("verifyScope") === "unverified"
? (query("verifyScope") as VerifyScope)
: "all";
- 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'",
- );
+ const { clauses, params } = buildFilterClauses({
+ startDate: safeStart,
+ endDate: safeEnd,
+ stationId,
+ customerId,
+ customerName,
+ plateNo,
+ vehicleScope,
+ verifyScope,
+ });
return {
startDate: safeStart,
endDate: safeEnd,
@@ -206,38 +175,6 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
};
}
-async function resolvedWhere(
- hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
- filter: Filter,
-) {
- 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 hydrogenPool.query(
- `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 };
-}
function filterContext(filter: Filter) {
return {
startDate: filter.startDate,
@@ -261,12 +198,6 @@ function number(value: unknown, digits = 2) {
}
// 承担方必须以账本专用字段 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"
@@ -306,55 +237,24 @@ function dateKeys(start: string, end: string) {
async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
const [yearRows, stationRows, watermarkRows] = await Promise.all([
- hydrogenPool.query(
- `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],
- ),
- hydrogenPool.query(
- `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`,
- ),
- hydrogenPool.query(
- `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],
- ),
+ loadMetaYears(hydrogenPool),
+ loadMetaStations(hydrogenPool),
+ loadMetaWatermark(hydrogenPool),
]);
return {
- years: yearRows[0].map((row) => ({
+ years: yearRows.map((row) => ({
value: Number(row.value),
startDate: String(row.startDate),
endDate: String(row.endDate),
})),
- stations: stationRows[0].map((row) => ({
+ stations: stationRows.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),
+ ledgerAt: formatWatermark(watermarkRows[0]?.ledgerAt),
paymentAt: null,
},
};
@@ -368,100 +268,18 @@ async function overview(
const month = filter.endDate.slice(0, 7);
const [summaryRows, monthlyRows, stationRows, customerRows] =
await Promise.all([
- hydrogenPool.query(
- `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, ...params],
- ),
- hydrogenPool.query(
- `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,
- ),
- hydrogenPool.query(
- `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,
- ),
- hydrogenPool.query(
- `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,
- ),
+ loadOverviewSummary(hydrogenPool, { sqlWhere, params, month, endDate: filter.endDate }),
+ loadOverviewMonthly(hydrogenPool, { sqlWhere, params }),
+ loadOverviewStations(hydrogenPool, { sqlWhere, params }),
+ loadOverviewCustomers(hydrogenPool, { sqlWhere, params }),
]);
- const summary: RowDataPacket = summaryRows[0][0] ?? ({} as RowDataPacket);
+ const summary: RowDataPacket = summaryRows[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]),
+ monthlyRows.map((row) => [String(row.month), row]),
);
const monthly = monthKeys(filter.startDate, filter.endDate).map((key) => {
const row = monthlyMap.get(key);
@@ -485,7 +303,7 @@ async function overview(
customerGrossProfit: number(customerMonthRevenue - customerMonthCost),
};
});
- const stations = stationRows[0].map((row) => ({
+ const stations = stationRows.map((row) => ({
id: String(row.id),
name: String(row.name),
province: String(row.province),
@@ -562,7 +380,7 @@ async function overview(
topStations: stations.slice(0, 5),
stations,
regions,
- customers: customerRows[0].map((row) => {
+ customers: customerRows.map((row) => {
const customerBearingKg = number(row.customerBearingKg, 3);
const companyBearingKg = number(row.companyBearingKg, 3);
const otherBearingKg = number(row.otherBearingKg, 3);
@@ -599,27 +417,10 @@ async function daily(
) {
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
const [rows, watermarkRows] = await Promise.all([
- hydrogenPool.query(
- `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,
- ),
- hydrogenPool.query(
- `SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
- FROM ${HYDROGEN_TABLE} b WHERE ${sqlWhere}`,
- params,
- ),
+ loadDailyRows(hydrogenPool, { sqlWhere, params }),
+ loadDailyWatermark(hydrogenPool, { sqlWhere, params }),
]);
- const map = new Map(rows[0].map((row) => [String(row.date), row]));
+ const map = new Map(rows.map((row) => [String(row.date), row]));
let previousKg = 0;
const days = dateKeys(filter.startDate, filter.endDate).map((date) => {
const row = map.get(date);
@@ -644,7 +445,7 @@ async function daily(
return {
range: { startDate: filter.startDate, endDate: filter.endDate },
watermark: {
- ledgerAt: formatWatermark(watermarkRows[0][0]?.ledgerAt),
+ ledgerAt: formatWatermark(watermarkRows[0]?.ledgerAt),
paymentAt: null,
},
filters: filterContext(filter),
@@ -671,32 +472,8 @@ async function dailyTree(
) {
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
const [stationRows, customerRows] = await Promise.all([
- hydrogenPool.query(
- `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,
- ),
- hydrogenPool.query(
- `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,
- ),
+ loadDailyTreeStations(hydrogenPool, { sqlWhere, params }),
+ loadDailyTreeCustomers(hydrogenPool, { sqlWhere, params }),
]);
const customersByStation = new Map<
string,
@@ -708,7 +485,7 @@ async function dailyTree(
recordCount: number;
}>
>();
- customerRows[0].forEach((row) => {
+ customerRows.forEach((row) => {
const stationId = String(row.stationId);
const list = customersByStation.get(stationId) || [];
list.push({
@@ -722,7 +499,7 @@ async function dailyTree(
});
return {
date: filter.startDate,
- stations: stationRows[0].map((row) => ({
+ stations: stationRows.map((row) => ({
id: String(row.id),
name: String(row.name),
kg: number(row.kg, 3),
@@ -742,82 +519,24 @@ async function drill(
amountScope: AmountScope,
) {
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, 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 scopedWhere = buildScopedWhere(sqlWhere, amountScope);
+ const groupSelect = buildGroupSelect(groupBy);
+ const groupExpression = buildGroupExpression(groupBy);
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, ''), '无车牌')";
// Stable tie-breakers prevent equal-volume groups drifting between pages.
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC, id ASC, name ASC";
const groupHaving =
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
const [summaryRows, groupRows, recordRows] = await Promise.all([
- hydrogenPool.query(
- `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,
- ),
+ loadDrillSummary(hydrogenPool, { scopedWhere, params }),
groupBy === "record"
- ? Promise.resolve([[] as RowDataPacket[]])
- : hydrogenPool.query(
- `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],
- ),
+ ? Promise.resolve([] as RowDataPacket[])
+ : loadDrillGroups(hydrogenPool, { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset }),
groupBy === "record"
- ? hydrogenPool.query(
- `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],
- )
- : Promise.resolve([[] as RowDataPacket[]]),
+ ? loadDrillRecords(hydrogenPool, { scopedWhere, params, pageSize, offset })
+ : Promise.resolve([] as RowDataPacket[]),
]);
- const summary = summaryRows[0][0] ?? {};
+ const summary = summaryRows[0] ?? {};
return {
groupBy,
amountScope,
@@ -831,7 +550,7 @@ async function drill(
cost: number(summary.cost),
revenue: number(summary.revenue),
},
- groups: groupRows[0].map((row) => ({
+ groups: groupRows.map((row) => ({
id: String(row.id),
name: String(row.name),
province: row.province ? String(row.province) : null,
@@ -846,7 +565,7 @@ async function drill(
externalKg: number(row.externalKg, 3),
settlementTypes: row.settlementTypes == null ? null : String(row.settlementTypes),
})),
- records: recordRows[0].map((row) => ({
+ records: recordRows.map((row) => ({
settlementType: row.settlementType == null ? null : String(row.settlementType),
id: String(row.id),
time: String(row.time),
@@ -867,7 +586,7 @@ async function drill(
cost: number(row.cost),
revenue: number(row.revenue),
})),
- page: { page, pageSize, hasMore: (groupBy === "record" ? recordRows[0] : groupRows[0]).length === pageSize },
+ page: { page, pageSize, hasMore: (groupBy === "record" ? recordRows : groupRows).length === pageSize },
};
}
diff --git a/src/server/routes/energy/repository.ts b/src/server/routes/energy/repository.ts
index e4bfab5..de20e0c 100644
--- a/src/server/routes/energy/repository.ts
+++ b/src/server/routes/energy/repository.ts
@@ -1,10 +1,16 @@
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,
@@ -360,3 +366,406 @@ export async function loadExternalReceipts(db: Database, args: { start: string;
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(
+ `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 {
+ const [rows] = await db.query(`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 {
+ const [rows] = await db.query(`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 {
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params, month, endDate } = args;
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { sqlWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { scopedWhere, params } = args;
+ const [rows] = await db.query(`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 {
+ const { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset } = args;
+ const [rows] = await db.query(`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 {
+ const { scopedWhere, params, pageSize, offset } = args;
+ const [rows] = await db.query(`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 };
+}