改动
- energy/hydrogen-station-board.ts 的 10 条查询全部移入 energy/repository.ts,
参数表达式逐条对应(含区间、站点、以及租户与手工充值来源的固定顺序)。
- 相关 SQL 片段助手搬进 constants.ts 并导出:
NORMALIZED_PLATE / NORMALIZED_PLATE_UNICODE / EXTERNAL_CUSTOMER_MAP_CTE,
以及 HYDROGEN_TENANT_ID / MANUAL_RECHARGE_SOURCE。
它们本质是 SQL,继续留在路由里会让"路由不含 SQL"形同虚设;放 constants.ts
是因为被多条查询共享,避免复制。
- 路由文件随之删掉 4 个只为 SQL 服务的 constants 导入(HYDROGEN_BASE_WHERE /
_B / HYDROGEN_LOCAL / HYDROGEN_TABLE),它们已不再被该文件使用。
- 架构守卫允许 constants.ts 存放共享 SQL 片段,并注明理由。
提取方式
- 用平衡括号扫描从原文件按顺序取出 10 条 SQL 与各自的参数数组(含查询 10 的多行参数),
按调用点跨度精确替换,避免手工转写 200 余行 SQL。
验证
- energy/routes.test.ts 的 13 个既有用例在提取前后均通过,其中包含单站看板的
SQL 与参数断言(迁移未改变查询文本、参数顺序与响应口径)。
- 全量 lint / test(191) / build 全绿,可达性 0 未引用文件。
未完成
- hydrogen-bi-v2.ts 仍有 7 条带 ${sqlWhere} / ${groupOrder} 等动态插值的查询,
需要把 where 组装函数一并搬进 repository;完成后再把 energy 加入守护清单。
197 lines
8.5 KiB
TypeScript
197 lines
8.5 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 {
|
|
loadCustomerMonth,
|
|
loadExternalCustomerMonth,
|
|
loadExternalReceipts,
|
|
loadRangePayments,
|
|
loadStationDaily,
|
|
loadStationDailyForStation,
|
|
loadStationList,
|
|
loadStationPaymentDaily,
|
|
loadSummaryDaily,
|
|
loadSummaryPaymentDaily,
|
|
} from './repository.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;
|
|
}
|
|
|
|
// 单站接口没有租户入参,租户只能由服务端配置决定,不能接受浏览器传值。
|
|
|
|
|
|
|
|
// 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。
|
|
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 loadStationList(hydrogenPool, { start: range.start, end: range.end });
|
|
|
|
const paymentRows = await loadRangePayments(hydrogenPool, { start: range.start, end: range.end });
|
|
const summaryDailyRows = await loadSummaryDaily(hydrogenPool, { start: range.start, end: range.end });
|
|
const stationDailyRows = await loadStationDaily(hydrogenPool, { start: range.start, end: range.end });
|
|
const summaryPaymentDailyRows = await loadSummaryPaymentDaily(hydrogenPool, { start: range.start, end: 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 loadStationDailyForStation(hydrogenPool, { stationId: stationId as number, start: range.start, end: range.end });
|
|
const dailyPaymentRows = await loadStationPaymentDaily(hydrogenPool, { stationId: stationId as number, start: range.start, end: 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 loadCustomerMonth(hydrogenPool, { stationId: stationId as number, end: range.end });
|
|
const externalCustomerMonthRows = await loadExternalCustomerMonth(hydrogenPool, { stationId: stationId as number, end: range.end });
|
|
const externalReceiptRows = await loadExternalReceipts(hydrogenPool, { start: range.start, end: 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);
|
|
});
|
|
}
|