From a177781fe71e2a6e30d0e4d4746f99c128dcafcf Mon Sep 17 00:00:00 2001 From: kfluous Date: Sat, 5 Sep 2026 17:03:15 +0800 Subject: [PATCH] feat(energy): integrate self-operated station customers and receipts Co-authored-by: HiFox Agent --- .env.local.example | 2 + public/lingniu-logo-light.svg | 43 ++++++ src/components/Shell.tsx | 6 +- src/modules/energy/types.ts | 7 + src/server/routes/energy/constants.ts | 4 + src/server/routes/energy/hydrogen-bi-v2.ts | 11 +- .../routes/energy/hydrogen-daily-detail.ts | 4 +- src/server/routes/energy/hydrogen-daily.ts | 4 +- .../routes/energy/hydrogen-overview-detail.ts | 4 +- src/server/routes/energy/hydrogen-overview.ts | 18 +-- .../routes/energy/hydrogen-station-board.ts | 143 ++++++++++++++++-- src/server/routes/energy/routes.test.ts | 114 ++++++++++++-- .../StationDailyDetailView.tsx | 76 ++++++---- .../energy-h2-station-daily/styles.css | 15 ++ 14 files changed, 380 insertions(+), 71 deletions(-) create mode 100644 public/lingniu-logo-light.svg diff --git a/.env.local.example b/.env.local.example index 44b7b00..1972429 100644 --- a/.env.local.example +++ b/.env.local.example @@ -4,6 +4,8 @@ HYDROGEN_DB_PORT=3306 HYDROGEN_DB_USER= HYDROGEN_DB_PASSWORD= HYDROGEN_DB_NAME= +# 单站与外部客户数据租户;仅由服务端设置,不接受网页传入。 +HYDROGEN_TENANT_ID=000000 # Optional legacy/electric business database; use a database-level read-only user. DB_HOST= DB_PORT=3306 diff --git a/public/lingniu-logo-light.svg b/public/lingniu-logo-light.svg new file mode 100644 index 0000000..626c3d8 --- /dev/null +++ b/public/lingniu-logo-light.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 1fa6cc7..5c4da01 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Suspense } from 'react'; import { motion } from 'motion/react'; -import { Building2, ChevronRight, ShieldCheck } from 'lucide-react'; +import { Building2, ChevronRight } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; import { DemoModeProvider } from './Blur'; import FeedbackFab from './FeedbackFab'; @@ -155,7 +155,9 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
- + + +
{user?.userName || '未登录'}
diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index fc4178b..ccf063b 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -185,6 +185,13 @@ export interface HydrogenStationBoardResponse { selected: { daily: HydrogenStationBoardDailyRow[]; customerMonths: HydrogenStationBoardCustomerMonth[]; + externalCustomerMonths?: HydrogenStationBoardCustomerMonth[]; + externalReceipts?: { + scope: 'customer'; + reason?: string; + rows: Array<{ id: string; date: string; customerName: string; amount: number; + payMethod: string; source: string; sourceRecordCount: number; updatedAt: string | null }>; + }; } | null; } diff --git a/src/server/routes/energy/constants.ts b/src/server/routes/energy/constants.ts index 7b93087..cfc2310 100644 --- a/src/server/routes/energy/constants.ts +++ b/src/server/routes/energy/constants.ts @@ -5,4 +5,8 @@ export const HYDROGEN_TABLE = 'hydrogen_fuel_ledger'; export const HYDROGEN_LOCAL = `refuel_time`; export const HYDROGEN_BASE_WHERE = `del_flag = '0'`; export const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`; +// 充值记录与加氢事实共用台账。经营、日报和下钻必须显式排除充值, +// 充值模块则继续使用 BASE 条件读取自己的流水。 +export const HYDROGEN_FUEL_ONLY_WHERE = `${HYDROGEN_BASE_WHERE} AND COALESCE(record_source, '') <> 'external_recharge_manual'`; +export const HYDROGEN_FUEL_ONLY_WHERE_B = `${HYDROGEN_BASE_WHERE_B} AND COALESCE(b.record_source, '') <> 'external_recharge_manual'`; export const ELECTRIC_LOCAL = `charging_start_time`; diff --git a/src/server/routes/energy/hydrogen-bi-v2.ts b/src/server/routes/energy/hydrogen-bi-v2.ts index 731b249..d2a670f 100644 --- a/src/server/routes/energy/hydrogen-bi-v2.ts +++ b/src/server/routes/energy/hydrogen-bi-v2.ts @@ -2,7 +2,8 @@ import type { Hono } from "hono"; import type { RowDataPacket } from "mysql2"; import type hydrogenPool from "../../hydrogen-db.js"; import { - HYDROGEN_BASE_WHERE_B, + HYDROGEN_FUEL_ONLY_WHERE, + HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE, @@ -150,7 +151,7 @@ function resolveFilter(query: (key: string) => string | undefined): Filter { ? (query("verifyScope") as VerifyScope) : "all"; const clauses = [ - HYDROGEN_BASE_WHERE_B, + HYDROGEN_FUEL_ONLY_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`, ]; @@ -309,7 +310,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) { DATE_FORMAT(MIN(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS startDate, DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d') AS endDate FROM ${HYDROGEN_TABLE} - WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ? + WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ? GROUP BY YEAR(${HYDROGEN_LOCAL}) ORDER BY value DESC`, [HYDROGEN_MIN_DATE], @@ -326,7 +327,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) { AND EXISTS ( SELECT 1 FROM ${HYDROGEN_TABLE} b - WHERE b.del_flag = '0' + WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B} AND b.station_id = s.id AND COALESCE(b.amount_kg, 0) > 0 ) @@ -335,7 +336,7 @@ async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) { hydrogenPool.query( `SELECT DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt FROM ${HYDROGEN_TABLE} - WHERE del_flag = '0' AND ${HYDROGEN_LOCAL} >= ?`, + WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ?`, [HYDROGEN_MIN_DATE], ), ]); diff --git a/src/server/routes/energy/hydrogen-daily-detail.ts b/src/server/routes/energy/hydrogen-daily-detail.ts index 5956037..3e644ed 100644 --- a/src/server/routes/energy/hydrogen-daily-detail.ts +++ b/src/server/routes/energy/hydrogen-daily-detail.ts @@ -2,7 +2,7 @@ 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_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.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 { @@ -47,7 +47,7 @@ export function registerHydrogenDailyDetailRoute( 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_BASE_WHERE_B} + 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 diff --git a/src/server/routes/energy/hydrogen-daily.ts b/src/server/routes/energy/hydrogen-daily.ts index ccee1ce..4ee6425 100644 --- a/src/server/routes/energy/hydrogen-daily.ts +++ b/src/server/routes/energy/hydrogen-daily.ts @@ -3,7 +3,7 @@ import type { RowDataPacket } from 'mysql2'; import type hydrogenPool from '../../hydrogen-db.js'; import type { cached } from './cache.js'; import { - HYDROGEN_BASE_WHERE_B, + HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE, @@ -37,7 +37,7 @@ export function registerHydrogenDailyRoute( const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}${verifyScope === 'verified' ? '&verify=verified' : ''}`, async () => { const where = [ - HYDROGEN_BASE_WHERE_B, + HYDROGEN_FUEL_ONLY_WHERE_B, `b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`, dateRangeClause(`b.${HYDROGEN_LOCAL}`), customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id'), diff --git a/src/server/routes/energy/hydrogen-overview-detail.ts b/src/server/routes/energy/hydrogen-overview-detail.ts index d685769..e4c9e76 100644 --- a/src/server/routes/energy/hydrogen-overview-detail.ts +++ b/src/server/routes/energy/hydrogen-overview-detail.ts @@ -2,7 +2,7 @@ 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_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE } from './constants.js'; +import { HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE } from './constants.js'; export interface HydrogenOverviewDetailDependencies { hydrogenPool: Pick; @@ -44,7 +44,7 @@ export function registerHydrogenOverviewDetailRoute( : 2_000; const force = c.req.query('force') === '1'; - const clauses = [HYDROGEN_BASE_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `YEAR(b.${HYDROGEN_LOCAL}) = ?`]; + 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'); diff --git a/src/server/routes/energy/hydrogen-overview.ts b/src/server/routes/energy/hydrogen-overview.ts index 8a88ef4..3a640c0 100644 --- a/src/server/routes/energy/hydrogen-overview.ts +++ b/src/server/routes/energy/hydrogen-overview.ts @@ -3,8 +3,8 @@ import type { RowDataPacket } from 'mysql2'; import type hydrogenPool from '../../hydrogen-db.js'; import type { cached } from './cache.js'; import { - HYDROGEN_BASE_WHERE, - HYDROGEN_BASE_WHERE_B, + HYDROGEN_FUEL_ONLY_WHERE, + HYDROGEN_FUEL_ONLY_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_MIN_DATE, HYDROGEN_TABLE, @@ -95,7 +95,7 @@ export function registerHydrogenOverviewRoute( `SELECT YEAR(${HYDROGEN_LOCAL}) AS y, DATE_FORMAT(MAX(${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime FROM ${HYDROGEN_TABLE} - WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ? + WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ? GROUP BY YEAR(${HYDROGEN_LOCAL}) ORDER BY y DESC`, [HYDROGEN_MIN_DATE], @@ -150,7 +150,7 @@ export function registerHydrogenOverviewRoute( SUM(CASE WHEN vehicle_id IS NOT NULL THEN cost_total ELSE 0 END) AS lingniuBornFee FROM ${HYDROGEN_TABLE} - WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?${filter.ledgerSql}`, + 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, @@ -197,7 +197,7 @@ export function registerHydrogenOverviewRoute( 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_BASE_WHERE_B} + WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql} GROUP BY b.station_id @@ -230,7 +230,7 @@ export function registerHydrogenOverviewRoute( 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_BASE_WHERE_B} + WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql} GROUP BY b.station_id @@ -271,7 +271,7 @@ export function registerHydrogenOverviewRoute( 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_BASE_WHERE_B} + WHERE ${HYDROGEN_FUEL_ONLY_WHERE_B} AND b.${HYDROGEN_LOCAL} >= ? AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql} ) r @@ -303,7 +303,7 @@ export function registerHydrogenOverviewRoute( 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_BASE_WHERE} + WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ? AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql} GROUP BY m @@ -343,7 +343,7 @@ export function registerHydrogenOverviewRoute( SUM(cost_total) AS cost, SUM(fee_total) AS revenue FROM ${HYDROGEN_TABLE} - WHERE ${HYDROGEN_BASE_WHERE} + WHERE ${HYDROGEN_FUEL_ONLY_WHERE} AND ${HYDROGEN_LOCAL} >= ? AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql} GROUP BY name diff --git a/src/server/routes/energy/hydrogen-station-board.ts b/src/server/routes/energy/hydrogen-station-board.ts index 196dcde..727762f 100644 --- a/src/server/routes/energy/hydrogen-station-board.ts +++ b/src/server/routes/energy/hydrogen-station-board.ts @@ -2,7 +2,7 @@ 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_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js'; +import { HYDROGEN_BASE_WHERE, HYDROGEN_BASE_WHERE_B, HYDROGEN_LOCAL, HYDROGEN_TABLE } from './constants.js'; import { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js'; export interface HydrogenStationBoardDependencies { @@ -19,6 +19,30 @@ function numberValue(value: unknown): number { return Number(value) || 0; } +// 单站接口没有租户入参,租户只能由服务端配置决定,不能接受浏览器传值。 +const HYDROGEN_TENANT_ID = process.env.HYDROGEN_TENANT_ID?.trim() || '000000'; +const MANUAL_RECHARGE_SOURCE = 'external_recharge_manual'; + +const NORMALIZED_PLATE = (column: string) => + `UPPER(REPLACE(REPLACE(REPLACE(TRIM(${column}), ' ', ''), '.', ''), '·', ''))`; +const NORMALIZED_PLATE_UNICODE = (column: string) => + `CONVERT(${NORMALIZED_PLATE(column)} USING utf8mb4) COLLATE utf8mb4_unicode_ci`; + +const EXTERNAL_CUSTOMER_MAP_CTE = ` + WITH external_vehicle_candidates AS ( + SELECT ${NORMALIZED_PLATE_UNICODE('plate_number')} AS normalizedPlate, + MAX(NULLIF(TRIM(actual_user), '')) AS customerName, + COUNT(DISTINCT NULLIF(TRIM(actual_user), '')) AS customerCount + FROM hydrogen_order_transfer_external_vehicle_user + WHERE NULLIF(TRIM(plate_number), '') IS NOT NULL + GROUP BY ${NORMALIZED_PLATE_UNICODE('plate_number')} + ), external_vehicle_map AS ( + SELECT normalizedPlate, customerName + FROM external_vehicle_candidates + WHERE customerCount = 1 + AND customerName IS NOT NULL + )`; + // 单站经营看板只做只读聚合。列表仅返回所选区间内存在有效加氢记录的站点。 export function registerHydrogenStationBoardRoute( app: Hono, @@ -48,6 +72,8 @@ export function registerHydrogenStationBoardRoute( LEFT JOIN ${HYDROGEN_TABLE} b ON b.station_id = s.id AND ${HYDROGEN_BASE_WHERE_B} + AND b.tenant_id = ? + AND COALESCE(b.record_source, '') <> ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} LEFT JOIN common_district dp ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci @@ -60,7 +86,7 @@ export function registerHydrogenStationBoardRoute( WHERE s.del_flag = '0' GROUP BY s.id, s.station_short_name, s.station_name, dp.NAME, s.province, dc.NAME, s.city ORDER BY kg DESC, name ASC`, - [range.start, range.end], + [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end], ); const [paymentRows] = await hydrogenPool.query( @@ -82,10 +108,12 @@ export function registerHydrogenStationBoardRoute( COUNT(*) AS recordCount FROM ${HYDROGEN_TABLE} b WHERE ${HYDROGEN_BASE_WHERE_B} + AND b.tenant_id = ? + AND COALESCE(b.record_source, '') <> ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') ORDER BY date ASC`, - [range.start, range.end], + [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end], ); const [stationDailyRows] = await hydrogenPool.query( `SELECT b.station_id AS stationId, @@ -93,10 +121,12 @@ export function registerHydrogenStationBoardRoute( ROUND(SUM(b.amount_kg), 2) AS kg FROM ${HYDROGEN_TABLE} b WHERE ${HYDROGEN_BASE_WHERE_B} + AND b.tenant_id = ? + AND COALESCE(b.record_source, '') <> ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} GROUP BY b.station_id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') ORDER BY stationId ASC, date ASC`, - [range.start, range.end], + [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, range.start, range.end], ); const [summaryPaymentDailyRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date, @@ -154,11 +184,13 @@ export function registerHydrogenStationBoardRoute( COUNT(*) AS recordCount FROM ${HYDROGEN_TABLE} b WHERE ${HYDROGEN_BASE_WHERE_B} + AND b.tenant_id = ? + AND COALESCE(b.record_source, '') <> ? AND b.station_id = ? AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)} GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') ORDER BY date ASC`, - [stationId, range.start, range.end], + [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.start, range.end], ); const [dailyPaymentRows] = await hydrogenPool.query( `SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date, @@ -195,19 +227,90 @@ export function registerHydrogenStationBoardRoute( }); const [customerMonthRows] = await hydrogenPool.query( - `SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month, - COALESCE(NULLIF(MAX(b.system_customer_name), ''), NULLIF(MAX(b.customer_name), ''), '未关联客户') AS customerName, + `${EXTERNAL_CUSTOMER_MAP_CTE} + SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month, + COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户') AS customerName, ROUND(SUM(b.amount_kg), 2) AS kg, ROUND(SUM(b.cost_total), 2) AS fee, COUNT(*) AS recordCount FROM ${HYDROGEN_TABLE} b + LEFT JOIN external_vehicle_map m + ON CONVERT(${NORMALIZED_PLATE('b.license_plate')} USING utf8mb4) COLLATE utf8mb4_unicode_ci + = CONVERT(m.normalizedPlate USING utf8mb4) COLLATE utf8mb4_unicode_ci + AND (b.vehicle_id IS NULL OR b.vehicle_id = 0) WHERE ${HYDROGEN_BASE_WHERE_B} + AND b.tenant_id = ? + AND COALESCE(b.record_source, '') <> ? AND b.station_id = ? AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH) AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY) - GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), COALESCE(b.system_customer_id, b.customer_id, 0) + GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), + COALESCE(m.customerName, NULLIF(TRIM(b.customer_name), ''), NULLIF(TRIM(b.system_customer_name), ''), '未关联客户') ORDER BY month ASC, kg DESC`, - [stationId, range.end, range.end], + [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end], + ); + const [externalCustomerMonthRows] = await hydrogenPool.query( + `${EXTERNAL_CUSTOMER_MAP_CTE} + SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month, + m.customerName, + ROUND(SUM(b.amount_kg), 2) AS kg, + ROUND(SUM(COALESCE(b.fee_total, b.cost_total)), 2) AS fee, + COUNT(*) AS recordCount + FROM ${HYDROGEN_TABLE} b + INNER JOIN external_vehicle_map m + ON ${NORMALIZED_PLATE_UNICODE('b.license_plate')} + = m.normalizedPlate + WHERE ${HYDROGEN_BASE_WHERE_B} + AND b.tenant_id = ? + AND COALESCE(b.record_source, '') <> ? + AND b.station_id = ? + AND (b.vehicle_id IS NULL OR b.vehicle_id = 0) + AND b.${HYDROGEN_LOCAL} >= DATE_SUB(DATE_FORMAT(?, '%Y-%m-01'), INTERVAL 11 MONTH) + AND b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY) + GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m'), m.customerName + ORDER BY month ASC, kg DESC, m.customerName ASC`, + [HYDROGEN_TENANT_ID, MANUAL_RECHARGE_SOURCE, stationId, range.end, range.end], + ); + const [externalReceiptRows] = await hydrogenPool.query( + `SELECT * FROM ( + SELECT CONCAT('auto:', CAST(id AS CHAR)) AS id, + DATE_FORMAT(summary_date, '%Y-%m-%d') AS date, + customer_name AS customerName, + amount, + pay_method AS payMethod, + source, + spot_record_count AS sourceRecordCount, + DATE_FORMAT(COALESCE(refresh_time, update_time), '%Y-%m-%d %H:%i:%s') AS updatedAt + FROM hydrogen_external_recharge_daily_summary + WHERE tenant_id = ? + AND summary_date >= ? + AND summary_date <= ? + UNION ALL + SELECT CONCAT('manual:', CAST(id AS CHAR)) AS id, + DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date, + COALESCE(NULLIF(customer_name, ''), '未关联客户') AS customerName, + fee_total AS amount, + recharge_pay_method AS payMethod, + record_source AS source, + 1 AS sourceRecordCount, + DATE_FORMAT(update_time, '%Y-%m-%d %H:%i:%s') AS updatedAt + FROM ${HYDROGEN_TABLE} + WHERE ${HYDROGEN_BASE_WHERE} + AND tenant_id = ? + AND record_source = ? + AND ${HYDROGEN_LOCAL} >= ? + AND ${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY) + ) externalReceipts + ORDER BY date DESC, customerName ASC, id ASC`, + [ + HYDROGEN_TENANT_ID, + range.start, + range.end, + HYDROGEN_TENANT_ID, + MANUAL_RECHARGE_SOURCE, + range.start, + range.end, + ], ); selected = { daily, @@ -218,6 +321,28 @@ export function registerHydrogenStationBoardRoute( fee: numberValue(row.fee), recordCount: numberValue(row.recordCount), })), + externalCustomerMonths: externalCustomerMonthRows.map(row => ({ + month: String(row.month), + customerName: String(row.customerName), + kg: numberValue(row.kg), + fee: numberValue(row.fee), + recordCount: numberValue(row.recordCount), + })), + // 快照和手工充值都没有可靠站点归属,不能把客户级进账误报为当前站点收益。 + externalReceipts: { + scope: 'customer', + reason: '当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。', + rows: externalReceiptRows.map(row => ({ + id: String(row.id), + date: String(row.date), + customerName: String(row.customerName), + amount: numberValue(row.amount), + payMethod: String(row.payMethod), + source: String(row.source), + sourceRecordCount: numberValue(row.sourceRecordCount), + updatedAt: typeof row.updatedAt === 'string' ? row.updatedAt : null, + })), + }, }; } diff --git a/src/server/routes/energy/routes.test.ts b/src/server/routes/energy/routes.test.ts index 9347384..661c1e0 100644 --- a/src/server/routes/energy/routes.test.ts +++ b/src/server/routes/energy/routes.test.ts @@ -588,6 +588,99 @@ test("单站看板过滤非指定零业务站并合并区间现结流水", async assert.match(calls[4].sql, /GROUP BY DATE_FORMAT\(payment_date/); }); +test("单站外部客户按唯一标准化车牌聚合,进账保持租户客户级口径", async () => { + const calls: QueryCall[] = []; + const stationBoardApp = new Hono(); + registerHydrogenStationBoardRoute(stationBoardApp, { + hydrogenPool: { + query: createQueryMock( + [ + [{ id: 341, name: "佛山南海羚牛加氢站", province: "广东省", city: "佛山市", kg: 10, fee: 300, recordCount: 1 }], + [], [], [], [], + [], [], [], + [ + { + month: "2026-09", + customerName: "外部客户 A", + kg: null, + fee: null, + recordCount: 1, + }, + ], + [ + { + id: "auto:9007199254740993", + date: "2026-09-05", + customerName: "外部客户 A", + amount: 200, + payMethod: "wechat", + source: "spot_daily_auto", + sourceRecordCount: 2, + updatedAt: "2026-09-05 23:59:00", + }, + { + id: "manual:1", + date: "2026-09-04", + customerName: "外部客户 B", + amount: 0, + payMethod: "corporate", + source: "external_recharge_manual", + sourceRecordCount: 1, + updatedAt: null, + }, + ], + ], + calls, + ), + }, + cached: createCachedMock([]), + } as unknown as HydrogenStationBoardDependencies); + + const response = await stationBoardApp.request( + "/hydrogen/station-board?stationId=341&startDate=2026-09-01&endDate=2026-09-05", + ); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.deepEqual(payload.selected.externalCustomerMonths, [{ + month: "2026-09", customerName: "外部客户 A", kg: 0, fee: 0, recordCount: 1, + }]); + assert.deepEqual(payload.selected.externalReceipts, { + scope: "customer", + reason: "当前租户全部外部客户进账,不按站点归属,不计入本单站经营收益或现结 KPI。", + rows: [ + { + id: "auto:9007199254740993", date: "2026-09-05", customerName: "外部客户 A", amount: 200, + payMethod: "wechat", source: "spot_daily_auto", sourceRecordCount: 2, updatedAt: "2026-09-05 23:59:00", + }, + { + id: "manual:1", date: "2026-09-04", customerName: "外部客户 B", amount: 0, + payMethod: "corporate", source: "external_recharge_manual", sourceRecordCount: 1, updatedAt: null, + }, + ], + }); + assert.equal(calls.length, 10); + for (const index of [0, 2, 3, 5, 7, 8, 9]) { + assert.match(calls[index].sql, /tenant_id\s*=\s*\?/); + assert.ok((calls[index].params as unknown[]).includes("000000")); + } + for (const index of [0, 2, 3, 5, 7, 8]) { + assert.match(calls[index].sql, /record_source, ''\) <> \?/); + assert.ok((calls[index].params as unknown[]).includes("external_recharge_manual")); + } + assert.match(calls[8].sql, /customerCount = 1/); + // 自营站客户月度保留全部加氢事实:映射仅做补充,不过滤未匹配或内部车辆。 + assert.match(calls[7].sql, /LEFT JOIN external_vehicle_map/); + assert.match(calls[7].sql, /COALESCE\(m.customerName/); + assert.doesNotMatch(calls[7].sql.split('WHERE b.del_flag')[1], /vehicle_id IS NULL/); + assert.match(calls[7].sql, /GROUP BY[\s\S]*customerName/); + assert.match(calls[8].sql, /CONVERT\(UPPER\(REPLACE/); + assert.match(calls[8].sql, /b\.vehicle_id IS NULL OR b\.vehicle_id = 0/); + assert.match(calls[9].sql, /UNION ALL/); + assert.match(calls[9].sql, /fee_total AS amount/); + assert.match(calls[9].sql, /summary_date >= \?/); + assert.match(calls[9].sql, /refuel_time < DATE_ADD\(\?, INTERVAL 1 DAY\)/); +}); + test("站日现结台账仅读取付款流水,并按最近有数据的 15 天聚合", async () => { const calls: QueryCall[] = []; const cacheCalls: CacheCall[] = []; @@ -807,18 +900,18 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a }, ]); - // 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。 + // 指纹覆盖加氢事实专用过滤与既有查询结构,避免手工充值重新混入统计。 assert.deepEqual( [...hydrogenCalls, ...electricCalls].map((call) => sqlHash(call.sql)), [ - "515acf4dbb2a", - "de16af670e96", - "1367eddb3b7f", - "f125d4dde376", - "c547b8e1318e", - "0e3dfd8f4b81", - "f1f69f275993", - "688cfdb7a2bf", + "4fa77c56a990", + "ac6329507bc2", + "e46a92ba851e", + "0295b2deee31", + "852b5b0b9fe1", + "abad6dcf0b2f", + "41cc73632c26", + "1224ee38f710", "03fd669f8077", "6e4031b10926", "4fc77463294c", @@ -859,6 +952,9 @@ test("四个处理器继续使用原缓存语义、数据库、SQL 与参数", a ], ); 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"]], diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx index b2492f6..003d7cf 100644 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx +++ b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/StationDailyDetailView.tsx @@ -194,18 +194,19 @@ export const StationDailyDetailView: React.FC<{ return idx > 0 ? sorted[idx - 1].quantityKg : null; }, [asOfVolume, volumeRows]); + const customerMonthRows = liveBoard?.selected?.customerMonths; const allCustCells = useMemo( () => { const byCustomer = new Map>(); - for (const row of liveBoard?.selected?.customerMonths ?? []) { + for (const row of customerMonthRows ?? []) { const months = byCustomer.get(row.customerName) ?? {}; const monthKey = String(row.month).slice(0, 7); - months[monthKey] = row.kg; + months[monthKey] = (months[monthKey] ?? 0) + row.kg; byCustomer.set(row.customerName, months); } return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months })); }, - [liveBoard, stationId], + [customerMonthRows, stationId], ); const customerOptions = useMemo( () => allCustCells.map((c) => c.customerName), @@ -220,14 +221,14 @@ export const StationDailyDetailView: React.FC<{ const allFeeCells = useMemo(() => { const byCustomer = new Map>(); - for (const row of liveBoard?.selected?.customerMonths ?? []) { + for (const row of customerMonthRows ?? []) { const months = byCustomer.get(row.customerName) ?? {}; const monthKey = String(row.month).slice(0, 7); - months[monthKey] = row.fee; + months[monthKey] = (months[monthKey] ?? 0) + row.fee; byCustomer.set(row.customerName, months); } return [...byCustomer].map(([customerName, months]) => ({ stationId, customerName, months })); - }, [liveBoard, stationId]); + }, [customerMonthRows, stationId]); const feeCells = useMemo(() => { const allowed = new Set(custCells.map((row) => row.customerName)); return allFeeCells.filter((row) => allowed.has(row.customerName)); @@ -254,17 +255,15 @@ export const StationDailyDetailView: React.FC<{ const cashLines = useMemo( () => - cashDays.flatMap((d) => - d.lines.map((l) => ({ - id: l.id, - bizDate: padYmd(d.bizDate), - customerName: l.customerName, - payMethod: l.payMethod, - amount: l.amount, - })), - ), - [cashDays], + (liveBoard?.selected?.externalReceipts?.rows ?? []).map((row) => ({ + ...row, bizDate: row.date, + sourceLabel: row.source === 'spot_daily_auto' ? '现结自动汇总' + : row.source === 'external_recharge_manual' ? '手工充值' : `未知来源(${row.source})`, + payLabel: row.payMethod === 'corporate' ? '对公转账' : row.payMethod === 'wechat' ? '微信' : row.payMethod || '未注明', + })), + [liveBoard], ); + const receiptTotal = cashLines.reduce((sum, row) => sum + row.amount, 0); const cashVisible = expandCash ? cashLines : cashLines.slice(0, ROW_LIMIT); const cashTotal = cashDays.reduce((s, d) => s + d.totalAmount, 0); @@ -292,6 +291,9 @@ export const StationDailyDetailView: React.FC<{ setExporting(true); setExportError(null); try { + if (!liveBoard?.selected?.customerMonths || !liveBoard?.selected?.externalReceipts) { + throw new Error('客户数据未完整返回,暂不导出,请刷新后重试'); + } const result = await fetchAllH2BiDrillRecords({ year: Number(rangeStart.slice(0, 4)), startDate: rangeStart, endDate: rangeEnd, vehicleScope: 'all', verifyScope: 'all', stationId, @@ -340,11 +342,15 @@ export const StationDailyDetailView: React.FC<{ b.balanceYuan, b.remark || '', ]), - ['小计', balanceSubtotal.recharge, balanceSubtotal.prepaid, balanceSubtotal.spot, balanceSubtotal.balance, ''], + ['未接入完整账户余额,不以零代替缺失数据'], [], - ['氢费充值/现结进账明细'], - ['充值日期', '客户', '付款方式', '金额(元)'], - ...cashLines.map((l) => [l.bizDate, l.customerName, SPOT_PAY_METHOD_LABEL[l.payMethod], l.amount]), + ['外部客户进账(当前租户全部外部客户,不按站点归属;不计入单站收益)'], + ['日期', '客户', '付款方式', '金额(元)', '来源', '源记录数', '刷新时间', '记录ID'], + ...cashLines.map((l) => [l.bizDate, l.customerName, l.payLabel, l.amount, l.sourceLabel, l.sourceRecordCount, l.updatedAt ?? '', l.id]), + [], + ['自营站全部车辆客户加氢月度(不含手工充值)'], + ['月份', '客户', '加氢量(Kg)', '加氢金额(元)', '记录数'], + ...(liveBoard?.selected?.customerMonths ?? []).map((r) => [r.month, r.customerName, r.kg, r.fee, r.recordCount]), [], ['车辆加氢明细(查询区间全部真实账本记录)', result.records.length], ['日期', '车牌', '客户', '归属', '加氢量(Kg)', '单价', '金额(元)'], @@ -491,13 +497,14 @@ export const StationDailyDetailView: React.FC<{
{asOf.slice(0, 7)} · 仅计入查询区间
-
查询区间现结
+
单站现结流水
{money(cashTotal)}
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`} +
不含客户级充值/进账
@@ -678,11 +685,12 @@ export const StationDailyDetailView: React.FC<{ onChange={setSelectedCustomers} /> +

自营站全部车辆与客户,包含羚牛及外部车辆;未关联客户保留统计,不含手工充值。

+ loading={liveLoading} error={liveError || (!customerMonthRows ? '客户数据暂不可用' : null)} />
@@ -708,7 +716,7 @@ export const StationDailyDetailView: React.FC<{ {(customerMonthlyMetric === 'volume' ? custCells : feeCells).length === 0 ? ( ) : ( @@ -745,6 +753,7 @@ export const StationDailyDetailView: React.FC<{

客户氢费收支汇总

+

完整账户余额未接入:新进账表不能单独推算余额或经营利润。

{balVisible.map((customer) => (
@@ -813,16 +822,19 @@ export const StationDailyDetailView: React.FC<{ setExpandBal((v) => !v)} />
-
-
-

氢费充值/现结进账明细

- 合计 ¥{money(cashTotal)} · {cashLines.length} 笔 +
+
+

外部客户充值/现结进账

+ {liveBoard?.selected?.externalReceipts ? `合计 ¥${money(receiptTotal)} · ${cashLines.length} 条` : '金额暂不可用'}
+

当前租户全部外部客户,不按站点归属。现结自动汇总已包含在加氢业务中,不重复计入单站现结或收益;充值也不等于利润。

+ {!cashLines.length ?

{liveBoard?.selected?.externalReceipts ? '查询区间暂无客户进账记录' : '客户进账数据暂不可用:接口未返回新数据,请刷新或检查服务版本。'}

: null}
{cashVisible.map((line) => (
{line.customerName}{line.bizDate}
-
¥{money(line.amount)}{SPOT_PAY_METHOD_LABEL[line.payMethod]}
+
¥{money(line.amount)}{line.payLabel}
+
{line.sourceLabel} · {line.sourceRecordCount} 条源记录{line.updatedAt ? ` · 更新 ${line.updatedAt}` : ''}
))}
@@ -833,14 +845,15 @@ export const StationDailyDetailView: React.FC<{
+ {cashLines.length === 0 ? ( - ) : ( @@ -848,7 +861,8 @@ export const StationDailyDetailView: React.FC<{ - + + )) diff --git a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/styles.css b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/styles.css index 3ace013..3fd07fe 100644 --- a/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/styles.css +++ b/src/vendor/lnbi-8113-exact/prototypes/energy-h2-station-daily/styles.css @@ -288,6 +288,11 @@ min-width: 0; } +.sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-panel > .sd-panel__head-row.sd-external-receipt-head { + display: flex; + flex-wrap: wrap; +} + .sd-panel__head-row { display: flex; align-items: baseline; @@ -2803,6 +2808,16 @@ .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-dual--ledger .sd-table-scroll { display: block; } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub:not([data-mobile-fullscreen-active="true"]) .sd-external-receipts > .sd-table-scroll { + display: none; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub:not([data-mobile-fullscreen-active="true"]) .sd-external-receipts > .sd-mobile-record-list { + display: grid !important; + gap: 8px; + } + .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-external-receipts > .sd-more-btn { + display: inline-flex; + } .sd-embedded .sd-detail.sd-detail--mobile-pilot .sd-mobile-detail-hub[data-mobile-fullscreen-active="true"] { display: flex; flex-direction: column;
- 无匹配客户 + {!customerMonthRows ? '客户数据暂不可用' : '无匹配客户'}
充值日期 客户 付款方式来源 / 源记录数 金额(元)
- 本窗暂无进账明细 + + {liveBoard?.selected?.externalReceipts ? '本窗暂无进账明细' : '客户进账数据暂不可用'}
{l.bizDate} {l.customerName}{SPOT_PAY_METHOD_LABEL[l.payMethod]}{l.payLabel}{l.sourceLabel} / {l.sourceRecordCount} {money(l.amount)}