From 9750d35ba48c4fbdca293a3a863233e8e9452987 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Fri, 11 Sep 2026 10:53:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor(stage15):=20=E6=B0=A2=E8=83=BD=20v2=20?= =?UTF-8?q?=E7=A7=BB=E5=85=A5=20repository=EF=BC=8C=E8=83=BD=E6=BA=90?= =?UTF-8?q?=E5=9F=9F=E5=AE=8C=E6=88=90=E5=B9=B6=E5=8A=A0=E5=85=A5=E5=AE=88?= =?UTF-8?q?=E6=8A=A4=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动 - 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 未引用文件。 --- docs/ARCHITECTURE.md | 9 +- src/architecture.test.ts | 1 + src/server/routes/energy/constants.ts | 10 + src/server/routes/energy/hydrogen-bi-v2.ts | 411 ++++----------------- src/server/routes/energy/repository.ts | 409 ++++++++++++++++++++ 5 files changed, 490 insertions(+), 350 deletions(-) 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 }; +}