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:
@@ -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
@@ -1,117 +1,17 @@
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import type {
|
||||
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow,
|
||||
HydrogenCustomerRow, HydrogenStationFull,
|
||||
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
|
||||
CustomerType, DateQuickPick,
|
||||
HydrogenStationBoardResponse,
|
||||
HydrogenDailyDetailResponse,
|
||||
HydrogenOverviewDetailGroupBy,
|
||||
HydrogenOverviewDetailResponse,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/energy';
|
||||
|
||||
export interface HydrogenOverviewResponse {
|
||||
kpi: HydrogenKpi;
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
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()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 氢能单站日报数据源。
|
||||
* 注意:该接口路径仍带 `/hydrogen` 前缀(历史命名),但为当前单站日报页面在用,
|
||||
* 与已下线的 `/hydrogen/overview|daily|settlement` 等旧接口无关。
|
||||
*/
|
||||
export interface HydrogenStationBoardQuery {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
@@ -126,6 +26,13 @@ export function fetchHydrogenStationBoard(query: HydrogenStationBoardQuery): Pro
|
||||
return fetchJson<HydrogenStationBoardResponse>(`${BASE}/hydrogen/station-board?${params.toString()}`);
|
||||
}
|
||||
|
||||
/** 电能与 ETC 共用的自然日区间查询参数。 */
|
||||
export interface EnergyRangeQuery {
|
||||
range?: DateQuickPick;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface ElectricOverviewResponse {
|
||||
kpi: ElectricKpi;
|
||||
trend: ElectricDailyRow[];
|
||||
@@ -136,12 +43,15 @@ export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
|
||||
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
|
||||
}
|
||||
|
||||
export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise<ElectricMonthGroup[]> {
|
||||
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);
|
||||
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
|
||||
export function fetchElectricMonthly(
|
||||
customer: CustomerType,
|
||||
query: EnergyRangeQuery = { range: 'last15' },
|
||||
): Promise<ElectricMonthGroup[]> {
|
||||
const params = new URLSearchParams({ customer });
|
||||
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 {
|
||||
@@ -158,45 +68,3 @@ export interface EtcOverviewResponse {
|
||||
export function fetchEtcOverview(force = false): Promise<EtcOverviewResponse> {
|
||||
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
@@ -1,127 +1,8 @@
|
||||
/** 能源模块(电能 / ETC / 单站日报)共用的 DTO 与筛选枚举。 */
|
||||
|
||||
export type CustomerType = 'all' | 'external' | 'lingniu';
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -195,77 +76,6 @@ export interface HydrogenStationBoardResponse {
|
||||
} | 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 {
|
||||
totalKwh: 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -9,11 +9,6 @@ import {
|
||||
registerElectricOverviewRoute,
|
||||
} from './electric.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 { registerHydrogenBiV2Routes } from './hydrogen-bi-v2.js';
|
||||
|
||||
@@ -31,11 +26,8 @@ app.use('*', async (c, next) => {
|
||||
|
||||
const cacheDependency = { cached };
|
||||
|
||||
registerHydrogenOverviewRoute(app, { ...cacheDependency, hydrogenPool });
|
||||
registerHydrogenOverviewDetailRoute(app, { ...cacheDependency, hydrogenPool });
|
||||
registerHydrogenDailyRoute(app, { ...cacheDependency, hydrogenPool });
|
||||
registerHydrogenDailyDetailRoute(app, { ...cacheDependency, hydrogenPool });
|
||||
registerHydrogenSettlementRoute(app, { ...cacheDependency, hydrogenPool });
|
||||
// 氢能经营看板:/hydrogen/station-board 仍被单站日报页面使用;/h2/v2/* 为当前看板所用。
|
||||
// 注册顺序沿用历史顺序(station-board 在 h2/v2 之前),以免影响路由匹配语义。
|
||||
registerHydrogenStationBoardRoute(app, { ...cacheDependency, hydrogenPool });
|
||||
registerHydrogenBiV2Routes(app, { hydrogenPool });
|
||||
registerElectricOverviewRoute(app, { ...cacheDependency, pool });
|
||||
|
||||
@@ -12,18 +12,6 @@ import {
|
||||
registerEtcOverviewRoute,
|
||||
type EtcOverviewDependencies,
|
||||
} 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 {
|
||||
registerHydrogenStationBoardRoute,
|
||||
type HydrogenStationBoardDependencies,
|
||||
@@ -90,11 +78,6 @@ test("能源路由按既有顺序注册并增加只读单站看板接口", () =>
|
||||
.filter((route) => route.method === "GET")
|
||||
.map((route) => route.path),
|
||||
[
|
||||
"/hydrogen/overview",
|
||||
"/hydrogen/overview-detail",
|
||||
"/hydrogen/daily",
|
||||
"/hydrogen/daily-detail",
|
||||
"/hydrogen/settlement",
|
||||
"/hydrogen/station-board",
|
||||
"/h2/v2/meta",
|
||||
"/h2/v2/overview",
|
||||
@@ -704,117 +687,10 @@ test("单站外部客户按唯一标准化车牌聚合,进账保持租户客
|
||||
assert.match(calls[9].sql, /refuel_time < DATE_ADD\(\?, INTERVAL 1 DAY\)/);
|
||||
});
|
||||
|
||||
test("站日现结台账仅读取付款流水,并按最近有数据的 15 天聚合", 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[] = [];
|
||||
test("电能处理器继续使用原缓存语义、数据库、SQL 与参数", async () => {
|
||||
const electricCalls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
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(
|
||||
[
|
||||
[
|
||||
@@ -835,65 +711,6 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
|
||||
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 electricDependencies = {
|
||||
pool: { query: electricQuery },
|
||||
@@ -911,11 +728,6 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
|
||||
assert.equal(electricMonthlyResponse.status, 200);
|
||||
|
||||
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/monthly?customer=external&start=2025-01-01&end=2025-01-02",
|
||||
@@ -923,18 +735,10 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a
|
||||
},
|
||||
]);
|
||||
|
||||
// 指纹覆盖加氢事实专用过滤与既有查询结构,避免手工充值重新混入统计。
|
||||
// 指纹锁定既有查询结构,避免取数口径被无意改动。
|
||||
assert.deepEqual(
|
||||
[...hydrogenCalls, ...electricCalls].map((call) => sqlHash(call.sql)),
|
||||
electricCalls.map((call) => sqlHash(call.sql)),
|
||||
[
|
||||
"4fa77c56a990",
|
||||
"ac6329507bc2",
|
||||
"e46a92ba851e",
|
||||
"0295b2deee31",
|
||||
"852b5b0b9fe1",
|
||||
"abad6dcf0b2f",
|
||||
"41cc73632c26",
|
||||
"1224ee38f710",
|
||||
"03fd669f8077",
|
||||
"6e4031b10926",
|
||||
"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(
|
||||
electricCalls.map((call) => call.params),
|
||||
[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 () => {
|
||||
const calls: QueryCall[] = [];
|
||||
const cacheCalls: CacheCall[] = [];
|
||||
|
||||
Reference in New Issue
Block a user