feat(energy): integrate self-operated station customers and receipts
ci/woodpecker/push/woodpecker Pipeline was canceled

Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
kfluous
2026-09-05 17:03:15 +08:00
co-authored by HiFox Agent
parent 637a72c1e9
commit a177781fe7
14 changed files with 380 additions and 71 deletions
+4
View File
@@ -5,4 +5,8 @@ export const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
export const HYDROGEN_LOCAL = `refuel_time`;
export const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
export const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
// 充值记录与加氢事实共用台账。经营、日报和下钻必须显式排除充值,
// 充值模块则继续使用 BASE 条件读取自己的流水。
export const HYDROGEN_FUEL_ONLY_WHERE = `${HYDROGEN_BASE_WHERE} AND COALESCE(record_source, '') <> 'external_recharge_manual'`;
export const HYDROGEN_FUEL_ONLY_WHERE_B = `${HYDROGEN_BASE_WHERE_B} AND COALESCE(b.record_source, '') <> 'external_recharge_manual'`;
export const ELECTRIC_LOCAL = `charging_start_time`;
+6 -5
View File
@@ -2,7 +2,8 @@ import type { Hono } from "hono";
import type { RowDataPacket } from "mysql2";
import type hydrogenPool from "../../hydrogen-db.js";
import {
HYDROGEN_BASE_WHERE_B,
HYDROGEN_FUEL_ONLY_WHERE,
HYDROGEN_FUEL_ONLY_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
@@ -150,7 +151,7 @@ function resolveFilter(query: (key: string) => string | undefined): Filter {
? (query("verifyScope") as VerifyScope)
: "all";
const clauses = [
HYDROGEN_BASE_WHERE_B,
HYDROGEN_FUEL_ONLY_WHERE_B,
`b.${HYDROGEN_LOCAL} >= ?`,
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
];
@@ -309,7 +310,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
DATE_FORMAT(MIN(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS startDate,
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS endDate
FROM ${HYDROGEN_TABLE}
WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ?
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${HYDROGEN_LOCAL})
ORDER BY value DESC`,
[HYDROGEN_MIN_DATE],
@@ -326,7 +327,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
AND EXISTS (
SELECT 1
FROM ${HYDROGEN_TABLE} b
WHERE b.del_flag = '0'
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND b.station_id = s.id
AND COALESCE(b.amount_kg, 0) > 0
)
@@ -335,7 +336,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
FROM ${HYDROGEN_TABLE}
WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ?`,
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
[HYDROGEN_MIN_DATE],
),
]);
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import { HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
import { HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
import { customerClause, type CustomerKind } from './query-model.js';
export interface HydrogenDailyDetailDependencies {
@@ -47,7 +47,7 @@ export function registerHydrogenDailyDetailRoute(
ROUND(COALESCE(b.cost_total, 0), 2) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND DATE(b.${HYDROGEN_LOCAL}) = ?
AND ${customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id')}${verifyScope === 'verified' ? " AND LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'" : ''}${stationClause}
ORDER BY b.${HYDROGEN_LOCAL} ASC, b.id ASC
+2 -2
View File
@@ -3,7 +3,7 @@ import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import {
HYDROGEN_BASE_WHERE_B,
HYDROGEN_FUEL_ONLY_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
@@ -37,7 +37,7 @@ export function registerHydrogenDailyRoute(
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}${verifyScope === 'verified' ? '&verify=verified' : ''}`, async () => {
const where = [
HYDROGEN_BASE_WHERE_B,
HYDROGEN_FUEL_ONLY_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id'),
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import { HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE } from './constants.js';
import { HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE } from './constants.js';
export interface HydrogenOverviewDetailDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
@@ -44,7 +44,7 @@ export function registerHydrogenOverviewDetailRoute(
: 2_000;
const force = c.req.query('force') === '1';
const clauses = [HYDROGEN_BASE_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `YEAR(b.${HYDROGEN_LOCAL}) = ?`];
const clauses = [HYDROGEN_FUEL_ONLY_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `YEAR(b.${HYDROGEN_LOCAL}) = ?`];
const params: unknown[] = [HYDROGEN_MIN_DATE, year];
if (vehicleScope === 'lingniu') clauses.push('b.vehicle_id IS NOT NULL');
if (vehicleScope === 'external') clauses.push('b.vehicle_id IS NULL');
@@ -3,8 +3,8 @@ import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import {
HYDROGEN_BASE_WHERE,
HYDROGEN_BASE_WHERE_B,
HYDROGEN_FUEL_ONLY_WHERE,
HYDROGEN_FUEL_ONLY_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
@@ -95,7 +95,7 @@ export function registerHydrogenOverviewRoute(
`SELECT YEAR(${HYDROGEN_LOCAL}) AS y,
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${HYDROGEN_LOCAL})
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
@@ -150,7 +150,7 @@ export function registerHydrogenOverviewRoute(
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`,
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`,
[year, year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
@@ -197,7 +197,7 @@ export function registerHydrogenOverviewRoute(
SUM(b.cost_total) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
@@ -230,7 +230,7 @@ export function registerHydrogenOverviewRoute(
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND d.STATUS = 'VALID'
WHERE ${HYDROGEN_BASE_WHERE_B}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
@@ -271,7 +271,7 @@ export function registerHydrogenOverviewRoute(
ON CONVERT(d.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND d.STATUS = 'VALID'
WHERE ${HYDROGEN_BASE_WHERE_B}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
) r
@@ -303,7 +303,7 @@ export function registerHydrogenOverviewRoute(
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY m
@@ -343,7 +343,7 @@ export function registerHydrogenOverviewRoute(
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY name
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import { HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
import { HYDROGEN_BASE_WHERE, HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js';
export interface HydrogenStationBoardDependencies {
@@ -19,6 +19,30 @@ function numberValue(value: unknown): number {
return Number(value) || 0;
}
// 单站接口没有租户入参,租户只能由服务端配置决定,不能接受浏览器传值。
const HYDROGEN_TENANT_ID = process.env.HYDROGEN_TENANT_ID?.trim() || '000000';
const MANUAL_RECHARGE_SOURCE = 'external_recharge_manual';
const NORMALIZED_PLATE = (column: string) =>
`UPPER(REPLACE(REPLACE(REPLACE(TRIM(${column}), ' ', ''), '.', ''), '·', ''))`;
const NORMALIZED_PLATE_UNICODE = (column: string) =>
`CONVERT(${NORMALIZED_PLATE(column)} USING utf8mb4) COLLATE utf8mb4_unicode_ci`;
const EXTERNAL_CUSTOMER_MAP_CTE = `
WITH external_vehicle_candidates AS (
SELECT ${NORMALIZED_PLATE_UNICODE('plate_number')} AS normalizedPlate,
MAX(NULLIF(TRIM(actual_user), '')) AS customerName,
COUNT(DISTINCT NULLIF(TRIM(actual_user), '')) AS customerCount
FROM hydrogen_order_transfer_external_vehicle_user
WHERE NULLIF(TRIM(plate_number), '') IS NOT NULL
GROUP BY ${NORMALIZED_PLATE_UNICODE('plate_number')}
), external_vehicle_map AS (
SELECT normalizedPlate, customerName
FROM external_vehicle_candidates
WHERE customerCount = 1
AND customerName IS NOT NULL
)`;
// 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。
export function registerHydrogenStationBoardRoute(
app: Hono,
@@ -48,6 +72,8 @@ export function registerHydrogenStationBoardRoute(
LEFT JOIN ${HYDROGEN_TABLE} b
ON b.station_id = s.id
AND ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
LEFT JOIN common_district dp
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
@@ -60,7 +86,7 @@ export function registerHydrogenStationBoardRoute(
WHERE s.del_flag = '0'
GROUP BY s.id, s.station_short_name, s.station_name, dp.NAME, s.province, dc.NAME, s.city
ORDER BY kg DESC, name ASC`,
[range.start, range.end],
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
);
const [paymentRows] = await hydrogenPool.query<RowDataPacket[]>(
@@ -82,10 +108,12 @@ export function registerHydrogenStationBoardRoute(
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`,
[range.start, range.end],
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
);
const [stationDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS stationId,
@@ -93,10 +121,12 @@ export function registerHydrogenStationBoardRoute(
ROUND(SUM(b.amount_kg), 2) AS kg
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY stationId ASC, date ASC`,
[range.start, range.end],
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
);
const [summaryPaymentDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
@@ -154,11 +184,13 @@ export function registerHydrogenStationBoardRoute(
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
ORDER BY date ASC`,
[stationId, range.start, range.end],
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.start, range.end],
);
const [dailyPaymentRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
@@ -195,19 +227,90 @@ export function registerHydrogenStationBoardRoute(
});
const [customerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
COALESCE(NULLIF(MAX(b.system_customer_name), ''), NULLIF(MAX(b.customer_name), ''), '未关联客户') AS customerName,
`${EXTERNAL_CUSTOMER_MAP_CTE}
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户') AS customerName,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
LEFT JOIN external_vehicle_map m
ON CONVERT(${NORMALIZED_PLATE('b.license_plate')} USING utf8mb4) COLLATE utf8mb4_unicode_ci
= CONVERT(m.normalizedPlate USING utf8mb4) COLLATE utf8mb4_unicode_ci
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), COALESCE(b.system_customer_id, b.customer_id, 0)
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'),
COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户')
ORDER BY month ASC, kg DESC`,
[stationId, range.end, range.end],
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end],
);
const [externalCustomerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
`${EXTERNAL_CUSTOMER_MAP_CTE}
SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
m.customerName,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(COALESCE(b.fee_total, b.cost_total)), 2) AS fee,
COUNT(*) AS recordCount
FROM ${HYDROGEN_TABLE} b
INNER JOIN external_vehicle_map m
ON ${NORMALIZED_PLATE_UNICODE('b.license_plate')}
= m.normalizedPlate
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.tenant_id = ?
AND COALESCE(b.record_source, '') <> ?
AND b.station_id = ?
AND (b.vehicle_id IS NULL OR b.vehicle_id = 0)
AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH)
AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), m.customerName
ORDER BY month ASC, kg DESC, m.customerName ASC`,
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end],
);
const [externalReceiptRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT * FROM (
SELECT CONCAT('auto:', CAST(id AS CHAR)) AS id,
DATE_FORMAT(summary_date, '%Y-%m-%d') AS date,
customer_name AS customerName,
amount,
pay_method AS payMethod,
source,
spot_record_count AS sourceRecordCount,
DATE_FORMAT(COALESCE(refresh_time, update_time), '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM hydrogen_external_recharge_daily_summary
WHERE tenant_id = ?
AND summary_date >= ?
AND summary_date <= ?
UNION ALL
SELECT CONCAT('manual:', CAST(id AS CHAR)) AS id,
DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
COALESCE(NULLIF(customer_name, ''), '未关联客户') AS customerName,
fee_total AS amount,
recharge_pay_method AS payMethod,
record_source AS source,
1 AS sourceRecordCount,
DATE_FORMAT(update_time, '%Y-%m-%d %H:%i:%s') AS updatedAt
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND tenant_id = ?
AND record_source = ?
AND ${HYDROGEN_LOCAL} >= ?
AND ${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)
) externalReceipts
ORDER BY date DESC, customerName ASC, id ASC`,
[
HYDROGEN_TENANT_ID,
range.start,
range.end,
HYDROGEN_TENANT_ID,
MANUAL_RECHARGE_SOURCE,
range.start,
range.end,
],
);
selected = {
daily,
@@ -218,6 +321,28 @@ export function registerHydrogenStationBoardRoute(
fee: numberValue(row.fee),
recordCount: numberValue(row.recordCount),
})),
externalCustomerMonths: externalCustomerMonthRows.map(row => ({
month: String(row.month),
customerName: String(row.customerName),
kg: numberValue(row.kg),
fee: numberValue(row.fee),
recordCount: numberValue(row.recordCount),
})),
// 快照和手工充值都没有可靠站点归属,不能把客户级进账误报为当前站点收益。
externalReceipts: {
scope: 'customer',
reason: '当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。',
rows: externalReceiptRows.map(row => ({
id: String(row.id),
date: String(row.date),
customerName: String(row.customerName),
amount: numberValue(row.amount),
payMethod: String(row.payMethod),
source: String(row.source),
sourceRecordCount: numberValue(row.sourceRecordCount),
updatedAt: typeof row.updatedAt === 'string' ? row.updatedAt : null,
})),
},
};
}
+105 -9
View File
@@ -588,6 +588,99 @@ test("单站看板过滤非指定零业务站并合并区间现结流水", async
assert.match(calls[4].sql, /GROUP BY DATE_FORMAT\(payment_date/);
});
test("单站外部客户按唯一标准化车牌聚合,进账保持租户客户级口径", async () => {
const calls: QueryCall[] = [];
const stationBoardApp = new Hono();
registerHydrogenStationBoardRoute(stationBoardApp, {
hydrogenPool: {
query: createQueryMock(
[
[{ id: 341, name: "佛山南海羚牛加氢站", province: "广东省", city: "佛山市", kg: 10, fee: 300, recordCount: 1 }],
[], [], [], [],
[], [], [],
[
{
month: "2026-09",
customerName: "外部客户 A",
kg: null,
fee: null,
recordCount: 1,
},
],
[
{
id: "auto:9007199254740993",
date: "2026-09-05",
customerName: "外部客户 A",
amount: 200,
payMethod: "wechat",
source: "spot_daily_auto",
sourceRecordCount: 2,
updatedAt: "2026-09-05 23:59:00",
},
{
id: "manual:1",
date: "2026-09-04",
customerName: "外部客户 B",
amount: 0,
payMethod: "corporate",
source: "external_recharge_manual",
sourceRecordCount: 1,
updatedAt: null,
},
],
],
calls,
),
},
cached: createCachedMock([]),
} as unknown as HydrogenStationBoardDependencies);
const response = await stationBoardApp.request(
"/hydrogen/station-board?stationId=341&startDate=2026-09-01&endDate=2026-09-05",
);
assert.equal(response.status, 200);
const payload = await response.json();
assert.deepEqual(payload.selected.externalCustomerMonths, [{
month: "2026-09", customerName: "外部客户 A", kg: 0, fee: 0, recordCount: 1,
}]);
assert.deepEqual(payload.selected.externalReceipts, {
scope: "customer",
reason: "当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。",
rows: [
{
id: "auto:9007199254740993", date: "2026-09-05", customerName: "外部客户 A", amount: 200,
payMethod: "wechat", source: "spot_daily_auto", sourceRecordCount: 2, updatedAt: "2026-09-05 23:59:00",
},
{
id: "manual:1", date: "2026-09-04", customerName: "外部客户 B", amount: 0,
payMethod: "corporate", source: "external_recharge_manual", sourceRecordCount: 1, updatedAt: null,
},
],
});
assert.equal(calls.length, 10);
for (const index of [0, 2, 3, 5, 7, 8, 9]) {
assert.match(calls[index].sql, /tenant_id\s*=\s*\?/);
assert.ok((calls[index].params as unknown[]).includes("000000"));
}
for (const index of [0, 2, 3, 5, 7, 8]) {
assert.match(calls[index].sql, /record_source, ''\) <> \?/);
assert.ok((calls[index].params as unknown[]).includes("external_recharge_manual"));
}
assert.match(calls[8].sql, /customerCount = 1/);
// 自营站客户月度保留全部加氢事实:映射仅做补充,不过滤未匹配或内部车辆。
assert.match(calls[7].sql, /LEFT JOIN external_vehicle_map/);
assert.match(calls[7].sql, /COALESCE\(m.customerName/);
assert.doesNotMatch(calls[7].sql.split('WHERE b.del_flag')[1], /vehicle_id IS NULL/);
assert.match(calls[7].sql, /GROUP BY[\s\S]*customerName/);
assert.match(calls[8].sql, /CONVERT\(UPPER\(REPLACE/);
assert.match(calls[8].sql, /b\.vehicle_id IS NULL OR b\.vehicle_id = 0/);
assert.match(calls[9].sql, /UNION ALL/);
assert.match(calls[9].sql, /fee_total AS amount/);
assert.match(calls[9].sql, /summary_date >= \?/);
assert.match(calls[9].sql, /refuel_time < DATE_ADD\(\?, INTERVAL 1 DAY\)/);
});
test("站日现结台账仅读取付款流水,并按最近有数据的 15 天聚合", async () => {
const calls: QueryCall[] = [];
const cacheCalls: CacheCall[] = [];
@@ -807,18 +900,18 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
},
]);
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败
// 指纹覆盖加氢事实专用过滤与既有查询结构,避免手工充值重新混入统计
assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map((call) => sqlHash(call.sql)),
[
"515acf4dbb2a",
"de16af670e96",
"1367eddb3b7f",
"f125d4dde376",
"c547b8e1318e",
"0e3dfd8f4b81",
"f1f69f275993",
"688cfdb7a2bf",
"4fa77c56a990",
"ac6329507bc2",
"e46a92ba851e",
"0295b2deee31",
"852b5b0b9fe1",
"abad6dcf0b2f",
"41cc73632c26",
"1224ee38f710",
"03fd669f8077",
"6e4031b10926",
"4fc77463294c",
@@ -859,6 +952,9 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
],
);
assert.match(hydrogenCalls[7].sql, /b\.vehicle_id IS NULL/);
assert.ok(hydrogenCalls.every((call) =>
call.sql.includes("external_recharge_manual"),
));
assert.deepEqual(
electricCalls.map((call) => call.params),
[undefined, undefined, undefined, undefined, ["2025-01-01", "2025-01-02"]],