fix(energy): deliver prototype-aligned hydrogen board and acceptance fixes
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const hydrogenPool = mysql.createPool({
|
||||
const rawHydrogenPool = mysql.createPool({
|
||||
// 氢能账本归属线上业务库。保留专用变量,便于未来拆分独立只读库;
|
||||
// 未配置专用变量时,复用主业务库,避免部署环境误回退到历史数据库地址。
|
||||
host: process.env.HYDROGEN_DB_HOST || process.env.DB_HOST,
|
||||
@@ -16,4 +17,13 @@ const hydrogenPool = mysql.createPool({
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
const rawQuery = rawHydrogenPool.query.bind(rawHydrogenPool) as typeof rawHydrogenPool.query;
|
||||
const hydrogenPool = {
|
||||
query: ((sql: unknown, values?: unknown) => {
|
||||
const statement = typeof sql === 'string' ? sql : String((sql as { sql?: unknown })?.sql ?? '');
|
||||
assertHydrogenReadOnlySql(statement, process.env.HYDROGEN_DB_READ_ONLY === '1');
|
||||
return rawQuery(sql as never, values as never);
|
||||
}) as typeof rawHydrogenPool.query,
|
||||
};
|
||||
|
||||
export default hydrogenPool;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { assertHydrogenReadOnlySql } from './hydrogen-read-only.js';
|
||||
|
||||
test('只读模式允许查询语句', () => {
|
||||
for (const sql of ['SELECT 1', ' SHOW TABLES', 'WITH rows AS (SELECT 1) SELECT * FROM rows', 'EXPLAIN SELECT 1']) {
|
||||
assert.doesNotThrow(() => assertHydrogenReadOnlySql(sql, true));
|
||||
}
|
||||
});
|
||||
|
||||
test('只读模式拒绝数据库写入语句', () => {
|
||||
for (const sql of ['INSERT INTO ledger VALUES (1)', 'UPDATE ledger SET value = 1', 'DELETE FROM ledger', 'ALTER TABLE ledger ADD value INT']) {
|
||||
assert.throws(
|
||||
() => assertHydrogenReadOnlySql(sql, true),
|
||||
/HYDROGEN_DB_READ_ONLY blocks non-read SQL/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const READ_ONLY_SQL = /^\s*(SELECT|SHOW|WITH|EXPLAIN)\b/i;
|
||||
|
||||
export function assertHydrogenReadOnlySql(statement: string, enabled: boolean) {
|
||||
if (enabled && !READ_ONLY_SQL.test(statement)) {
|
||||
throw new Error('HYDROGEN_DB_READ_ONLY blocks non-read SQL');
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export interface HydrogenBiV2Dependencies {
|
||||
}
|
||||
|
||||
type VehicleScope = "all" | "lingniu" | "external";
|
||||
type VerifyScope = "all" | "verified";
|
||||
type VerifyScope = "all" | "verified" | "unverified";
|
||||
type AmountScope = "all" | "customer" | "company" | "other";
|
||||
type GroupBy = "station" | "customer" | "date" | "vehicle" | "record";
|
||||
type RegionGranularity = "province" | "city";
|
||||
@@ -59,19 +59,9 @@ function endOfMonth(month: string) {
|
||||
return new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function stationRegionSql(granularity: RegionGranularity) {
|
||||
// new_hydrogen_site is the OneOS station master (453 active stations). The
|
||||
// historical hydrogen_station table only contains 89 records, so it cannot
|
||||
// be used to decide a ledger station's province/city.
|
||||
function stationMasterRegionSql(granularity: RegionGranularity) {
|
||||
const districtColumn = granularity === "province" ? "rs.province" : "rs.city";
|
||||
const stationName = `COALESCE(
|
||||
(SELECT COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''))
|
||||
FROM new_hydrogen_site rs
|
||||
WHERE rs.id = b.station_id AND rs.del_flag = '0'
|
||||
LIMIT 1),
|
||||
b.station_name,
|
||||
''
|
||||
)`;
|
||||
const stationName = "COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''), '')";
|
||||
const fallback =
|
||||
granularity === "province"
|
||||
? `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '浙江省'
|
||||
@@ -85,16 +75,7 @@ function stationRegionSql(granularity: RegionGranularity) {
|
||||
WHEN ${stationName} LIKE '%成都%' THEN '成都市'
|
||||
WHEN ${stationName} LIKE '%昆山%' THEN '昆山市'
|
||||
ELSE '未归属区域' END`;
|
||||
return `COALESCE(
|
||||
(SELECT NULLIF(rd.NAME, '')
|
||||
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.id = b.station_id AND rs.del_flag = '0'
|
||||
LIMIT 1),
|
||||
${fallback}
|
||||
)`;
|
||||
return `COALESCE(NULLIF(rd.NAME, ''), ${fallback})`;
|
||||
}
|
||||
|
||||
function todayYmd() {
|
||||
@@ -165,7 +146,9 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
? (query("vehicleScope") as VehicleScope)
|
||||
: "all";
|
||||
const verifyScope: VerifyScope =
|
||||
query("verifyScope") === "verified" ? "verified" : "all";
|
||||
query("verifyScope") === "verified" || query("verifyScope") === "unverified"
|
||||
? (query("verifyScope") as VerifyScope)
|
||||
: "all";
|
||||
const clauses = [
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= ?`,
|
||||
@@ -193,16 +176,16 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
clauses.push("COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?");
|
||||
params.push(plateNo);
|
||||
}
|
||||
if (region) {
|
||||
clauses.push(`${stationRegionSql(regionGranularity)} = ?`);
|
||||
params.push(region);
|
||||
}
|
||||
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'",
|
||||
);
|
||||
return {
|
||||
startDate: safeStart,
|
||||
endDate: safeEnd,
|
||||
@@ -221,8 +204,37 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
};
|
||||
}
|
||||
|
||||
function where(filter: Filter) {
|
||||
return filter.clauses.join(" AND ");
|
||||
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 {
|
||||
@@ -311,6 +323,13 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
||||
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 b.del_flag = '0'
|
||||
AND b.station_id = s.id
|
||||
AND COALESCE(b.amount_kg, 0) > 0
|
||||
)
|
||||
ORDER BY name`,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
@@ -343,7 +362,7 @@ async function overview(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||
const month = filter.endDate.slice(0, 7);
|
||||
const [summaryRows, monthlyRows, stationRows, customerRows] =
|
||||
await Promise.all([
|
||||
@@ -367,7 +386,7 @@ async function overview(
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}`,
|
||||
[month, month, filter.endDate, filter.endDate, ...filter.params],
|
||||
[month, month, filter.endDate, filter.endDate, ...params],
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||
@@ -384,7 +403,7 @@ async function overview(
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m')
|
||||
ORDER BY month`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
||||
@@ -408,8 +427,9 @@ async function overview(
|
||||
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`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
||||
@@ -430,7 +450,7 @@ async function overview(
|
||||
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`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
const summary: RowDataPacket = summaryRows[0][0] ?? ({} as RowDataPacket);
|
||||
@@ -575,7 +595,7 @@ async function daily(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
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,
|
||||
@@ -589,12 +609,12 @@ async function daily(
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date`,
|
||||
filter.params,
|
||||
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}`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
const map = new Map(rows[0].map((row) => [String(row.date), row]));
|
||||
@@ -647,7 +667,7 @@ async function dailyTree(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
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,
|
||||
@@ -660,7 +680,7 @@ async function dailyTree(
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.station_id, 0)
|
||||
ORDER BY kg DESC`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS stationId,
|
||||
@@ -673,7 +693,7 @@ async function dailyTree(
|
||||
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`,
|
||||
filter.params,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
const customersByStation = new Map<
|
||||
@@ -719,7 +739,7 @@ async function drill(
|
||||
pageSize: number,
|
||||
amountScope: AmountScope,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const { sql: sqlWhere, params } = await resolvedWhere(hydrogenPool, filter);
|
||||
const scopedWhere =
|
||||
amountScope === "customer"
|
||||
? `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`
|
||||
@@ -728,7 +748,6 @@ async function drill(
|
||||
: amountScope === "other"
|
||||
? `${sqlWhere} AND ${OTHER_BEARING_ORDER}`
|
||||
: sqlWhere;
|
||||
const params = filter.params;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const groupSelect =
|
||||
groupBy === "station"
|
||||
@@ -747,6 +766,8 @@ async function drill(
|
||||
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
|
||||
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC";
|
||||
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,
|
||||
@@ -762,6 +783,7 @@ async function drill(
|
||||
? 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,
|
||||
@@ -773,21 +795,24 @@ async function drill(
|
||||
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],
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS time, b.order_no AS orderNo,
|
||||
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],
|
||||
),
|
||||
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] ?? {};
|
||||
return {
|
||||
@@ -816,8 +841,10 @@ async function drill(
|
||||
revenue: number(row.revenue),
|
||||
lingniuKg: number(row.lingniuKg, 3),
|
||||
externalKg: number(row.externalKg, 3),
|
||||
settlementTypes: row.settlementTypes == null ? null : String(row.settlementTypes),
|
||||
})),
|
||||
records: recordRows[0].map((row) => ({
|
||||
settlementType: row.settlementType == null ? null : String(row.settlementType),
|
||||
id: String(row.id),
|
||||
time: String(row.time),
|
||||
orderNo: String(row.orderNo || ""),
|
||||
|
||||
@@ -19,7 +19,7 @@ function numberValue(value: unknown): number {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
// 单站经营看板只做只读聚合。站点列表保留区间内零业务站点,便于核对站点覆盖范围。
|
||||
// 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。
|
||||
export function registerHydrogenStationBoardRoute(
|
||||
app: Hono,
|
||||
{ hydrogenPool, cached }: HydrogenStationBoardDependencies,
|
||||
@@ -140,7 +140,7 @@ export function registerHydrogenStationBoardRoute(
|
||||
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}).filter(station => station.recordCount > 0);
|
||||
|
||||
let selected = null;
|
||||
if (stationId) {
|
||||
|
||||
@@ -115,6 +115,7 @@ test("氢能 BI v2 使用独立真实账本合同,并且不让前端拼接旧
|
||||
assert.equal(calls.length, 3);
|
||||
assert.match(calls[0].sql, /FROM hydrogen_fuel_ledger/);
|
||||
assert.match(calls[1].sql, /FROM new_hydrogen_site/);
|
||||
assert.match(calls[1].sql, /EXISTS\s*\([\s\S]*amount_kg[\s\S]*> 0/);
|
||||
});
|
||||
|
||||
test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文中复用同一过滤口径", async () => {
|
||||
@@ -124,6 +125,7 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
hydrogenPool: {
|
||||
query: createQueryMock(
|
||||
[
|
||||
[{ id: "123" }],
|
||||
[{ recordCount: 2, kg: 30, cost: 900, revenue: 1000 }],
|
||||
[
|
||||
{
|
||||
@@ -141,25 +143,6 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
externalKg: 30,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 9,
|
||||
time: "2026-08-18 08:00:00",
|
||||
orderNo: "H2-9",
|
||||
stationId: 0,
|
||||
stationName: "未关联站点",
|
||||
customerId: 0,
|
||||
customerName: "未关联客户",
|
||||
plateNo: "无车牌",
|
||||
source: "import",
|
||||
verifyStatus: "VERIFIED",
|
||||
vehicleId: null,
|
||||
kg: 30,
|
||||
unitPrice: 30,
|
||||
cost: 900,
|
||||
revenue: 1000,
|
||||
},
|
||||
],
|
||||
],
|
||||
calls,
|
||||
),
|
||||
@@ -182,6 +165,8 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
assert.equal(payload.summary.kg, 30);
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
assert.deepEqual(calls[0].params, ["嘉兴市"]);
|
||||
assert.match(calls[0].sql, /FROM new_hydrogen_site rs/);
|
||||
const commonParams = [
|
||||
"2026-08-18",
|
||||
"2026-08-18",
|
||||
@@ -189,27 +174,26 @@ test("氢能 BI v2 下钻在日期、站点、客户、车辆及区域上下文
|
||||
0,
|
||||
"未关联客户",
|
||||
"无车牌",
|
||||
"嘉兴市",
|
||||
"123",
|
||||
];
|
||||
assert.deepEqual(calls[0].params, commonParams);
|
||||
assert.deepEqual((calls[1].params as unknown[]).slice(0, -2), commonParams);
|
||||
assert.deepEqual(calls[1].params, commonParams);
|
||||
assert.deepEqual((calls[2].params as unknown[]).slice(0, -2), commonParams);
|
||||
for (const call of calls) {
|
||||
for (const call of calls.slice(1)) {
|
||||
assert.match(call.sql, /COALESCE\(b\.station_id, 0\) = \?/);
|
||||
assert.match(
|
||||
call.sql,
|
||||
/COALESCE\(b\.system_customer_id, b\.customer_id, 0\) = \?/,
|
||||
);
|
||||
assert.match(call.sql, /COALESCE\(NULLIF\(b\.license_plate/);
|
||||
assert.match(call.sql, /new_hydrogen_site rs/);
|
||||
assert.match(call.sql, /b\.station_id IN \(\?\)/);
|
||||
assert.match(call.sql, /b\.vehicle_id IS NULL/);
|
||||
assert.match(call.sql, /verify_status/);
|
||||
}
|
||||
assert.match(
|
||||
calls[1].sql,
|
||||
calls[2].sql,
|
||||
/GROUP BY DATE_FORMAT\(b\.refuel_time, '%Y-%m-%d'\)/,
|
||||
);
|
||||
assert.match(calls[1].sql, /ORDER BY id DESC/);
|
||||
assert.match(calls[2].sql, /ORDER BY id DESC/);
|
||||
});
|
||||
|
||||
test("氢能 BI v2 利润下钻只保留客户承担订单,并与对客总价使用同一订单集合", async () => {
|
||||
@@ -268,7 +252,7 @@ test("氢能 BI v2 利润下钻只保留客户承担订单,并与对客总价
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.amountScope, "customer");
|
||||
assert.equal(payload.summary.revenue - payload.summary.cost, 60);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls.length, 2);
|
||||
for (const call of calls) {
|
||||
assert.match(
|
||||
call.sql,
|
||||
@@ -473,7 +457,7 @@ test("氢能 BI v2 月度上下文换算为完整自然月并沿用至明细", a
|
||||
]);
|
||||
});
|
||||
|
||||
test("单站看板保留零业务站点并合并区间现结流水", async () => {
|
||||
test("单站看板仅保留有加氢记录的站点并合并区间现结流水", async () => {
|
||||
const calls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
const stationBoardApp = new Hono();
|
||||
@@ -533,7 +517,7 @@ test("单站看板保留零业务站点并合并区间现结流水", async () =>
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(payload.summary, {
|
||||
stationCount: 2,
|
||||
stationCount: 1,
|
||||
activeStationCount: 1,
|
||||
totalKg: 100,
|
||||
totalFee: 3500,
|
||||
@@ -560,8 +544,8 @@ test("单站看板保留零业务站点并合并区间现结流水", async () =>
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(payload.stations[1].name, "零业务站");
|
||||
assert.equal(payload.stations[1].kg, 0);
|
||||
assert.equal(payload.stations.length, 1);
|
||||
assert.equal(payload.stations[0].name, "测试加氢站");
|
||||
assert.deepEqual(payload.stations[0].dailyKg, [
|
||||
{ date: "2026-08-16", kg: 40 },
|
||||
{ date: "2026-08-17", kg: 60 },
|
||||
|
||||
Reference in New Issue
Block a user