fix(energy): deliver prototype-aligned hydrogen board and acceptance fixes
ci/woodpecker/push/woodpecker Pipeline was successful

Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
kfluous
2026-09-03 22:27:23 +08:00
co-authored by HiFox Agent
parent 5eb38a4b05
commit 6c91a6694a
77 changed files with 30169 additions and 361 deletions
+76 -49
View File
@@ -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) {
+15 -31
View File
@@ -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 },