refactor(stage3): 下线 5 个无调用方的 legacy 氢能接口

先确认调用方再删:/hydrogen/station-board 被单站日报页面(StationDailyApp /
StationDailyDetailView)实际使用,必须保留;其余 5 个确认零调用方。

- 删服务端路由 hydrogen-overview / -overview-detail / -daily / -daily-detail /
  -settlement(约 1,080 行),energy/index.ts 保留原有注册顺序语义。
- 删客户端对应请求函数与类型:api.ts 由 202 行精简为 74 行;
  types.ts 由 291 行精简为 112 行(只保留 StationBoard / Electric / ETC 闭包)。
- 顺带修正命名:HydrogenDailyQuery -> EnergyRangeQuery(它服务于电能按月接口)。
- routes.test.ts 同步收敛:删除 settlement / overview 两个已下线接口的测试,
  '四个处理器' 改为只覆盖电能;路由顺序断言更新为现存 9 条。
  电能 SQL 指纹沿用原值,证明取数口径未变。

测试 131 -> 128(删掉的 3 个用例覆盖的是已下线接口)。lint / test / build 全绿。
This commit is contained in:
dsh-agent
2026-09-11 10:16:43 +08:00
parent 7556dbcebc
commit 680c7e0662
10 changed files with 28 additions and 1738 deletions
-14
View File
@@ -1,14 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildHydrogenSettlementQuery } from './api.js';
test('现结自定义日期明确传递 custom 范围,避免服务端回退到最近有数据', () => {
assert.equal(
buildHydrogenSettlementQuery({
range: 'custom',
startDate: '2026-05-26',
endDate: '2026-05-26',
}),
'range=custom&startDate=2026-05-26&endDate=2026-05-26',
);
});
+21 -153
View File
@@ -1,117 +1,17 @@
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import type { import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
HydrogenCustomerRow, HydrogenStationFull,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup, ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick, CustomerType, DateQuickPick,
HydrogenStationBoardResponse, HydrogenStationBoardResponse,
HydrogenDailyDetailResponse,
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailResponse,
} from './types'; } from './types';
const BASE = '/api/energy'; const BASE = '/api/energy';
export interface HydrogenOverviewResponse { /**
kpi: HydrogenKpi; * 氢能单站日报数据源。
top5: HydrogenStationTop[]; * 注意:该接口路径仍带 `/hydrogen` 前缀(历史命名),但为当前单站日报页面在用,
regions: HydrogenRegionShare[]; * 与已下线的 `/hydrogen/overview|daily|settlement` 等旧接口无关。
monthly: HydrogenMonthlyPoint[]; */
customers: HydrogenCustomerRow[];
stations: HydrogenStationFull[];
availableYears: number[];
year: number;
latestLedgerTime: string | null;
filter: {
stationId: number | null;
vehicleScope: HydrogenVehicleScope;
verifyScope: HydrogenVerifyScope;
};
}
export type HydrogenVehicleScope = 'all' | 'lingniu' | 'external';
export type HydrogenVerifyScope = 'all' | 'verified';
export interface HydrogenOverviewQuery {
year?: number;
stationId?: number | null;
vehicleScope?: HydrogenVehicleScope;
verifyScope?: HydrogenVerifyScope;
force?: boolean;
}
export function fetchHydrogenOverview(query: HydrogenOverviewQuery = {}): Promise<HydrogenOverviewResponse> {
const params = new URLSearchParams();
if (query.year) params.set('year', String(query.year));
if (query.stationId) params.set('stationId', String(query.stationId));
if (query.vehicleScope && query.vehicleScope !== 'all') params.set('vehicleScope', query.vehicleScope);
if (query.verifyScope === 'verified') params.set('verifyScope', 'verified');
if (query.force) params.set('force', '1');
const q = params.toString();
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
}
export interface HydrogenOverviewDetailQuery {
year: number;
vehicleScope?: HydrogenVehicleScope;
verifyScope?: HydrogenVerifyScope;
stationId?: number | null;
month?: string | null;
date?: string | null;
customerName?: string | null;
customerId?: number | null;
plateNo?: string | null;
region?: string | null;
regionGranularity?: 'province' | 'city';
groupBy?: HydrogenOverviewDetailGroupBy | null;
limit?: number;
}
export function fetchHydrogenOverviewDetail(query: HydrogenOverviewDetailQuery): Promise<HydrogenOverviewDetailResponse> {
const params = new URLSearchParams({ year: String(query.year) });
if (query.vehicleScope && query.vehicleScope !== 'all') params.set('vehicleScope', query.vehicleScope);
if (query.verifyScope === 'verified') params.set('verifyScope', 'verified');
if (query.stationId) params.set('stationId', String(query.stationId));
if (query.month) params.set('month', query.month);
if (query.date) params.set('date', query.date);
if (query.customerName) params.set('customerName', query.customerName);
if (query.customerId !== null && query.customerId !== undefined) params.set('customerId', String(query.customerId));
if (query.plateNo) params.set('plateNo', query.plateNo);
if (query.region) params.set('region', query.region);
if (query.regionGranularity) params.set('regionGranularity', query.regionGranularity);
if (query.groupBy) params.set('groupBy', query.groupBy);
if (query.limit) params.set('limit', String(query.limit));
return fetchJson<HydrogenOverviewDetailResponse>(`${BASE}/hydrogen/overview-detail?${params.toString()}`);
}
export interface HydrogenDailyQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
verifyScope?: HydrogenVerifyScope;
}
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ customer });
if (query.range) q.set('range', query.range);
if (query.startDate) q.set('startDate', query.startDate);
if (query.endDate) q.set('endDate', query.endDate);
if (query.verifyScope === 'verified') q.set('verifyScope', 'verified');
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
export function fetchHydrogenDailyDetail(
date: string,
customer: CustomerType,
stationId?: number | null,
verifyScope: HydrogenVerifyScope = 'all',
): Promise<HydrogenDailyDetailResponse> {
const params = new URLSearchParams({ date, customer });
if (stationId) params.set('stationId', String(stationId));
if (verifyScope === 'verified') params.set('verifyScope', 'verified');
return fetchJson<HydrogenDailyDetailResponse>(`${BASE}/hydrogen/daily-detail?${params.toString()}`);
}
export interface HydrogenStationBoardQuery { export interface HydrogenStationBoardQuery {
startDate: string; startDate: string;
endDate: string; endDate: string;
@@ -126,6 +26,13 @@ export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Pro
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`); return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`);
} }
/** 电能与 ETC 共用的自然日区间查询参数。 */
export interface EnergyRangeQuery {
range?: DateQuickPick;
startDate?: string;
endDate?: string;
}
export interface ElectricOverviewResponse { export interface ElectricOverviewResponse {
kpi: ElectricKpi; kpi: ElectricKpi;
trend: ElectricDailyRow[]; trend: ElectricDailyRow[];
@@ -136,12 +43,15 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`); return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
} }
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> { export function fetchElectricMonthly(
const q = new URLSearchParams({ customer }); customer: CustomerType,
if (query.range) q.set('range', query.range); query: EnergyRangeQuery = { range: 'last15' },
if (query.startDate) q.set('startDate', query.startDate); ): Promise<ElectricMonthGroup[]> {
if (query.endDate) q.set('endDate', query.endDate); const params = new URLSearchParams({ customer });
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`); if (query.range) params.set('range', query.range);
if (query.startDate) params.set('startDate', query.startDate);
if (query.endDate) params.set('endDate', query.endDate);
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${params.toString()}`);
} }
export interface EtcOverviewResponse { export interface EtcOverviewResponse {
@@ -158,45 +68,3 @@ export interface EtcOverviewResponse {
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> { export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`); return fetchJson<EtcOverviewResponse>(`${BASE}/etc/overview${force ? '?force=1' : ''}`);
} }
export type HydrogenSettlementRange = DateQuickPick | 'latest';
export interface HydrogenSettlementRow {
date: string;
stationId: number | null;
stationName: string;
amount: number;
paymentCount: number;
matchMode: 'exact' | 'manual' | 'group' | 'unmatched';
}
export interface HydrogenSettlementResponse {
range: { start: string | null; end: string | null; mode: HydrogenSettlementRange | 'custom' };
summary: {
amount: number;
paymentCount: number;
stationDayCount: number;
stationCount: number;
latestPaymentDate: string | null;
};
rows: HydrogenSettlementRow[];
}
export interface HydrogenSettlementQuery {
range?: HydrogenSettlementRange | 'custom';
startDate?: string;
endDate?: string;
}
export function buildHydrogenSettlementQuery(query: HydrogenSettlementQuery = {}) {
const params = new URLSearchParams();
if (query.range) params.set('range', query.range);
if (query.startDate) params.set('startDate', query.startDate);
if (query.endDate) params.set('endDate', query.endDate);
return params.toString();
}
export function fetchHydrogenSettlement(query: HydrogenSettlementQuery = {}): Promise<HydrogenSettlementResponse> {
const q = buildHydrogenSettlementQuery(query);
return fetchJson<HydrogenSettlementResponse>(`${BASE}/hydrogen/settlement${q ? `?${q}` : ''}`);
}
+2 -192
View File
@@ -1,127 +1,8 @@
/** 能源模块(电能 / ETC / 单站日报)共用的 DTO 与筛选枚举。 */
export type CustomerType = 'all' | 'external' | 'lingniu'; export type CustomerType = 'all' | 'external' | 'lingniu';
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15'; export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
export interface HydrogenKpi {
yearKg: number;
yearFee: number;
yearRevenue: number;
// 客户单毛利:对客收入减去客户承担订单的氢气成本,不代表全量经营利润。
yearProfit: number;
ourYearKg: number;
ourYearFee: number;
customerYearKg: number;
monthKg: number;
monthFee: number;
monthRevenue: number;
monthProfit: number;
todayKg: number;
todayFee: number;
todayRevenue: number;
todayProfit: number;
lingniuBornKg: number;
lingniuBornFee: number;
}
export interface HydrogenStationTop {
id: number;
rank: number;
name: string;
kg: number;
fee: number;
share: number;
}
export interface HydrogenRegionShare {
region: string;
kg: number;
share: number;
}
export interface HydrogenMonthlyPoint {
month: string; // YYYY-MM
kg: number;
lingniuKg?: number;
externalKg?: number;
fee: number;
revenue: number;
profit: number;
}
export interface HydrogenCustomerRow {
name: string;
payer: 'lingniu' | 'customer' | 'mixed';
kg: number;
cost: number;
revenue: number;
}
export interface HydrogenStationFull {
id: number;
name: string;
province?: string;
kg: number;
revenue: number;
share: number; // 加氢量占比
revenueShare: number;// 收入占比
}
export interface HydrogenStationRow {
id: number;
name: string;
pricePerKg: number;
kg: number;
fee: number;
chainPct: number;
}
export interface HydrogenDailyRow {
date: string;
totalKg: number;
totalFee: number;
chainPct: number;
customerType: CustomerType;
stations: HydrogenStationRow[];
}
export interface HydrogenDailyDetailVehicle {
id: string;
time: string;
plateNo: string;
vehicleScope: 'lingniu' | 'external';
source: string;
verifyStatus: string;
unitPrice: number;
kg: number;
fee: number;
}
export interface HydrogenDailyDetailCustomer {
id: number;
name: string;
kg: number;
fee: number;
vehicles: HydrogenDailyDetailVehicle[];
}
export interface HydrogenDailyDetailStation {
id: number;
name: string;
stationType: string;
kg: number;
fee: number;
// Null means the source system has no persisted station-balance record for
// this station on or before the selected date; it must not be treated as 0.
balance: number | null;
balanceEffectiveTime: string | null;
customers: HydrogenDailyDetailCustomer[];
}
export interface HydrogenDailyDetailResponse {
date: string;
recordCount: number;
stations: HydrogenDailyDetailStation[];
}
export interface HydrogenStationBoardStation { export interface HydrogenStationBoardStation {
id: number; id: number;
name: string; name: string;
@@ -195,77 +76,6 @@ export interface HydrogenStationBoardResponse {
} | null; } | null;
} }
export interface HydrogenOverviewDetailRecord {
id: number;
orderNo: string;
refuelTime: string;
stationId: number;
stationName: string;
province: string;
city: string;
customerId: number;
customerName: string;
plateNo: string;
vehicleScope: 'lingniu' | 'external';
source: string;
verifyStatus: string;
kg: number;
costPrice: number;
cost: number;
customerPrice: number;
revenue: number;
paymentStatus: number | null;
}
export type HydrogenOverviewDetailGroupBy = 'station' | 'customer' | 'vehicle';
export interface HydrogenOverviewDetailGroup {
id: number | string;
name: string;
province: string | null;
city: string | null;
recordCount: number;
stationCount: number;
customerCount: number;
kg: number;
cost: number;
customerCost: number;
revenue: number;
vehicleScope?: 'lingniu' | 'external';
source?: string;
verifyStatus?: string;
}
export interface HydrogenOverviewDetailResponse {
filter: {
year: number;
vehicleScope: 'all' | 'lingniu' | 'external';
verifyScope: 'all' | 'verified';
stationId: number | null;
month: string | null;
date: string | null;
customerName: string | null;
customerId: number | null;
plateNo: string | null;
region: string | null;
regionGranularity: 'province' | 'city';
groupBy: HydrogenOverviewDetailGroupBy | null;
};
truncated: boolean;
summary: {
recordCount: number;
stationCount: number;
customerCount: number;
kg: number;
cost: number;
customerCost: number;
revenue: number;
};
groupBy: HydrogenOverviewDetailGroupBy | null;
groups: HydrogenOverviewDetailGroup[];
records: HydrogenOverviewDetailRecord[];
}
export interface ElectricKpi { export interface ElectricKpi {
totalKwh: number; totalKwh: number;
totalFee: number; totalFee: number;
@@ -1,153 +0,0 @@
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_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js';
import { customerClause, type CustomerKind } from './query-model.js';
export interface HydrogenDailyDetailDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
const YMD = /^\d{4}-\d{2}-\d{2}$/;
export function registerHydrogenDailyDetailRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenDailyDetailDependencies,
) {
app.get('/hydrogen/daily-detail', async (c) => {
const date = c.req.query('date') || '';
if (!YMD.test(date)) return c.json({ error: 'date must be YYYY-MM-DD' }, 400);
const customer = (c.req.query('customer') || 'all') as CustomerKind;
const verifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
const force = c.req.query('force') === '1';
const stationValue = Number(c.req.query('stationId'));
const stationId = Number.isInteger(stationValue) && stationValue > 0 ? stationValue : null;
const stationClause = stationId ? ' AND b.station_id = ?' : '';
const params: unknown[] = [date, ...(stationId ? [stationId] : [])];
const data = await cached(
`hydrogen/daily-detail?date=${date}&customer=${customer}${stationId ? `&station=${stationId}` : ''}${verifyScope === 'verified' ? '&verify=verified' : ''}`,
async () => {
const [rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.id,
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%H:%i') AS time,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS stationName,
COALESCE(s.station_type, 'unknown') AS stationType,
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,
b.vehicle_id AS vehicleId,
COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus,
ROUND(COALESCE(b.cost_price, 0), 2) AS unitPrice,
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg,
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_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
LIMIT 3000`,
params,
);
type Vehicle = {
id: string;
time: string;
plateNo: string;
vehicleScope: 'lingniu' | 'external';
source: string;
verifyStatus: string;
unitPrice: number;
kg: number;
fee: number;
};
type Customer = { id: number; name: string; kg: number; fee: number; vehicles: Vehicle[] };
type Station = { id: number; name: string; stationType: string; kg: number; fee: number; customers: Map<number, Customer> };
const stations = new Map<number, Station>();
for (const row of rows) {
const currentStationId = Number(row.stationId) || 0;
const currentCustomerId = Number(row.customerId) || 0;
let station = stations.get(currentStationId);
if (!station) {
station = { id: currentStationId, name: String(row.stationName), stationType: String(row.stationType), kg: 0, fee: 0, customers: new Map() };
stations.set(currentStationId, station);
}
let customerRow = station.customers.get(currentCustomerId);
if (!customerRow) {
customerRow = { id: currentCustomerId, name: String(row.customerName), kg: 0, fee: 0, vehicles: [] };
station.customers.set(currentCustomerId, customerRow);
}
const kg = Number(row.kg) || 0;
const fee = Number(row.fee) || 0;
station.kg += kg;
station.fee += fee;
customerRow.kg += kg;
customerRow.fee += fee;
customerRow.vehicles.push({
// Ledger ids can exceed Number.MAX_SAFE_INTEGER. Keep the database
// identifier lossless so the final vehicle/source drill-down has
// stable React keys.
id: String(row.id),
time: String(row.time),
plateNo: String(row.plateNo),
vehicleScope: row.vehicleId === null || row.vehicleId === undefined ? 'external' : 'lingniu',
source: String(row.source),
verifyStatus: String(row.verifyStatus),
unitPrice: Number(row.unitPrice) || 0,
kg,
fee,
});
}
// `new_hydrogen_site_balance_record` is the only persisted station-balance
// source. It is intentionally queried separately so a missing balance record
// stays null instead of becoming a fabricated zero balance.
const stationIds = [...stations.keys()].filter(id => id > 0);
const balances = new Map<number, { amount: number; effectiveTime: string | null }>();
if (stationIds.length > 0) {
const placeholders = stationIds.map(() => '?').join(', ');
const [balanceRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT r.site_id AS stationId,
r.ending_balance AS amount,
DATE_FORMAT(r.effective_time, '%Y-%m-%d %H:%i:%s') AS effectiveTime
FROM new_hydrogen_site_balance_record r
INNER JOIN (
SELECT site_id, MAX(effective_time) AS maxEffectiveTime
FROM new_hydrogen_site_balance_record
WHERE del_flag = '0' AND effective_time < DATE_ADD(?, INTERVAL 1 DAY)
AND site_id IN (${placeholders})
GROUP BY site_id
) latest ON latest.site_id = r.site_id AND latest.maxEffectiveTime = r.effective_time
WHERE r.del_flag = '0'`,
[date, ...stationIds],
);
for (const row of balanceRows) {
balances.set(Number(row.stationId), {
amount: Number(row.amount),
effectiveTime: row.effectiveTime === null || row.effectiveTime === undefined ? null : String(row.effectiveTime),
});
}
}
return {
date,
recordCount: rows.length,
stations: [...stations.values()].map(station => ({
...station,
balance: balances.get(station.id)?.amount ?? null,
balanceEffectiveTime: balances.get(station.id)?.effectiveTime ?? null,
customers: [...station.customers.values()],
})),
};
},
{ force },
);
return c.json(data);
});
}
-162
View File
@@ -1,162 +0,0 @@
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_FUEL_ONLY_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
} from './constants.js';
import {
customerClause,
dateRangeClause,
enumerateDateRange,
resolveDateRange,
type CustomerKind,
type EnergyDateRangeKind as Range,
} from './query-model.js';
export interface HydrogenDailyDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
// 氢能每日:日期范围 + 客户类型 + 站点级下钻。
export function registerHydrogenDailyRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenDailyDependencies,
) {
app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const customer = (c.req.query('customer') || 'external') as CustomerKind;
const verifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}${verifyScope === 'verified' ? '&verify=verified' : ''}`, async () => {
const where = [
HYDROGEN_FUEL_ONLY_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id'),
...(verifyScope === 'verified'
? ["LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'"]
: []),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
// 单价不重算:直接取账本成本价;费用直接累计账本成本总额。
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
ROUND(SUM(b.amount_kg), 2) AS kg,
ROUND(SUM(b.cost_total), 2) AS fee,
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0)
ORDER BY d DESC, kg DESC`,
[dateRange.start, dateRange.end],
);
// 站点环比:同站点上一条记录的 kg
// 按 stationId 分组、按日期升序计算
type StationRow = {
date: string;
stationId: number;
name: string;
kg: number;
fee: number;
pricePerKg: number;
};
const flat: StationRow[] = stationRows.map(r => ({
date: r.d as string,
stationId: Number(r.stationId),
name: r.stationName as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
pricePerKg: Number(r.pricePerKg) || 0,
}));
// 计算日级总量 + 日级环比
const dayMap = new Map<string, { totalKg: number; totalFee: number; stations: typeof flat }>();
for (const s of flat) {
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, totalFee: 0, stations: [] });
const e = dayMap.get(s.date)!;
e.totalKg += s.kg;
e.totalFee += s.fee;
e.stations.push(s);
}
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
const dayChainPct = new Map<string, number>();
let prev = 0;
for (const d of dates) {
const cur = dayMap.get(d)!.totalKg;
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
prev = cur;
}
// 站点级环比:按 stationId 分组按日期升序
const stationPrev = new Map<number, number>();
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
// 需要按 stationId 分组排序
const byStation = new Map<number, StationRow[]>();
for (const s of flat) {
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
byStation.get(s.stationId)!.push(s);
}
for (const [, list] of byStation) {
list.sort((a, b) => a.date.localeCompare(b.date));
let p = 0;
for (const r of list) {
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
p = r.kg;
}
}
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => {
const info = dayMap.get(date);
return {
date,
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
totalFee: info ? Math.round(info.totalFee * 100) / 100 : 0,
chainPct: dayChainPct.get(date) ?? 0,
customerType: customer,
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
id: s.stationId,
name: s.name,
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
kg: Math.round(s.kg * 100) / 100,
fee: Math.round(s.fee * 100) / 100,
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
}))
: [],
};
});
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
let prevKg = 0;
for (const d of ascDays) {
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
prevKg = d.totalKg;
}
// 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result;
}, { force });
return c.json(data);
});
}
@@ -1,271 +0,0 @@
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_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE } from './constants.js';
export interface HydrogenOverviewDetailDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
type VehicleScope = 'all' | 'lingniu' | 'external';
type VerifyScope = 'all' | 'verified';
type GroupBy = 'station' | 'customer' | 'vehicle';
export function registerHydrogenOverviewDetailRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenOverviewDetailDependencies,
) {
app.get('/hydrogen/overview-detail', async (c) => {
const requestedYear = Number(c.req.query('year'));
const year = Number.isInteger(requestedYear) && requestedYear >= 2020 && requestedYear <= 2100
? requestedYear
: new Date().getFullYear();
const scopeParam = c.req.query('vehicleScope');
const vehicleScope: VehicleScope = scopeParam === 'lingniu' || scopeParam === 'external' ? scopeParam : 'all';
const verifyScope: VerifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
const stationValue = Number(c.req.query('stationId'));
const stationId = Number.isInteger(stationValue) && stationValue > 0 ? stationValue : null;
const month = /^\d{4}-\d{2}$/.test(c.req.query('month') || '') ? c.req.query('month')! : null;
const date = /^\d{4}-\d{2}-\d{2}$/.test(c.req.query('date') || '') ? c.req.query('date')! : null;
const customerName = (c.req.query('customerName') || '').trim().slice(0, 128) || null;
const customerValue = Number(c.req.query('customerId'));
const customerId = Number.isInteger(customerValue) && customerValue >= 0 ? customerValue : null;
const plateNo = (c.req.query('plateNo') || '').trim().slice(0, 32) || null;
const region = (c.req.query('region') || '').trim().slice(0, 64) || null;
const regionGranularity = c.req.query('regionGranularity') === 'city' ? 'city' : 'province';
const groupByParam = c.req.query('groupBy');
const groupBy: GroupBy | null = groupByParam === 'station' || groupByParam === 'customer' || groupByParam === 'vehicle' ? groupByParam : null;
const requestedLimit = Number(c.req.query('limit'));
// 普通下钻避免一次返回过多账本;导出明确申请时,仍限制在可控范围。
const recordLimit = Number.isInteger(requestedLimit) && requestedLimit > 0
? Math.min(requestedLimit, 20_000)
: 2_000;
const force = c.req.query('force') === '1';
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');
if (verifyScope === 'verified') {
clauses.push("LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'");
}
if (stationId) { clauses.push('b.station_id = ?'); params.push(stationId); }
if (month) { clauses.push(`DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ?`); params.push(month); }
if (date) { clauses.push(`DATE(b.${HYDROGEN_LOCAL}) = ?`); params.push(date); }
if (customerName) {
clauses.push(`COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') = ?`);
params.push(customerName);
}
if (customerId !== null) {
clauses.push('COALESCE(b.system_customer_id, b.customer_id, 0) = ?');
params.push(customerId);
}
if (plateNo) {
clauses.push(`COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?`);
params.push(plateNo);
}
if (region) {
clauses.push(regionGranularity === 'city'
? `COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') = ?`
: `COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') = ?`);
params.push(region);
}
const cacheKey = [
`hydrogen/overview-detail?year=${year}`,
`vehicle=${vehicleScope}`,
verifyScope !== 'all' ? `verify=${verifyScope}` : '',
stationId ? `station=${stationId}` : '',
month ? `month=${month}` : '',
date ? `date=${date}` : '',
customerName ? `customer=${customerName}` : '',
customerId !== null ? `customerId=${customerId}` : '',
plateNo ? `plate=${plateNo}` : '',
region ? `${regionGranularity}=${region}` : '',
groupBy ? `groupBy=${groupBy}` : '',
].filter(Boolean).join('&');
const data = await cached(cacheKey, async () => {
const [summaryRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT CONCAT(
COALESCE(b.system_customer_id, b.customer_id, 0), '|',
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
)) 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(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
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 ${clauses.join(' AND ')}`,
params,
);
// 总览第一层先返回业务维度汇总;只有用户点选某一行时,才读取账本流水。
const groupSelect = groupBy === 'station'
? `COALESCE(b.station_id, 0) AS id,
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS name,
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
COUNT(*) AS recordCount,
1 AS stationCount,
COUNT(DISTINCT CONCAT(COALESCE(b.system_customer_id, b.customer_id, 0), '|', COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户'))) 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(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue`
: groupBy === 'customer'
? `COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
NULL AS province,
NULL AS city,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
1 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(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue`
: `COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id,
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name,
NULL AS province,
NULL AS city,
COUNT(*) AS recordCount,
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
COUNT(DISTINCT CONCAT(COALESCE(b.system_customer_id, b.customer_id, 0), '|', COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户'))) 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(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
CASE WHEN MAX(CASE WHEN b.vehicle_id IS NULL THEN 0 ELSE 1 END) = 1 THEN 'lingniu' ELSE 'external' END AS vehicleScope,
COALESCE(NULLIF(MAX(b.record_source), ''), CAST(MAX(b.source) AS CHAR), '未知来源') AS source,
COALESCE(NULLIF(MAX(b.verify_status), ''), 'UNVERIFIED') AS verifyStatus`;
const groupBySql = groupBy === 'station'
? `COALESCE(b.station_id, 0), COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点'), COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属'), COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属')`
: groupBy === 'customer'
? `COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')`
: `COALESCE(NULLIF(b.license_plate, ''), '无车牌')`;
const [groupRows] = groupBy
? await hydrogenPool.query<RowDataPacket[]>(
`SELECT ${groupSelect}
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
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 ${clauses.join(' AND ')}
GROUP BY ${groupBySql}
ORDER BY kg DESC, recordCount DESC
LIMIT 200`,
params,
)
: [[] as RowDataPacket[]];
const [rows] = groupBy ? [[] as RowDataPacket[]] : await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.id,
b.order_no AS orderNo,
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS refuelTime,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS stationName,
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
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,
b.vehicle_id AS vehicleId,
COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus,
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg,
ROUND(COALESCE(b.cost_price, 0), 2) AS costPrice,
ROUND(COALESCE(b.cost_total, 0), 2) AS cost,
ROUND(COALESCE(b.customer_price, 0), 2) AS customerPrice,
ROUND(COALESCE(b.fee_total, 0), 2) AS revenue,
b.payment_status AS paymentStatus
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
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 ${clauses.join(' AND ')}
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC
LIMIT ${recordLimit}`,
params,
);
const groups = groupRows.map(row => ({
id: groupBy === 'vehicle' ? String(row.id || '无车牌') : Number(row.id) || 0,
name: String(row.name || (groupBy === 'station' ? '未关联站点' : groupBy === 'customer' ? '未关联客户' : '无车牌')),
province: row.province === null || row.province === undefined ? null : String(row.province),
city: row.city === null || row.city === undefined ? null : String(row.city),
recordCount: Number(row.recordCount) || 0,
stationCount: Number(row.stationCount) || 0,
customerCount: Number(row.customerCount) || 0,
kg: Number(row.kg) || 0,
cost: Number(row.cost) || 0,
customerCost: Number(row.customerCost) || 0,
revenue: Number(row.revenue) || 0,
vehicleScope: row.vehicleScope === 'lingniu' ? 'lingniu' : row.vehicleScope === 'external' ? 'external' : undefined,
source: row.source === null || row.source === undefined ? undefined : String(row.source),
verifyStatus: row.verifyStatus === null || row.verifyStatus === undefined ? undefined : String(row.verifyStatus),
}));
const records = rows.map(row => ({
id: Number(row.id),
orderNo: String(row.orderNo || ''),
refuelTime: String(row.refuelTime),
stationId: Number(row.stationId) || 0,
stationName: String(row.stationName),
province: String(row.province),
city: String(row.city),
customerId: Number(row.customerId) || 0,
customerName: String(row.customerName),
plateNo: String(row.plateNo),
vehicleScope: row.vehicleId === null || row.vehicleId === undefined ? 'external' as const : 'lingniu' as const,
source: String(row.source),
verifyStatus: String(row.verifyStatus),
kg: Number(row.kg) || 0,
costPrice: Number(row.costPrice) || 0,
cost: Number(row.cost) || 0,
customerPrice: Number(row.customerPrice) || 0,
revenue: Number(row.revenue) || 0,
paymentStatus: row.paymentStatus === null || row.paymentStatus === undefined ? null : Number(row.paymentStatus),
}));
const summaryRow = summaryRows[0] || {};
const recordCount = Number(summaryRow.recordCount) || 0;
return {
filter: { year, vehicleScope, verifyScope, stationId, month, date, customerName, customerId, plateNo, region, regionGranularity, groupBy },
groupBy,
groups,
truncated: !groupBy && recordCount > rows.length,
summary: {
recordCount,
stationCount: Number(summaryRow.stationCount) || 0,
customerCount: Number(summaryRow.customerCount) || 0,
kg: Number(summaryRow.kg) || 0,
cost: Number(summaryRow.cost) || 0,
customerCost: Number(summaryRow.customerCost) || 0,
revenue: Number(summaryRow.revenue) || 0,
},
records,
};
}, { force });
return c.json(data);
});
}
@@ -1,374 +0,0 @@
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_FUEL_ONLY_WHERE,
HYDROGEN_FUEL_ONLY_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
} from './constants.js';
export interface HydrogenOverviewDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
type VehicleScope = 'all' | 'lingniu' | 'external';
type VerifyScope = 'all' | 'verified';
interface OverviewFilter {
cacheKey: string;
ledgerSql: string;
billSql: string;
params: number[];
stationId: number | null;
vehicleScope: VehicleScope;
verifyScope: VerifyScope;
}
function overviewFilter(
stationIdParam: string | undefined,
vehicleScopeParam: string | undefined,
verifyScopeParam: string | undefined,
): OverviewFilter {
const stationId = Number(stationIdParam);
const safeStationId = Number.isInteger(stationId) && stationId > 0 ? stationId : null;
const vehicleScope: VehicleScope = vehicleScopeParam === 'lingniu' || vehicleScopeParam === 'external'
? vehicleScopeParam
: 'all';
const verifyScope: VerifyScope = verifyScopeParam === 'verified' ? 'verified' : 'all';
const clauses: string[] = [];
const params: number[] = [];
if (safeStationId) {
clauses.push('station_id = ?');
params.push(safeStationId);
}
if (vehicleScope === 'lingniu') clauses.push('vehicle_id IS NOT NULL');
if (vehicleScope === 'external') clauses.push('vehicle_id IS NULL');
// Only use the ledger's persisted verification state; never infer verification
// from import source or payment status.
if (verifyScope === 'verified') {
clauses.push("LOWER(COALESCE(NULLIF(TRIM(verify_status), ''), 'unverified')) = 'verified'");
}
const suffix = clauses.length ? ` AND ${clauses.join(' AND ')}` : '';
const billSuffix = suffix
.replaceAll('station_id', 'b.station_id')
.replaceAll('vehicle_id', 'b.vehicle_id')
.replaceAll('verify_status', 'b.verify_status');
const cacheParts = [
safeStationId ? `station=${safeStationId}` : '',
vehicleScope !== 'all' ? `vehicle=${vehicleScope}` : '',
verifyScope !== 'all' ? `verify=${verifyScope}` : '',
].filter(Boolean);
return {
cacheKey: cacheParts.length ? `&${cacheParts.join('&')}` : '',
ledgerSql: suffix,
billSql: billSuffix,
params,
stationId: safeStationId,
vehicleScope,
verifyScope,
};
}
// 氢能总览:KPI + 趋势 + 站点/区域/客户汇总。筛选只使用账本中可验证的站点和车辆归属字段。
export function registerHydrogenOverviewRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenOverviewDependencies,
) {
app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const filter = overviewFilter(c.req.query('stationId'), c.req.query('vehicleScope'), c.req.query('verifyScope'));
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}${filter.cacheKey}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT YEAR(${HYDROGEN_LOCAL}) AS y,
DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?
GROUP BY YEAR(${HYDROGEN_LOCAL})
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
// 数据源时间来自账本,不等同于前端请求完成时间。
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
// The watermark is calculated with the active scope below. The year list
// remains global metadata so users can still select a year with no matching
// verified/vehicle/station records.
let latestLedgerTime: string | null = null;
const isCurrentYear = year === todayYear;
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)。
// yearProfit 是客户单毛利:仅以存在对客价/对客收入的订单成本抵扣对客收入。
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(MAX(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? THEN ${HYDROGEN_LOCAL} END), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_total ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN fee_total ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN amount_kg ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN cost_total ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN amount_kg ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN amount_kg ELSE 0 END) AS monthKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_total ELSE 0 END) AS monthFee,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee_total ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN amount_kg ELSE 0 END) AS todayKg,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_total ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN fee_total ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
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,
HYDROGEN_MIN_DATE, ...filter.params],
);
const k = kpiRows[0] ?? {};
latestLedgerTime = typeof k.latestLedgerTime === 'string' ? k.latestLedgerTime : null;
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
const monthFee = Number(k.monthFee) || 0;
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
const monthRevenue = Number(k.monthRevenue) || 0;
const todayFee = Number(k.todayFee) || 0;
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
const todayRevenue = Number(k.todayRevenue) || 0;
const kpi = {
yearKg: Number(k.yearKg) || 0,
yearFee,
yearRevenue,
yearProfit: yearRevenue - yearCustomerCost,
ourYearKg: Number(k.ourYearKg) || 0,
ourYearFee: Number(k.ourYearFee) || 0,
customerYearKg: Number(k.customerYearKg) || 0,
monthKg: Number(k.monthKg) || 0,
monthFee,
monthRevenue,
monthProfit: monthRevenue - monthCustomerCost,
todayKg: Number(k.todayKg) || 0,
todayFee,
todayRevenue,
todayProfit: todayRevenue - todayCustomerCost,
lingniuBornKg: Number(k.lingniuBornKg) || 0,
lingniuBornFee: Number(k.lingniuBornFee) || 0,
};
// Top5 加氢站(指定年份)
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
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_FUEL_ONLY_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
rank: i + 1,
id: Number(r.id) || 0,
name: r.name as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
share: (Number(r.kg) || 0) / top5KgSum,
}));
// 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
COALESCE(MAX(d.NAME), '未归属') AS province,
SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
LEFT JOIN common_district d
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_FUEL_ONLY_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
GROUP BY b.station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
id: Number(r.id) || 0,
name: r.name as string,
province: r.province as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
}));
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN NULLIF(TRIM(d.NAME), '') IS NOT NULL THEN d.NAME
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE '未归属区域'
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
-- 历史站点城市编码与行政区字典的排序规则不同,显式统一避免线上连接报错。
LEFT JOIN common_district d
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_FUEL_ONLY_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
const top = regionRows.slice(0, TOP_REGIONS);
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
const regions = [
...top.map(r => ({
region: r.region as string,
kg: Number(r.kg) || 0,
share: (Number(r.kg) || 0) / totalKg,
})),
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
];
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(CASE WHEN vehicle_id IS NOT NULL THEN amount_kg ELSE 0 END), 2) AS lingniuKg,
ROUND(SUM(CASE WHEN vehicle_id IS NULL THEN amount_kg ELSE 0 END), 2) AS externalKg,
ROUND(SUM(cost_total), 2) AS fee,
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_FUEL_ONLY_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const monthMap = new Map<string, { kg: number; lingniuKg: number; externalKg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
lingniuKg: Number(r.lingniuKg) || 0,
externalKg: Number(r.externalKg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; lingniuKg: number; externalKg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, lingniuKg: 0, externalKg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, lingniuKg: v.lingniuKg, externalKg: v.externalKg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top(指定年份;按加氢量降序,前 30)。
// 同一客户可能同时存在客户承担与羚牛承担订单,必须显示为混合,不能按任一条记录覆盖。
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE
WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0 THEN 1 ELSE 0 END) > 0 THEN 'mixed'
WHEN SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN 1 ELSE 0 END) > 0 THEN 'customer'
ELSE 'lingniu'
END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_FUEL_ONLY_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year, ...filter.params],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu'
? 'lingniu' as const
: (r.payer as string) === 'mixed'
? 'mixed' as const
: 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year, latestLedgerTime, filter: {
stationId: filter.stationId,
vehicleScope: filter.vehicleScope,
verifyScope: filter.verifyScope,
} };
}, { force });
return c.json(data);
});
}
@@ -1,119 +0,0 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import { resolveDateRange, type EnergyDateRangeKind } from './query-model.js';
type SettlementRange = EnergyDateRangeKind | 'latest' | 'custom';
export interface HydrogenSettlementDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
function toDateString(value: unknown): string | null {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
return null;
}
function minusDays(date: string, days: number): string {
// 用 UTC 正午作为日历运算锚点,避免东八区转 ISO 日期时落到前一天。
const result = new Date(`${date}T12:00:00Z`);
result.setUTCDate(result.getUTCDate() - days);
return result.toISOString().slice(0, 10);
}
function normalizeMatchMode(value: unknown): 'exact' | 'manual' | 'group' | 'unmatched' {
if (value === 'exact' || value === 'manual' || value === 'group') return value;
return 'unmatched';
}
// 站日现结只读台账:数据来自已入库的付款流水,不承担登记或审批职责。
export function registerHydrogenSettlementRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenSettlementDependencies,
) {
app.get('/hydrogen/settlement', async (c) => {
const requestedRange = (c.req.query('range') || 'latest') as SettlementRange;
const force = c.req.query('force') === '1';
const [latestRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
FROM hydrogen_station_payment
WHERE del_flag = '0'`,
);
const latestPaymentDate = toDateString(latestRows[0]?.latestPaymentDate);
const dateRange = requestedRange === 'latest'
? latestPaymentDate
? { start: minusDays(latestPaymentDate, 14), end: latestPaymentDate, mode: 'latest' as const }
: { start: null, end: null, mode: 'latest' as const }
: (() => {
const resolved = resolveDateRange(
requestedRange as EnergyDateRangeKind,
c.req.query('startDate'),
c.req.query('endDate'),
);
return { ...resolved, mode: requestedRange === 'custom' ? 'custom' as const : requestedRange };
})();
if (!dateRange.start || !dateRange.end) {
return c.json({
range: dateRange,
summary: { amount: 0, paymentCount: 0, stationDayCount: 0, stationCount: 0, latestPaymentDate },
rows: [],
});
}
const data = await cached(
`hydrogen/settlement?start=${dateRange.start}&end=${dateRange.end}`,
async () => {
const [rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(p.payment_date, '%Y-%m-%d') AS date,
p.station_id AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(p.raw_station_name), '未匹配加氢站') AS stationName,
ROUND(SUM(p.amount), 2) AS amount,
COUNT(*) AS paymentCount,
CASE
WHEN SUM(p.match_mode = 'exact') = COUNT(*) THEN 'exact'
WHEN SUM(p.match_mode = 'manual') = COUNT(*) THEN 'manual'
WHEN SUM(p.match_mode = 'group') = COUNT(*) THEN 'group'
ELSE 'unmatched'
END AS matchMode
FROM hydrogen_station_payment p
LEFT JOIN hydrogen_station s ON s.id = p.station_id AND s.del_flag = '0'
WHERE p.del_flag = '0'
AND p.payment_date >= ?
AND p.payment_date <= ?
GROUP BY DATE_FORMAT(p.payment_date, '%Y-%m-%d'), p.station_id, p.raw_station_name
ORDER BY date DESC, amount DESC
LIMIT 500`,
[dateRange.start, dateRange.end],
);
const normalizedRows = rows.map(row => ({
date: String(row.date),
stationId: row.stationId === null || row.stationId === undefined ? null : Number(row.stationId),
stationName: String(row.stationName),
amount: Number(row.amount) || 0,
paymentCount: Number(row.paymentCount) || 0,
matchMode: normalizeMatchMode(row.matchMode),
}));
const stationIds = new Set(normalizedRows.map(row => row.stationId ?? `name:${row.stationName}`));
return {
range: dateRange,
summary: {
amount: normalizedRows.reduce((total, row) => total + row.amount, 0),
paymentCount: normalizedRows.reduce((total, row) => total + row.paymentCount, 0),
stationDayCount: normalizedRows.length,
stationCount: stationIds.size,
latestPaymentDate,
},
rows: normalizedRows,
};
},
{ force },
);
return c.json(data);
});
}
+2 -10
View File
@@ -9,11 +9,6 @@ import {
registerElectricOverviewRoute, registerElectricOverviewRoute,
} from './electric.js'; } from './electric.js';
import { registerEtcOverviewRoute } from './etc.js'; import { registerEtcOverviewRoute } from './etc.js';
import { registerHydrogenDailyRoute } from './hydrogen-daily.js';
import { registerHydrogenDailyDetailRoute } from './hydrogen-daily-detail.js';
import { registerHydrogenOverviewRoute } from './hydrogen-overview.js';
import { registerHydrogenOverviewDetailRoute } from './hydrogen-overview-detail.js';
import { registerHydrogenSettlementRoute } from './hydrogen-settlement.js';
import { registerHydrogenStationBoardRoute } from './hydrogen-station-board.js'; import { registerHydrogenStationBoardRoute } from './hydrogen-station-board.js';
import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js'; import { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js';
@@ -31,11 +26,8 @@ app.use('*', async (c, next) => {
const cacheDependency = { cached }; const cacheDependency = { cached };
registerHydrogenOverviewRoute(app, { ...cacheDependency, hydrogenPool }); // 氢能经营看板:/hydrogen/station-board 仍被单站日报页面使用;/h2/v2/* 为当前看板所用。
registerHydrogenOverviewDetailRoute(app, { ...cacheDependency, hydrogenPool }); // 注册顺序沿用历史顺序(station-board 在 h2/v2 之前),以免影响路由匹配语义。
registerHydrogenDailyRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenDailyDetailRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenSettlementRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenStationBoardRoute(app, { ...cacheDependency, hydrogenPool }); registerHydrogenStationBoardRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenBiV2Routes(app, { hydrogenPool }); registerHydrogenBiV2Routes(app, { hydrogenPool });
registerElectricOverviewRoute(app, { ...cacheDependency, pool }); registerElectricOverviewRoute(app, { ...cacheDependency, pool });
+3 -290
View File
@@ -12,18 +12,6 @@ import {
registerEtcOverviewRoute, registerEtcOverviewRoute,
type EtcOverviewDependencies, type EtcOverviewDependencies,
} from "./etc.js"; } from "./etc.js";
import {
registerHydrogenDailyRoute,
type HydrogenDailyDependencies,
} from "./hydrogen-daily.js";
import {
registerHydrogenOverviewRoute,
type HydrogenOverviewDependencies,
} from "./hydrogen-overview.js";
import {
registerHydrogenSettlementRoute,
type HydrogenSettlementDependencies,
} from "./hydrogen-settlement.js";
import { import {
registerHydrogenStationBoardRoute, registerHydrogenStationBoardRoute,
type HydrogenStationBoardDependencies, type HydrogenStationBoardDependencies,
@@ -90,11 +78,6 @@ test("能源路由按既有顺序注册并增加只读单站看板接口", () =>
.filter((route) => route.method === "GET") .filter((route) => route.method === "GET")
.map((route) => route.path), .map((route) => route.path),
[ [
"/hydrogen/overview",
"/hydrogen/overview-detail",
"/hydrogen/daily",
"/hydrogen/daily-detail",
"/hydrogen/settlement",
"/hydrogen/station-board", "/hydrogen/station-board",
"/h2/v2/meta", "/h2/v2/meta",
"/h2/v2/overview", "/h2/v2/overview",
@@ -704,117 +687,10 @@ test("单站外部客户按唯一标准化车牌聚合,进账保持租户客
assert.match(calls[9].sql, /refuel_time < DATE_ADD\(\?, INTERVAL 1 DAY\)/); assert.match(calls[9].sql, /refuel_time < DATE_ADD\(\?, INTERVAL 1 DAY\)/);
}); });
test("站日现结台账仅读取付款流水,并按最近有数据的 15 天聚合", async () => { test("电能处理器继续使用原缓存语义、数据库、SQL 与参数", async () => {
const calls: QueryCall[] = [];
const cacheCalls: CacheCall[] = [];
const app = new Hono();
registerHydrogenSettlementRoute(app, {
hydrogenPool: {
query: createQueryMock(
[
[{ latestPaymentDate: "2026-05-26" }],
[
{
date: "2026-05-26",
stationId: 44,
stationName: "联新加氢站",
amount: 30000,
paymentCount: 1,
matchMode: "exact",
},
{
date: "2026-05-25",
stationId: null,
stationName: "佛山中石化",
amount: 20000,
paymentCount: 2,
matchMode: "group",
},
],
],
calls,
),
},
cached: createCachedMock(cacheCalls),
} as unknown as HydrogenSettlementDependencies);
const response = await app.request(
"/hydrogen/settlement?range=latest&force=1",
);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
range: { start: "2026-05-12", end: "2026-05-26", mode: "latest" },
summary: {
amount: 50000,
paymentCount: 3,
stationDayCount: 2,
stationCount: 2,
latestPaymentDate: "2026-05-26",
},
rows: [
{
date: "2026-05-26",
stationId: 44,
stationName: "联新加氢站",
amount: 30000,
paymentCount: 1,
matchMode: "exact",
},
{
date: "2026-05-25",
stationId: null,
stationName: "佛山中石化",
amount: 20000,
paymentCount: 2,
matchMode: "group",
},
],
});
assert.deepEqual(cacheCalls, [
{ key: "hydrogen/settlement?start=2026-05-12&end=2026-05-26", force: true },
]);
assert.equal(calls.length, 2);
assert.match(calls[0].sql, /MAX\(payment_date\)/);
assert.match(calls[1].sql, /FROM hydrogen_station_payment p/);
assert.match(calls[1].sql, /LIMIT 500/);
assert.deepEqual(calls[1].params, ["2026-05-12", "2026-05-26"]);
});
test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", async () => {
const hydrogenCalls: QueryCall[] = [];
const electricCalls: QueryCall[] = []; const electricCalls: QueryCall[] = [];
const cacheCalls: CacheCall[] = []; const cacheCalls: CacheCall[] = [];
const cached = createCachedMock(cacheCalls); const cached = createCachedMock(cacheCalls);
const hydrogenQuery = createQueryMock(
[
[{ y: 2025, latestLedgerTime: "2025-01-02 10:30:00" }],
[{}],
[],
[],
[],
[],
[
{
name: "混合承担客户",
payer: "mixed",
kg: 10,
cost: 350,
revenue: 360,
},
],
[
{
d: "2025-01-02",
stationId: 7,
stationName: "测试站",
kg: 10,
fee: 350,
pricePerKg: 35,
},
],
],
hydrogenCalls,
);
const electricQuery = createQueryMock( const electricQuery = createQueryMock(
[ [
[ [
@@ -835,65 +711,6 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
electricCalls, electricCalls,
); );
const hydrogenOverviewApp = new Hono();
registerHydrogenOverviewRoute(hydrogenOverviewApp, {
hydrogenPool: { query: hydrogenQuery },
cached,
} as unknown as HydrogenOverviewDependencies);
const overviewResponse = await hydrogenOverviewApp.request(
"/hydrogen/overview?year=2025&force=1",
);
assert.equal(overviewResponse.status, 200);
const overviewPayload = (await overviewResponse.json()) as {
customers: { payer: string; name: string }[];
};
assert.deepEqual(overviewPayload.customers, [
{
name: "混合承担客户",
payer: "mixed",
kg: 10,
cost: 350,
revenue: 360,
},
]);
const hydrogenDailyApp = new Hono();
registerHydrogenDailyRoute(hydrogenDailyApp, {
hydrogenPool: { query: hydrogenQuery },
cached,
} as unknown as HydrogenDailyDependencies);
const dailyResponse = await hydrogenDailyApp.request(
"/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1",
);
assert.equal(dailyResponse.status, 200);
assert.deepEqual(await dailyResponse.json(), [
{
date: "2025-01-02",
totalKg: 10,
totalFee: 350,
chainPct: 0,
customerType: "external",
stations: [
{
id: 7,
name: "测试站",
pricePerKg: 35,
kg: 10,
fee: 350,
chainPct: 0,
},
],
},
{
date: "2025-01-01",
totalKg: 0,
totalFee: 0,
chainPct: 0,
customerType: "external",
stations: [],
},
]);
const electricApp = new Hono(); const electricApp = new Hono();
const electricDependencies = { const electricDependencies = {
pool: { query: electricQuery }, pool: { query: electricQuery },
@@ -911,11 +728,6 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
assert.equal(electricMonthlyResponse.status, 200); assert.equal(electricMonthlyResponse.status, 200);
assert.deepEqual(cacheCalls, [ assert.deepEqual(cacheCalls, [
{ key: "hydrogen/overview?year=2025", force: true },
{
key: "hydrogen/daily?start=2025-01-01&end=2025-01-02&customer=external",
force: true,
},
{ key: "electric/overview", force: true }, { key: "electric/overview", force: true },
{ {
key: "electric/monthly?customer=external&start=2025-01-01&end=2025-01-02", key: "electric/monthly?customer=external&start=2025-01-01&end=2025-01-02",
@@ -923,18 +735,10 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
}, },
]); ]);
// 指纹覆盖加氢事实专用过滤与既有查询结构,避免手工充值重新混入统计 // 指纹锁定既有查询结构,避免取数口径被无意改动
assert.deepEqual( assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map((call) => sqlHash(call.sql)), electricCalls.map((call) => sqlHash(call.sql)),
[ [
"4fa77c56a990",
"ac6329507bc2",
"e46a92ba851e",
"0295b2deee31",
"852b5b0b9fe1",
"abad6dcf0b2f",
"41cc73632c26",
"1224ee38f710",
"03fd669f8077", "03fd669f8077",
"6e4031b10926", "6e4031b10926",
"4fc77463294c", "4fc77463294c",
@@ -943,103 +747,12 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
], ],
); );
assert.deepEqual(
hydrogenCalls.map((call) => call.params),
[
["2024-01-01"],
[
2025,
2025,
2025,
2025,
2025,
2025,
2025,
2025,
0,
0,
0,
0,
0,
0,
0,
0,
"2024-01-01",
],
["2024-01-01", 2025],
["2024-01-01", 2025],
["2024-01-01", 2025],
["2024-01-01", 2025],
["2024-01-01", 2025],
["2025-01-01", "2025-01-02"],
],
);
assert.match(hydrogenCalls[7].sql, /b\.vehicle_id IS NULL/);
assert.ok(hydrogenCalls.every((call) =>
call.sql.includes("external_recharge_manual"),
));
assert.deepEqual( assert.deepEqual(
electricCalls.map((call) => call.params), electricCalls.map((call) => call.params),
[undefined, undefined, undefined, undefined, ["2025-01-01", "2025-01-02"]], [undefined, undefined, undefined, undefined, ["2025-01-01", "2025-01-02"]],
); );
}); });
test("氢能总览按车辆归属和单站筛选,并隔离缓存结果", async () => {
const calls: QueryCall[] = [];
const cacheCalls: CacheCall[] = [];
const app = new Hono();
registerHydrogenOverviewRoute(app, {
hydrogenPool: {
query: createQueryMock(
[
[{ y: 2026, latestLedgerTime: "2026-08-17 21:50:09" }],
[{ yearKg: 12, latestLedgerTime: "2026-08-17 21:50:09" }],
[],
[],
[],
[],
[],
],
calls,
),
},
cached: createCachedMock(cacheCalls),
} as unknown as HydrogenOverviewDependencies);
const response = await app.request(
"/hydrogen/overview?year=2026&stationId=7&vehicleScope=lingniu",
);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.latestLedgerTime, "2026-08-17 21:50:09");
assert.deepEqual(cacheCalls, [
{
key: "hydrogen/overview?year=2026&station=7&vehicle=lingniu",
force: false,
},
]);
assert.deepEqual(payload.filter, {
stationId: 7,
vehicleScope: "lingniu",
verifyScope: "all",
});
// 年份候选项是全局元数据;其余查询都必须带上单站筛选参数。
assert.ok(
calls
.slice(1)
.every(
(call) =>
Array.isArray(call.params) && (call.params as unknown[]).includes(7),
),
);
assert.match(calls[1].sql, /station_id = \? AND vehicle_id IS NOT NULL/);
assert.match(
calls[2].sql,
/b\.station_id = \? AND b\.vehicle_id IS NOT NULL/,
);
});
test("ETC 总览对空台账返回明确空状态", async () => { test("ETC 总览对空台账返回明确空状态", async () => {
const calls: QueryCall[] = []; const calls: QueryCall[] = [];
const cacheCalls: CacheCall[] = []; const cacheCalls: CacheCall[] = [];