后端目录 - db/:mysql / hydrogen / heatmap 连接与氢能只读 SQL 守卫收拢到一处。 - middleware/:auth(JWT → 注入 user)与 read-only 归位。 - 相关测试随文件移动(middleware/read-only.test.ts、db/hydrogen-read-only.test.ts)。 import 改写由一次性 codemod 完成,未改变任何逻辑。 架构守护 - 新增 src/architecture.test.ts,断言 6 条分层铁律: server 不依赖 modules、前端不依赖 server、shared 为叶子层、 无 vendor 引用、model.ts 不依赖 react、@ts-nocheck 仅限已登记的原型快照。 豁免清单只减不增。 测试基建 - npm test 的 glob 同时匹配 .test.ts 与 .test.tsx(此前 .tsx 测试会被静默漏掉)。 文档 - 新增根 README.md:入口、快速开始、命令、目录、部署注意事项 (JWT_SECRET 必须注入且 >=32 字符,否则拒绝启动)。 - 新增 docs/ARCHITECTURE.md:依赖方向、6 条硬规则、业务域形状、 中间件顺序、新增模块步骤,以及"已知未尽事项"的诚实清单。 lint / test(134) / build 全绿。
391 lines
18 KiB
TypeScript
391 lines
18 KiB
TypeScript
import type { Hono } from 'hono';
|
|
import type { RowDataPacket } from 'mysql2';
|
|
import type hydrogenPool from '../../db/hydrogen.js';
|
|
import type { cached } from './cache.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 {
|
|
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
|
cached: typeof cached;
|
|
}
|
|
|
|
function safeStationId(value: string | undefined): number | null {
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
}
|
|
|
|
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,
|
|
{ hydrogenPool, cached }: HydrogenStationBoardDependencies,
|
|
) {
|
|
app.get('/hydrogen/station-board', async (c) => {
|
|
const range = resolveDateRange(
|
|
'last15',
|
|
c.req.query('startDate'),
|
|
c.req.query('endDate'),
|
|
);
|
|
const stationId = safeStationId(c.req.query('stationId'));
|
|
const force = c.req.query('force') === '1';
|
|
const cacheKey = `hydrogen/station-board?start=${range.start}&end=${range.end}${stationId ? `&station=${stationId}` : ''}`;
|
|
|
|
const data = await cached(cacheKey, async () => {
|
|
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
|
`SELECT s.id,
|
|
COALESCE(NULLIF(s.station_short_name, ''), NULLIF(s.station_name, ''), CONCAT('未知站点 #', s.id)) AS name,
|
|
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
|
|
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
|
|
ROUND(COALESCE(SUM(b.amount_kg), 0), 2) AS kg,
|
|
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS fee,
|
|
COUNT(b.id) AS recordCount,
|
|
DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
|
|
FROM hydrogen_station s
|
|
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
|
|
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
|
AND dp.STATUS = 'VALID'
|
|
LEFT JOIN common_district dc
|
|
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
|
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
|
AND dc.STATUS = 'VALID'
|
|
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`,
|
|
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
|
|
);
|
|
|
|
const [paymentRows] = await hydrogenPool.query<RowDataPacket[]>(
|
|
`SELECT station_id AS stationId,
|
|
ROUND(SUM(amount), 2) AS amount,
|
|
COUNT(*) AS paymentCount,
|
|
DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
|
|
FROM hydrogen_station_payment
|
|
WHERE del_flag = '0'
|
|
AND payment_date >= ?
|
|
AND payment_date <= ?
|
|
GROUP BY station_id`,
|
|
[range.start, range.end],
|
|
);
|
|
const [summaryDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
|
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
|
ROUND(SUM(b.amount_kg), 2) AS kg,
|
|
ROUND(SUM(b.cost_total), 2) AS fee,
|
|
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`,
|
|
[HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end],
|
|
);
|
|
const [stationDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
|
`SELECT b.station_id AS stationId,
|
|
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
|
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`,
|
|
[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,
|
|
ROUND(SUM(amount), 2) AS amount,
|
|
COUNT(*) AS paymentCount
|
|
FROM hydrogen_station_payment
|
|
WHERE del_flag = '0'
|
|
AND payment_date >= ?
|
|
AND payment_date <= ?
|
|
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
|
|
ORDER BY date ASC`,
|
|
[range.start, range.end],
|
|
);
|
|
const paymentByStation = new Map(paymentRows.map(row => [numberValue(row.stationId), row]));
|
|
const dailyKgByStation = new Map<number, Map<string, number>>();
|
|
for (const row of stationDailyRows) {
|
|
const id = numberValue(row.stationId);
|
|
const values = dailyKgByStation.get(id) ?? new Map<string, number>();
|
|
values.set(String(row.date), numberValue(row.kg));
|
|
dailyKgByStation.set(id, values);
|
|
}
|
|
const totalKg = stationRows.reduce((sum, row) => sum + numberValue(row.kg), 0);
|
|
const stations = stationRows.map(row => {
|
|
const id = numberValue(row.id);
|
|
const payment = paymentByStation.get(id);
|
|
return {
|
|
id,
|
|
name: String(row.name),
|
|
province: String(row.province),
|
|
city: String(row.city),
|
|
kg: numberValue(row.kg),
|
|
fee: numberValue(row.fee),
|
|
recordCount: numberValue(row.recordCount),
|
|
paymentAmount: numberValue(payment?.amount),
|
|
paymentCount: numberValue(payment?.paymentCount),
|
|
share: totalKg > 0 ? numberValue(row.kg) / totalKg : 0,
|
|
latestLedgerTime: typeof row.latestLedgerTime === 'string' ? row.latestLedgerTime : null,
|
|
latestPaymentDate: typeof payment?.latestPaymentDate === 'string' ? payment.latestPaymentDate : null,
|
|
dailyKg: enumerateDateRange(range.start, range.end).map(date => ({
|
|
date,
|
|
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
|
|
})),
|
|
};
|
|
}).filter(station => station.recordCount > 0
|
|
|| station.name.includes('东鹏大道')
|
|
|| (station.name.includes('佛山南海') && station.name.includes('羚牛')));
|
|
|
|
let selected = null;
|
|
if (stationId) {
|
|
const [dailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
|
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
|
ROUND(SUM(b.amount_kg), 2) AS kg,
|
|
ROUND(SUM(b.cost_total), 2) AS fee,
|
|
ROUND(AVG(NULLIF(b.cost_price, 0)), 2) AS avgPrice,
|
|
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`,
|
|
[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,
|
|
ROUND(SUM(amount), 2) AS amount,
|
|
COUNT(*) AS paymentCount
|
|
FROM hydrogen_station_payment
|
|
WHERE del_flag = '0'
|
|
AND station_id = ?
|
|
AND payment_date >= ?
|
|
AND payment_date <= ?
|
|
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
|
|
ORDER BY date ASC`,
|
|
[stationId, range.start, range.end],
|
|
);
|
|
const paymentByDate = new Map(dailyPaymentRows.map(row => [String(row.date), row]));
|
|
const ledgerByDate = new Map(dailyRows.map(row => [String(row.date), row]));
|
|
let previousKg = 0;
|
|
const daily = enumerateDateRange(range.start, range.end).map(date => {
|
|
const ledger = ledgerByDate.get(date);
|
|
const kg = numberValue(ledger?.kg);
|
|
const payment = paymentByDate.get(date);
|
|
const result = {
|
|
date,
|
|
kg,
|
|
fee: numberValue(ledger?.fee),
|
|
avgPrice: numberValue(ledger?.avgPrice),
|
|
recordCount: numberValue(ledger?.recordCount),
|
|
changeKg: kg - previousKg,
|
|
paymentAmount: numberValue(payment?.amount),
|
|
paymentCount: numberValue(payment?.paymentCount),
|
|
};
|
|
previousKg = kg;
|
|
return result;
|
|
});
|
|
|
|
const [customerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
|
`${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(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户')
|
|
ORDER BY month ASC, kg DESC`,
|
|
[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,
|
|
customerMonths: customerMonthRows.map(row => ({
|
|
month: String(row.month),
|
|
customerName: String(row.customerName),
|
|
kg: numberValue(row.kg),
|
|
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,
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
|
|
const paymentTotal = stations.reduce((sum, station) => sum + station.paymentAmount, 0);
|
|
const summaryLedgerByDate = new Map(summaryDailyRows.map(row => [String(row.date), row]));
|
|
const summaryPaymentByDate = new Map(summaryPaymentDailyRows.map(row => [String(row.date), row]));
|
|
const summaryDaily = enumerateDateRange(range.start, range.end).map(date => {
|
|
const ledger = summaryLedgerByDate.get(date);
|
|
const payment = summaryPaymentByDate.get(date);
|
|
return {
|
|
date,
|
|
kg: numberValue(ledger?.kg),
|
|
fee: numberValue(ledger?.fee),
|
|
recordCount: numberValue(ledger?.recordCount),
|
|
paymentAmount: numberValue(payment?.amount),
|
|
paymentCount: numberValue(payment?.paymentCount),
|
|
};
|
|
});
|
|
const latestLedgerTime = stations
|
|
.map(station => station.latestLedgerTime)
|
|
.filter((value): value is string => Boolean(value))
|
|
.sort()
|
|
.at(-1) ?? null;
|
|
|
|
return {
|
|
range,
|
|
summary: {
|
|
stationCount: stations.length,
|
|
activeStationCount: stations.filter(station => station.kg > 0).length,
|
|
totalKg,
|
|
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
|
|
recordCount: stations.reduce((sum, station) => sum + station.recordCount, 0),
|
|
paymentAmount: paymentTotal,
|
|
paymentCount: stations.reduce((sum, station) => sum + station.paymentCount, 0),
|
|
latestLedgerTime,
|
|
daily: summaryDaily,
|
|
},
|
|
stations,
|
|
selected,
|
|
};
|
|
}, { force });
|
|
|
|
return c.json(data);
|
|
});
|
|
}
|