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 { 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
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
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 });
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user