From 680c7e0662f2db8a4ab93ffbb2141a5694af2b55 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Fri, 11 Sep 2026 10:16:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(stage3):=20=E4=B8=8B=E7=BA=BF=205=20?= =?UTF-8?q?=E4=B8=AA=E6=97=A0=E8=B0=83=E7=94=A8=E6=96=B9=E7=9A=84=20legacy?= =?UTF-8?q?=20=E6=B0=A2=E8=83=BD=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先确认调用方再删:/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 全绿。 --- src/modules/energy/api.test.ts | 14 - src/modules/energy/api.ts | 174 +------- src/modules/energy/types.ts | 194 +-------- .../routes/energy/hydrogen-daily-detail.ts | 153 ------- src/server/routes/energy/hydrogen-daily.ts | 162 -------- .../routes/energy/hydrogen-overview-detail.ts | 271 ------------- src/server/routes/energy/hydrogen-overview.ts | 374 ------------------ .../routes/energy/hydrogen-settlement.ts | 119 ------ src/server/routes/energy/index.ts | 12 +- src/server/routes/energy/routes.test.ts | 293 +------------- 10 files changed, 28 insertions(+), 1738 deletions(-) delete mode 100644 src/modules/energy/api.test.ts delete mode 100644 src/server/routes/energy/hydrogen-daily-detail.ts delete mode 100644 src/server/routes/energy/hydrogen-daily.ts delete mode 100644 src/server/routes/energy/hydrogen-overview-detail.ts delete mode 100644 src/server/routes/energy/hydrogen-overview.ts delete mode 100644 src/server/routes/energy/hydrogen-settlement.ts diff --git a/src/modules/energy/api.test.ts b/src/modules/energy/api.test.ts deleted file mode 100644 index 406d429..0000000 --- a/src/modules/energy/api.test.ts +++ /dev/null @@ -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', - ); -}); diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 3d8d398..7a303fa 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -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 { - 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(`${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 { - 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(`${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 { - 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(`${BASE}/hydrogen/daily?${q.toString()}`); -} - -export function fetchHydrogenDailyDetail( - date: string, - customer: CustomerType, - stationId?: number | null, - verifyScope: HydrogenVerifyScope = 'all', -): Promise { - const params = new URLSearchParams({ date, customer }); - if (stationId) params.set('stationId', String(stationId)); - if (verifyScope === 'verified') params.set('verifyScope', 'verified'); - return fetchJson(`${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(`${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 { return fetchJson(`${BASE}/electric/overview`); } -export function fetchElectricMonthly(customer: CustomerType, query: HydrogenDailyQuery = { range: 'last15' }): Promise { - 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(`${BASE}/electric/monthly?${q.toString()}`); +export function fetchElectricMonthly( + customer: CustomerType, + query: EnergyRangeQuery = { range: 'last15' }, +): Promise { + 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(`${BASE}/electric/monthly?${params.toString()}`); } export interface EtcOverviewResponse { @@ -158,45 +68,3 @@ export interface EtcOverviewResponse { export function fetchEtcOverview(force = false): Promise { return fetchJson(`${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 { - const q = buildHydrogenSettlementQuery(query); - return fetchJson(`${BASE}/hydrogen/settlement${q ? `?${q}` : ''}`); -} diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index ccf063b..1f5095e 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -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; diff --git a/src/server/routes/energy/hydrogen-daily-detail.ts b/src/server/routes/energy/hydrogen-daily-detail.ts deleted file mode 100644 index 3e644ed..0000000 --- a/src/server/routes/energy/hydrogen-daily-detail.ts +++ /dev/null @@ -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; - 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( - `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 }; - const stations = new Map(); - - 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(); - if (stationIds.length > 0) { - const placeholders = stationIds.map(() => '?').join(', '); - const [balanceRows] = await hydrogenPool.query( - `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); - }); -} diff --git a/src/server/routes/energy/hydrogen-daily.ts b/src/server/routes/energy/hydrogen-daily.ts deleted file mode 100644 index 4ee6425..0000000 --- a/src/server/routes/energy/hydrogen-daily.ts +++ /dev/null @@ -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; - 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( - `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(); - 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(); - 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(); - const stationChain = new Map(); // key = `${date}|${stationId}` - // 需要按 stationId 分组排序 - const byStation = new Map(); - 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); - }); -} diff --git a/src/server/routes/energy/hydrogen-overview-detail.ts b/src/server/routes/energy/hydrogen-overview-detail.ts deleted file mode 100644 index e4c9e76..0000000 --- a/src/server/routes/energy/hydrogen-overview-detail.ts +++ /dev/null @@ -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; - 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( - `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( - `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( - `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); - }); -} diff --git a/src/server/routes/energy/hydrogen-overview.ts b/src/server/routes/energy/hydrogen-overview.ts deleted file mode 100644 index 3a640c0..0000000 --- a/src/server/routes/energy/hydrogen-overview.ts +++ /dev/null @@ -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; - 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( - `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( - `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( - `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( - `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( - `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( - `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(); - 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( - `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); - }); -} diff --git a/src/server/routes/energy/hydrogen-settlement.ts b/src/server/routes/energy/hydrogen-settlement.ts deleted file mode 100644 index 93e3838..0000000 --- a/src/server/routes/energy/hydrogen-settlement.ts +++ /dev/null @@ -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; - 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( - `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( - `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); - }); -} diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index d36adc1..5098cd0 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -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 }); diff --git a/src/server/routes/energy/routes.test.ts b/src/server/routes/energy/routes.test.ts index a4f3c8b..a8498e2 100644 --- a/src/server/routes/energy/routes.test.ts +++ b/src/server/routes/energy/routes.test.ts @@ -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[] = [];