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/参数/响应体已做等价性验证 |
|
||||
| `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 <table> set`)而不是裸关键字,避免把
|
||||
`UpdateNotification` 或日志里的 "update error" 误判为 SQL;小写 SQL 同样能被抓到。
|
||||
|
||||
@@ -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 也依然能被抓到。
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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<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) {
|
||||
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<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],
|
||||
),
|
||||
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],
|
||||
),
|
||||
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<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS totalCost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS totalRevenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS monthKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.cost_total ELSE 0 END), 0), 2) AS monthCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS todayKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.cost_total ELSE 0 END), 0), 2) AS todayCost,
|
||||
COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}`,
|
||||
[month, month, filter.endDate, filter.endDate, ...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,
|
||||
),
|
||||
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<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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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<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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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<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,
|
||||
),
|
||||
loadDrillSummary(hydrogenPool, { scopedWhere, params }),
|
||||
groupBy === "record"
|
||||
? Promise.resolve([[] as RowDataPacket[]])
|
||||
: hydrogenPool.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],
|
||||
),
|
||||
? Promise.resolve([] as RowDataPacket[])
|
||||
: loadDrillGroups(hydrogenPool, { scopedWhere, params, groupSelect, groupExpression, groupHaving, groupOrder, pageSize, offset }),
|
||||
groupBy === "record"
|
||||
? hydrogenPool.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],
|
||||
)
|
||||
: 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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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