refactor(stage15): 氢能 v2 移入 repository,能源域完成并加入守护清单
改动 - hydrogen-bi-v2.ts 的 14 条查询移入 energy/repository.ts(SQL 与参数表达式逐条对应)。 - 片段组装也随之搬走,因为它们本质是 SQL 构造,留在路由里会让"路由不含 SQL"形同虚设: resolvedWhere / stationMasterRegionSql / buildFilterClauses / buildGroupSelect / buildGroupExpression / buildScopedWhere。 - 承担方映射片段(CUSTOMER/COMPANY/OTHER_BEARING_ORDER)移入 constants.ts 供多处共享。 - 结果形状从 mysql2 的 [rows, fields] 改为直接返回 rows,路由侧 13 处 [0] 索引同步调整。 - 架构守护的"已完整分层"清单加入 energy(现为 7 个域)。 提取方式与一次失误 - 查询文本用"反引号定界 + 括号配对"扫描提取。第一次用纯括号配对扫描时, 因为把 SQL 文本里的括号也算了进去,导致调用点跨度越界、文件被改坏; 已从 git 恢复并改用反引号定界(并对跨度做了"严格递增且不重叠"的断言)后一次通过。 - 中途还有一次变量写错文件(把 repository 内容写进了路由文件), 被随后的正常写入覆盖,未造成实际损坏;已核对两个文件的首行与关键符号确认无误。 验证 - energy/routes.test.ts 的 13 个用例(含氢能 v2 的口径、利润、账单、路由顺序、 SQL 与参数指纹)在提取前后全部通过。 - 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
@@ -95,12 +95,12 @@ server/
|
|||||||
| `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 |
|
| `ele/` | `routes.ts` `repository.ts` `model.ts`(+ `model.test.ts` `routes.test.ts`) | ✅ 完整分层;改造前后 SQL/参数/响应体已做等价性验证 |
|
||||||
| `feedback/` | `routes.ts` `repository.ts` `oss.ts`(+ `routes.test.ts`) | ✅ 完整分层;同上 |
|
| `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 仍在各路由文件内** |
|
| `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)显式注入 |
|
| `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` 片段与参数顺序已锁定 |
|
| `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(定位点)两个库 |
|
| `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**。
|
由架构测试守护:必须存在 `repository.ts`,且该域**其他任何非测试文件都不得含 SQL**。
|
||||||
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
其余域(`energy` / `mileage`)尚未拆出 repository——拆分时**不要改变 SQL 与参数顺序**,
|
||||||
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
|
请按 `ele/routes.test.ts` 的配方先补契约测试,并用"改造前后同一批请求对比落库 SQL 与响应体"做等价性验证。
|
||||||
@@ -139,8 +139,9 @@ cors → read-only → /api/auth(公开) → authMiddleware → 各业务域
|
|||||||
|
|
||||||
诚实记录,避免后来者以为已经做完:
|
诚实记录,避免后来者以为已经做完:
|
||||||
|
|
||||||
- **后端仍有 2 个域没拆出 repository**:`energy` / `mileage` 的 SQL 仍在各自的路由文件里
|
- **后端仍有 1 个域没拆出 repository**:`mileage` 的 SQL 仍分散在 `cache.ts` /
|
||||||
(形状见上表)。已完成的有 6 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,
|
`daily-report-store.ts` / `targets.ts` / `monitoring.ts` / `trend.ts` / `vehicle-recent.ts` 等文件里。
|
||||||
|
已完成的有 7 个域,可作为模板:路由只做校验与组装,SQL 进 `repository.ts`,
|
||||||
纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
|
纯逻辑进 `model.ts`,并用 mock pool 的契约测试锁定 SQL 与参数。
|
||||||
守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
|
守卫用的是"语句形状"正则(如 `update <table> set`)而不是裸关键字,避免把
|
||||||
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
|
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ test("已完整分层的域:repository.ts 存在,且 SQL 只允许出现在
|
|||||||
"server/routes/vehicle-heatmap",
|
"server/routes/vehicle-heatmap",
|
||||||
"server/routes/hydrogen-heatmap",
|
"server/routes/hydrogen-heatmap",
|
||||||
"server/routes/scheduling",
|
"server/routes/scheduling",
|
||||||
|
"server/routes/energy",
|
||||||
];
|
];
|
||||||
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
|
// 用"语句形状"而不是裸关键字:UpdateNotification 或 "update error" 这类标识符/日志
|
||||||
// 不应误判,而小写 SQL 也依然能被抓到。
|
// 不应误判,而小写 SQL 也依然能被抓到。
|
||||||
|
|||||||
@@ -37,3 +37,13 @@ export const EXTERNAL_CUSTOMER_MAP_CTE = `
|
|||||||
WHERE customerCount = 1
|
WHERE customerCount = 1
|
||||||
AND customerName IS NOT NULL
|
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)";
|
||||||
|
|||||||
@@ -2,12 +2,26 @@ import type { Hono } from "hono";
|
|||||||
import type { RowDataPacket } from "mysql2";
|
import type { RowDataPacket } from "mysql2";
|
||||||
import type hydrogenPool from "../../db/hydrogen.js";
|
import type hydrogenPool from "../../db/hydrogen.js";
|
||||||
import {
|
import {
|
||||||
HYDROGEN_FUEL_ONLY_WHERE,
|
buildFilterClauses,
|
||||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
buildGroupExpression,
|
||||||
HYDROGEN_LOCAL,
|
buildGroupSelect,
|
||||||
HYDROGEN_MIN_DATE,
|
buildScopedWhere,
|
||||||
HYDROGEN_TABLE,
|
loadDailyRows,
|
||||||
} from "./constants.js";
|
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
|
* 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);
|
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() {
|
function todayYmd() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -150,44 +147,16 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
|||||||
query("verifyScope") === "verified" || query("verifyScope") === "unverified"
|
query("verifyScope") === "verified" || query("verifyScope") === "unverified"
|
||||||
? (query("verifyScope") as VerifyScope)
|
? (query("verifyScope") as VerifyScope)
|
||||||
: "all";
|
: "all";
|
||||||
const clauses = [
|
const { clauses, params } = buildFilterClauses({
|
||||||
HYDROGEN_FUEL_ONLY_WHERE_B,
|
startDate: safeStart,
|
||||||
`b.${HYDROGEN_LOCAL} >= ?`,
|
endDate: safeEnd,
|
||||||
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
|
stationId,
|
||||||
];
|
customerId,
|
||||||
const params: unknown[] = [
|
customerName,
|
||||||
safeStart < HYDROGEN_MIN_DATE ? HYDROGEN_MIN_DATE : safeStart,
|
plateNo,
|
||||||
safeEnd,
|
vehicleScope,
|
||||||
];
|
verifyScope,
|
||||||
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 {
|
return {
|
||||||
startDate: safeStart,
|
startDate: safeStart,
|
||||||
endDate: safeEnd,
|
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<RowDataPacket[]>(
|
|
||||||
`SELECT CAST(rs.id AS CHAR) AS id
|
|
||||||
FROM new_hydrogen_site rs
|
|
||||||
LEFT JOIN common_district rd
|
|
||||||
ON CONVERT(rd.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(${districtColumn} USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
|
||||||
AND rd.STATUS = 'VALID'
|
|
||||||
WHERE rs.del_flag = '0'
|
|
||||||
AND ${stationMasterRegionSql(filter.regionGranularity)} = ?`,
|
|
||||||
[filter.region],
|
|
||||||
);
|
|
||||||
const stationIds = [...new Set(rows.map((row) => String(row.id)))];
|
|
||||||
if (stationIds.length === 0) {
|
|
||||||
clauses.push("1 = 0");
|
|
||||||
} else {
|
|
||||||
clauses.push(`b.station_id IN (${stationIds.map(() => "?").join(", ")})`);
|
|
||||||
params.push(...stationIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { sql: clauses.join(" AND "), params };
|
|
||||||
}
|
|
||||||
function filterContext(filter: Filter) {
|
function filterContext(filter: Filter) {
|
||||||
return {
|
return {
|
||||||
startDate: filter.startDate,
|
startDate: filter.startDate,
|
||||||
@@ -261,12 +198,6 @@ function number(value: unknown, digits = 2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 承担方必须以账本专用字段 settlement_type 为准,不能由对客单价/金额反推。
|
// 承担方必须以账本专用字段 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 {
|
function resolveAmountScope(value: string | undefined): AmountScope {
|
||||||
return value === "customer" || value === "company" || value === "other"
|
return value === "customer" || value === "company" || value === "other"
|
||||||
@@ -306,55 +237,24 @@ function dateKeys(start: string, end: string) {
|
|||||||
|
|
||||||
async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
||||||
const [yearRows, stationRows, watermarkRows] = await Promise.all([
|
const [yearRows, stationRows, watermarkRows] = await Promise.all([
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
loadMetaYears(hydrogenPool),
|
||||||
`SELECT YEAR(${HYDROGEN_LOCAL}) AS value,
|
loadMetaStations(hydrogenPool),
|
||||||
DATE_FORMAT(MIN(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS startDate,
|
loadMetaWatermark(hydrogenPool),
|
||||||
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<RowDataPacket[]>(
|
|
||||||
`SELECT CAST(s.id AS CHAR) AS id,
|
|
||||||
COALESCE(NULLIF(s.site_short_name, ''), NULLIF(s.site_name, ''), CONCAT('站点 #', s.id)) AS name,
|
|
||||||
COALESCE(NULLIF(p.NAME, ''), '未归属') AS province,
|
|
||||||
COALESCE(NULLIF(ct.NAME, ''), '未归属') AS city
|
|
||||||
FROM new_hydrogen_site s
|
|
||||||
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
|
||||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
|
||||||
WHERE s.del_flag = '0'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM ${HYDROGEN_TABLE} b
|
|
||||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
|
|
||||||
AND b.station_id = s.id
|
|
||||||
AND COALESCE(b.amount_kg, 0) > 0
|
|
||||||
)
|
|
||||||
ORDER BY name`,
|
|
||||||
),
|
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
|
||||||
`SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
|
|
||||||
FROM ${HYDROGEN_TABLE}
|
|
||||||
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
|
|
||||||
[HYDROGEN_MIN_DATE],
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
years: yearRows[0].map((row) => ({
|
years: yearRows.map((row) => ({
|
||||||
value: Number(row.value),
|
value: Number(row.value),
|
||||||
startDate: String(row.startDate),
|
startDate: String(row.startDate),
|
||||||
endDate: String(row.endDate),
|
endDate: String(row.endDate),
|
||||||
})),
|
})),
|
||||||
stations: stationRows[0].map((row) => ({
|
stations: stationRows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
province: String(row.province),
|
province: String(row.province),
|
||||||
city: String(row.city),
|
city: String(row.city),
|
||||||
})),
|
})),
|
||||||
watermark: {
|
watermark: {
|
||||||
ledgerAt: formatWatermark(watermarkRows[0][0]?.ledgerAt),
|
ledgerAt: formatWatermark(watermarkRows[0]?.ledgerAt),
|
||||||
paymentAt: null,
|
paymentAt: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -368,100 +268,18 @@ async function overview(
|
|||||||
const month = filter.endDate.slice(0, 7);
|
const month = filter.endDate.slice(0, 7);
|
||||||
const [summaryRows, monthlyRows, stationRows, customerRows] =
|
const [summaryRows, monthlyRows, stationRows, customerRows] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
loadOverviewSummary(hydrogenPool, { sqlWhere, params, month, endDate: filter.endDate }),
|
||||||
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt,
|
loadOverviewMonthly(hydrogenPool, { sqlWhere, params }),
|
||||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
|
loadOverviewStations(hydrogenPool, { sqlWhere, params }),
|
||||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS totalCost,
|
loadOverviewCustomers(hydrogenPool, { sqlWhere, params }),
|
||||||
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<RowDataPacket[]>(
|
|
||||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
|
||||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
|
||||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
|
||||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost
|
|
||||||
FROM ${HYDROGEN_TABLE} b
|
|
||||||
WHERE ${sqlWhere}
|
|
||||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m')
|
|
||||||
ORDER BY month`,
|
|
||||||
params,
|
|
||||||
),
|
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
|
||||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
|
||||||
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
|
|
||||||
COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province,
|
|
||||||
COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city,
|
|
||||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
|
||||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
|
||||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
|
||||||
COUNT(*) AS recordCount,
|
|
||||||
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount
|
|
||||||
FROM ${HYDROGEN_TABLE} b
|
|
||||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
|
||||||
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
|
||||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
|
||||||
WHERE ${sqlWhere}
|
|
||||||
GROUP BY COALESCE(b.station_id, 0)
|
|
||||||
HAVING SUM(COALESCE(b.amount_kg, 0)) > 0
|
|
||||||
ORDER BY kg DESC`,
|
|
||||||
params,
|
|
||||||
),
|
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
|
||||||
`SELECT COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
|
||||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
|
|
||||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
|
|
||||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
|
||||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
|
||||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
|
||||||
COUNT(*) AS recordCount
|
|
||||||
FROM ${HYDROGEN_TABLE} b
|
|
||||||
WHERE ${sqlWhere}
|
|
||||||
GROUP BY COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
|
||||||
ORDER BY kg DESC
|
|
||||||
LIMIT 200`,
|
|
||||||
params,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
const summary: RowDataPacket = summaryRows[0][0] ?? ({} as RowDataPacket);
|
const summary: RowDataPacket = summaryRows[0] ?? ({} as RowDataPacket);
|
||||||
const totalKg = number(summary.totalKg, 3);
|
const totalKg = number(summary.totalKg, 3);
|
||||||
const totalRevenue = number(summary.totalRevenue);
|
const totalRevenue = number(summary.totalRevenue);
|
||||||
const customerRevenue = number(summary.customerRevenue);
|
const customerRevenue = number(summary.customerRevenue);
|
||||||
const customerCost = number(summary.customerCost);
|
const customerCost = number(summary.customerCost);
|
||||||
const monthlyMap = new Map(
|
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 monthly = monthKeys(filter.startDate, filter.endDate).map((key) => {
|
||||||
const row = monthlyMap.get(key);
|
const row = monthlyMap.get(key);
|
||||||
@@ -485,7 +303,7 @@ async function overview(
|
|||||||
customerGrossProfit: number(customerMonthRevenue - customerMonthCost),
|
customerGrossProfit: number(customerMonthRevenue - customerMonthCost),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const stations = stationRows[0].map((row) => ({
|
const stations = stationRows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
province: String(row.province),
|
province: String(row.province),
|
||||||
@@ -562,7 +380,7 @@ async function overview(
|
|||||||
topStations: stations.slice(0, 5),
|
topStations: stations.slice(0, 5),
|
||||||
stations,
|
stations,
|
||||||
regions,
|
regions,
|
||||||
customers: customerRows[0].map((row) => {
|
customers: customerRows.map((row) => {
|
||||||
const customerBearingKg = number(row.customerBearingKg, 3);
|
const customerBearingKg = number(row.customerBearingKg, 3);
|
||||||
const companyBearingKg = number(row.companyBearingKg, 3);
|
const companyBearingKg = number(row.companyBearingKg, 3);
|
||||||
const otherBearingKg = number(row.otherBearingKg, 3);
|
const otherBearingKg = number(row.otherBearingKg, 3);
|
||||||
@@ -599,27 +417,10 @@ async function daily(
|
|||||||
) {
|
) {
|
||||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||||
const [rows, watermarkRows] = await Promise.all([
|
const [rows, watermarkRows] = await Promise.all([
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
loadDailyRows(hydrogenPool, { sqlWhere, params }),
|
||||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
loadDailyWatermark(hydrogenPool, { sqlWhere, params }),
|
||||||
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<RowDataPacket[]>(
|
|
||||||
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
|
|
||||||
FROM ${HYDROGEN_TABLE} b WHERE ${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;
|
let previousKg = 0;
|
||||||
const days = dateKeys(filter.startDate, filter.endDate).map((date) => {
|
const days = dateKeys(filter.startDate, filter.endDate).map((date) => {
|
||||||
const row = map.get(date);
|
const row = map.get(date);
|
||||||
@@ -644,7 +445,7 @@ async function daily(
|
|||||||
return {
|
return {
|
||||||
range: { startDate: filter.startDate, endDate: filter.endDate },
|
range: { startDate: filter.startDate, endDate: filter.endDate },
|
||||||
watermark: {
|
watermark: {
|
||||||
ledgerAt: formatWatermark(watermarkRows[0][0]?.ledgerAt),
|
ledgerAt: formatWatermark(watermarkRows[0]?.ledgerAt),
|
||||||
paymentAt: null,
|
paymentAt: null,
|
||||||
},
|
},
|
||||||
filters: filterContext(filter),
|
filters: filterContext(filter),
|
||||||
@@ -671,32 +472,8 @@ async function dailyTree(
|
|||||||
) {
|
) {
|
||||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||||
const [stationRows, customerRows] = await Promise.all([
|
const [stationRows, customerRows] = await Promise.all([
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
loadDailyTreeStations(hydrogenPool, { sqlWhere, params }),
|
||||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
loadDailyTreeCustomers(hydrogenPool, { sqlWhere, params }),
|
||||||
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<RowDataPacket[]>(
|
|
||||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS stationId,
|
|
||||||
COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
|
||||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
|
|
||||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
|
||||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
|
||||||
COUNT(*) AS recordCount
|
|
||||||
FROM ${HYDROGEN_TABLE} b
|
|
||||||
WHERE ${sqlWhere}
|
|
||||||
GROUP BY COALESCE(b.station_id, 0), COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
|
||||||
ORDER BY stationId, kg DESC`,
|
|
||||||
params,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
const customersByStation = new Map<
|
const customersByStation = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -708,7 +485,7 @@ async function dailyTree(
|
|||||||
recordCount: number;
|
recordCount: number;
|
||||||
}>
|
}>
|
||||||
>();
|
>();
|
||||||
customerRows[0].forEach((row) => {
|
customerRows.forEach((row) => {
|
||||||
const stationId = String(row.stationId);
|
const stationId = String(row.stationId);
|
||||||
const list = customersByStation.get(stationId) || [];
|
const list = customersByStation.get(stationId) || [];
|
||||||
list.push({
|
list.push({
|
||||||
@@ -722,7 +499,7 @@ async function dailyTree(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
date: filter.startDate,
|
date: filter.startDate,
|
||||||
stations: stationRows[0].map((row) => ({
|
stations: stationRows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
kg: number(row.kg, 3),
|
kg: number(row.kg, 3),
|
||||||
@@ -742,82 +519,24 @@ async function drill(
|
|||||||
amountScope: AmountScope,
|
amountScope: AmountScope,
|
||||||
) {
|
) {
|
||||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||||
const scopedWhere =
|
const scopedWhere = buildScopedWhere(sqlWhere, amountScope);
|
||||||
amountScope === "customer"
|
const groupSelect = buildGroupSelect(groupBy);
|
||||||
? `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`
|
const groupExpression = buildGroupExpression(groupBy);
|
||||||
: amountScope === "company"
|
|
||||||
? `${sqlWhere} AND ${COMPANY_BEARING_ORDER}`
|
|
||||||
: amountScope === "other"
|
|
||||||
? `${sqlWhere} AND ${OTHER_BEARING_ORDER}`
|
|
||||||
: sqlWhere;
|
|
||||||
const offset = (page - 1) * pageSize;
|
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.
|
// Stable tie-breakers prevent equal-volume groups drifting between pages.
|
||||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC, id ASC, name ASC";
|
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC, id ASC, name ASC";
|
||||||
const groupHaving =
|
const groupHaving =
|
||||||
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
|
groupBy === "station" ? "HAVING SUM(COALESCE(b.amount_kg, 0)) > 0" : "";
|
||||||
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
||||||
hydrogenPool.query<RowDataPacket[]>(
|
loadDrillSummary(hydrogenPool, { scopedWhere, params }),
|
||||||
`SELECT COUNT(*) AS recordCount,
|
|
||||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
|
||||||
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
|
|
||||||
SUM(CASE WHEN NULLIF(b.record_source, '') IS NOT NULL OR b.source IS NOT NULL THEN 1 ELSE 0 END) AS traceableRecordCount,
|
|
||||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
|
||||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
|
|
||||||
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}`,
|
|
||||||
params,
|
|
||||||
),
|
|
||||||
groupBy === "record"
|
groupBy === "record"
|
||||||
? Promise.resolve([[] as RowDataPacket[]])
|
? Promise.resolve([] as RowDataPacket[])
|
||||||
: hydrogenPool.query<RowDataPacket[]>(
|
: loadDrillGroups(hydrogenPool, { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset }),
|
||||||
`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],
|
|
||||||
),
|
|
||||||
groupBy === "record"
|
groupBy === "record"
|
||||||
? hydrogenPool.query<RowDataPacket[]>(
|
? loadDrillRecords(hydrogenPool, { scopedWhere, params, pageSize, offset })
|
||||||
`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,
|
: Promise.resolve([] as RowDataPacket[]),
|
||||||
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[]]),
|
|
||||||
]);
|
]);
|
||||||
const summary = summaryRows[0][0] ?? {};
|
const summary = summaryRows[0] ?? {};
|
||||||
return {
|
return {
|
||||||
groupBy,
|
groupBy,
|
||||||
amountScope,
|
amountScope,
|
||||||
@@ -831,7 +550,7 @@ async function drill(
|
|||||||
cost: number(summary.cost),
|
cost: number(summary.cost),
|
||||||
revenue: number(summary.revenue),
|
revenue: number(summary.revenue),
|
||||||
},
|
},
|
||||||
groups: groupRows[0].map((row) => ({
|
groups: groupRows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
province: row.province ? String(row.province) : null,
|
province: row.province ? String(row.province) : null,
|
||||||
@@ -846,7 +565,7 @@ async function drill(
|
|||||||
externalKg: number(row.externalKg, 3),
|
externalKg: number(row.externalKg, 3),
|
||||||
settlementTypes: row.settlementTypes == null ? null : String(row.settlementTypes),
|
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),
|
settlementType: row.settlementType == null ? null : String(row.settlementType),
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
time: String(row.time),
|
time: String(row.time),
|
||||||
@@ -867,7 +586,7 @@ async function drill(
|
|||||||
cost: number(row.cost),
|
cost: number(row.cost),
|
||||||
revenue: number(row.revenue),
|
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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { RowDataPacket } from 'mysql2';
|
import type { RowDataPacket } from 'mysql2';
|
||||||
import { dateRangeClause, type CustomerKind } from './query-model.js';
|
import { dateRangeClause, type CustomerKind } from './query-model.js';
|
||||||
import {
|
import {
|
||||||
|
COMPANY_BEARING_ORDER,
|
||||||
|
CUSTOMER_BEARING_ORDER,
|
||||||
EXTERNAL_CUSTOMER_MAP_CTE,
|
EXTERNAL_CUSTOMER_MAP_CTE,
|
||||||
|
OTHER_BEARING_ORDER,
|
||||||
HYDROGEN_BASE_WHERE,
|
HYDROGEN_BASE_WHERE,
|
||||||
HYDROGEN_BASE_WHERE_B,
|
HYDROGEN_BASE_WHERE_B,
|
||||||
|
HYDROGEN_FUEL_ONLY_WHERE,
|
||||||
|
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||||
HYDROGEN_LOCAL,
|
HYDROGEN_LOCAL,
|
||||||
|
HYDROGEN_MIN_DATE,
|
||||||
HYDROGEN_TABLE,
|
HYDROGEN_TABLE,
|
||||||
HYDROGEN_TENANT_ID,
|
HYDROGEN_TENANT_ID,
|
||||||
MANUAL_RECHARGE_SOURCE,
|
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]);
|
ORDER BY date DESC, customerName ASC, id ASC`, [HYDROGEN_TENANT_ID, start, end, HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, start, end]);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 氢能 v2(/h2/v2/*)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type RegionGranularity = 'province' | 'city';
|
||||||
|
|
||||||
|
/** 区域筛选所需的最小输入(与路由的 Filter 结构兼容)。 */
|
||||||
|
export interface RegionFilter {
|
||||||
|
clauses: string[];
|
||||||
|
params: unknown[];
|
||||||
|
region: string | null;
|
||||||
|
regionGranularity: RegionGranularity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stationMasterRegionSql(granularity: RegionGranularity) {
|
||||||
|
const districtColumn = granularity === "province" ? "rs.province" : "rs.city";
|
||||||
|
const stationName = "COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''), '')";
|
||||||
|
const fallback =
|
||||||
|
granularity === "province"
|
||||||
|
? `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '浙江省'
|
||||||
|
WHEN ${stationName} LIKE '%广州%' OR ${stationName} LIKE '%佛山%' THEN '广东省'
|
||||||
|
WHEN ${stationName} LIKE '%成都%' THEN '四川省'
|
||||||
|
WHEN ${stationName} LIKE '%昆山%' THEN '江苏省'
|
||||||
|
ELSE '未归属省份' END`
|
||||||
|
: `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '嘉兴市'
|
||||||
|
WHEN ${stationName} LIKE '%广州%' THEN '广州市'
|
||||||
|
WHEN ${stationName} LIKE '%佛山%' THEN '佛山市'
|
||||||
|
WHEN ${stationName} LIKE '%成都%' THEN '成都市'
|
||||||
|
WHEN ${stationName} LIKE '%昆山%' THEN '昆山市'
|
||||||
|
ELSE '未归属区域' END`;
|
||||||
|
return `COALESCE(NULLIF(rd.NAME, ''), ${fallback})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvedWhere(
|
||||||
|
db: Database,
|
||||||
|
filter: RegionFilter,
|
||||||
|
) {
|
||||||
|
const clauses = [...filter.clauses];
|
||||||
|
const params = [...filter.params];
|
||||||
|
if (filter.region) {
|
||||||
|
// Resolve the small station master once. The old implementation ran two
|
||||||
|
// correlated station/district subqueries for every ledger row and repeated
|
||||||
|
// that work in summary, grouping and record queries.
|
||||||
|
const districtColumn =
|
||||||
|
filter.regionGranularity === "province" ? "rs.province" : "rs.city";
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(
|
||||||
|
`SELECT CAST(rs.id AS CHAR) AS id
|
||||||
|
FROM new_hydrogen_site rs
|
||||||
|
LEFT JOIN common_district rd
|
||||||
|
ON CONVERT(rd.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(${districtColumn} USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||||
|
AND rd.STATUS = 'VALID'
|
||||||
|
WHERE rs.del_flag = '0'
|
||||||
|
AND ${stationMasterRegionSql(filter.regionGranularity)} = ?`,
|
||||||
|
[filter.region],
|
||||||
|
);
|
||||||
|
const stationIds = [...new Set(rows.map((row) => String(row.id)))];
|
||||||
|
if (stationIds.length === 0) {
|
||||||
|
clauses.push("1 = 0");
|
||||||
|
} else {
|
||||||
|
clauses.push(`b.station_id IN (${stationIds.map(() => "?").join(", ")})`);
|
||||||
|
params.push(...stationIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sql: clauses.join(" AND "), params };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadMetaYears(db: Database): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT YEAR(${HYDROGEN_LOCAL}) AS value,
|
||||||
|
DATE_FORMAT(MIN(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS startDate,
|
||||||
|
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS endDate
|
||||||
|
FROM ${HYDROGEN_TABLE}
|
||||||
|
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
|
||||||
|
GROUP BY YEAR(${HYDROGEN_LOCAL})
|
||||||
|
ORDER BY value DESC`, [HYDROGEN_MIN_DATE]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadMetaStations(db: Database): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(s.id AS CHAR) AS id,
|
||||||
|
COALESCE(NULLIF(s.site_short_name, ''), NULLIF(s.site_name, ''), CONCAT('站点 #', s.id)) AS name,
|
||||||
|
COALESCE(NULLIF(p.NAME, ''), '未归属') AS province,
|
||||||
|
COALESCE(NULLIF(ct.NAME, ''), '未归属') AS city
|
||||||
|
FROM new_hydrogen_site s
|
||||||
|
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
||||||
|
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||||
|
WHERE s.del_flag = '0'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
|
||||||
|
AND b.station_id = s.id
|
||||||
|
AND COALESCE(b.amount_kg, 0) > 0
|
||||||
|
)
|
||||||
|
ORDER BY name`, []);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadMetaWatermark(db: Database): Promise<RowDataPacket[]> {
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
|
||||||
|
FROM ${HYDROGEN_TABLE}
|
||||||
|
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?`, [HYDROGEN_MIN_DATE]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadOverviewSummary(db: Database, args: { sqlWhere: string; params: unknown[]; month: string; endDate: string }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params, month, endDate } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS totalCost,
|
||||||
|
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS totalRevenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS monthKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.cost_total ELSE 0 END), 0), 2) AS monthCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS todayKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.cost_total ELSE 0 END), 0), 2) AS todayCost,
|
||||||
|
COUNT(*) AS recordCount,
|
||||||
|
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
WHERE ${sqlWhere}`, [month, month, endDate, endDate, ...params]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadOverviewMonthly(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||||
|
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
WHERE ${sqlWhere}
|
||||||
|
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m')
|
||||||
|
ORDER BY month`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadOverviewStations(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
||||||
|
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
|
||||||
|
COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province,
|
||||||
|
COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||||
|
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
||||||
|
COUNT(*) AS recordCount,
|
||||||
|
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
|
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
||||||
|
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||||
|
WHERE ${sqlWhere}
|
||||||
|
GROUP BY COALESCE(b.station_id, 0)
|
||||||
|
HAVING SUM(COALESCE(b.amount_kg, 0)) > 0
|
||||||
|
ORDER BY kg DESC`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadOverviewCustomers(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
||||||
|
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||||
|
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
||||||
|
COUNT(*) AS recordCount
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
WHERE ${sqlWhere}
|
||||||
|
GROUP BY COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
||||||
|
ORDER BY kg DESC
|
||||||
|
LIMIT 200`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadDailyRows(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||||
|
COUNT(*) AS recordCount,
|
||||||
|
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
WHERE ${sqlWhere}
|
||||||
|
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||||
|
ORDER BY date`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadDailyWatermark(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
|
||||||
|
FROM ${HYDROGEN_TABLE} b WHERE ${sqlWhere}`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadDailyTreeStations(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
||||||
|
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||||
|
COUNT(*) AS recordCount
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
|
WHERE ${sqlWhere}
|
||||||
|
GROUP BY COALESCE(b.station_id, 0)
|
||||||
|
ORDER BY kg DESC`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadDailyTreeCustomers(db: Database, args: { sqlWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { sqlWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS stationId,
|
||||||
|
COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
||||||
|
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||||
|
COUNT(*) AS recordCount
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
WHERE ${sqlWhere}
|
||||||
|
GROUP BY COALESCE(b.station_id, 0), COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
||||||
|
ORDER BY stationId, kg DESC`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadDrillSummary(db: Database, args: { scopedWhere: string; params: unknown[] }): Promise<RowDataPacket[]> {
|
||||||
|
const { scopedWhere, params } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT COUNT(*) AS recordCount,
|
||||||
|
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||||
|
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
|
||||||
|
SUM(CASE WHEN NULLIF(b.record_source, '') IS NOT NULL OR b.source IS NOT NULL THEN 1 ELSE 0 END) AS traceableRecordCount,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||||
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
|
||||||
|
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}`, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadDrillGroups(db: Database, args: { scopedWhere: string; params: unknown[]; groupSelect: string; groupExpression: string; groupHaving: string; groupOrder: string; pageSize: number; offset: number }): Promise<RowDataPacket[]> {
|
||||||
|
const { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT ${groupSelect},
|
||||||
|
GROUP_CONCAT(DISTINCT COALESCE(CAST(b.settlement_type AS CHAR), 'unknown') ORDER BY COALESCE(CAST(b.settlement_type AS CHAR), 'unknown')) AS settlementTypes,
|
||||||
|
COUNT(*) AS recordCount, COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||||
|
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
|
||||||
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg, ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||||
|
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg
|
||||||
|
FROM ${HYDROGEN_TABLE} b
|
||||||
|
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||||
|
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
||||||
|
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||||
|
WHERE ${scopedWhere}
|
||||||
|
GROUP BY ${groupExpression}
|
||||||
|
${groupHaving}
|
||||||
|
ORDER BY ${groupOrder} LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 氢能 v2 查询(SQL 与参数顺序由 routes.test.ts 的指纹断言锁定)。 */
|
||||||
|
export async function loadDrillRecords(db: Database, args: { scopedWhere: string; params: unknown[]; pageSize: number; offset: number }): Promise<RowDataPacket[]> {
|
||||||
|
const { scopedWhere, params, pageSize, offset } = args;
|
||||||
|
const [rows] = await db.query<RowDataPacket[]>(`SELECT b.id, b.settlement_type AS settlementType, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS time, b.order_no AS orderNo,
|
||||||
|
CAST(COALESCE(b.station_id, 0) AS CHAR) AS stationId, COALESCE(NULLIF(b.station_name, ''), '未关联站点') AS stationName,
|
||||||
|
COALESCE(b.system_customer_id, b.customer_id, 0) AS customerId, COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS customerName,
|
||||||
|
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS plateNo, COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
|
||||||
|
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus, b.vehicle_id AS vehicleId,
|
||||||
|
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg, ROUND(COALESCE(b.cost_price, 0), 2) AS unitPrice,
|
||||||
|
ROUND(COALESCE(b.cost_total, 0), 2) AS cost, ROUND(COALESCE(b.fee_total, 0), 2) AS revenue
|
||||||
|
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}
|
||||||
|
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下钻的金额口径片段:在基础 WHERE 之上追加承担方条件。 */
|
||||||
|
export function buildScopedWhere(sqlWhere: string, amountScope: string): string {
|
||||||
|
if (amountScope === 'customer') return `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`;
|
||||||
|
if (amountScope === 'company') return `${sqlWhere} AND ${COMPANY_BEARING_ORDER}`;
|
||||||
|
if (amountScope === 'other') return `${sqlWhere} AND ${OTHER_BEARING_ORDER}`;
|
||||||
|
return sqlWhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下钻分组列(站点 / 客户 / 日期 / 车牌)。 */
|
||||||
|
export function buildGroupSelect(groupBy: string): string {
|
||||||
|
if (groupBy === 'station') return "CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id, COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name, COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province, COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city";
|
||||||
|
if (groupBy === 'customer') return "COALESCE(b.system_customer_id, b.customer_id, 0) AS id, COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name, NULL AS province, NULL AS city";
|
||||||
|
if (groupBy === 'date') return `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS name, NULL AS province, NULL AS city`;
|
||||||
|
return "COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id, COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name, NULL AS province, NULL AS city";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下钻分组表达式(与 buildGroupSelect 必须成对使用)。 */
|
||||||
|
export function buildGroupExpression(groupBy: string): string {
|
||||||
|
if (groupBy === 'station') return 'COALESCE(b.station_id, 0)';
|
||||||
|
if (groupBy === 'customer') return "COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')";
|
||||||
|
if (groupBy === 'date') return `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`;
|
||||||
|
return "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由筛选条件构建 WHERE 片段与参数(顺序即 SQL 参数顺序,由契约测试锁定)。 */
|
||||||
|
export function buildFilterClauses(input: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
stationId: string | null;
|
||||||
|
customerId: number | null;
|
||||||
|
customerName: string | null;
|
||||||
|
plateNo: string | null;
|
||||||
|
vehicleScope: string;
|
||||||
|
verifyScope: string;
|
||||||
|
}): { clauses: string[]; params: unknown[] } {
|
||||||
|
const { startDate: safeStart, endDate: safeEnd, stationId, customerId, customerName, plateNo, vehicleScope, verifyScope } = input;
|
||||||
|
const clauses = [
|
||||||
|
HYDROGEN_FUEL_ONLY_WHERE_B,
|
||||||
|
`b.${HYDROGEN_LOCAL} >= ?`,
|
||||||
|
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
|
||||||
|
];
|
||||||
|
const params: unknown[] = [
|
||||||
|
safeStart < HYDROGEN_MIN_DATE ? HYDROGEN_MIN_DATE : safeStart,
|
||||||
|
safeEnd,
|
||||||
|
];
|
||||||
|
if (stationId !== null) {
|
||||||
|
clauses.push("COALESCE(b.station_id, 0) = ?");
|
||||||
|
params.push(stationId);
|
||||||
|
}
|
||||||
|
if (customerId !== null) {
|
||||||
|
clauses.push("COALESCE(b.system_customer_id, b.customer_id, 0) = ?");
|
||||||
|
params.push(customerId);
|
||||||
|
}
|
||||||
|
if (customerName) {
|
||||||
|
clauses.push(
|
||||||
|
"COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') = ?",
|
||||||
|
);
|
||||||
|
params.push(customerName);
|
||||||
|
}
|
||||||
|
if (plateNo) {
|
||||||
|
clauses.push("COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?");
|
||||||
|
params.push(plateNo);
|
||||||
|
}
|
||||||
|
if (vehicleScope === "lingniu") clauses.push("b.vehicle_id IS NOT NULL");
|
||||||
|
if (vehicleScope === "external") clauses.push("b.vehicle_id IS NULL");
|
||||||
|
if (verifyScope === "verified")
|
||||||
|
clauses.push(
|
||||||
|
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'",
|
||||||
|
);
|
||||||
|
if (verifyScope === "unverified")
|
||||||
|
clauses.push(
|
||||||
|
"LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) <> 'verified'",
|
||||||
|
"b.refuel_time >= '2026-05-01'",
|
||||||
|
);
|
||||||
|
return { clauses, params };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user