feat(energy): integrate self-operated station customers and receipts
ci/woodpecker/push/woodpecker Pipeline was canceled
ci/woodpecker/push/woodpecker Pipeline was canceled
Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
@@ -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[] }) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2 border-t border-white/10 pt-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-400/10 text-emerald-300 ring-1 ring-emerald-300/20" title={user?.userName || '当前用户'}>
|
||||
<ShieldCheck size={18} />
|
||||
<svg width="28" height="22" viewBox="4 3 44 30" role="img" aria-label="羚牛 Logo">
|
||||
<image href="/lingniu-logo-light.svg" width="150" height="36" style={{ filter: 'brightness(0) invert(1)' }} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="max-w-16 truncate text-center text-[9px] font-bold text-slate-400">{user?.userName || '未登录'}</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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<RowDataPacket[]>(
|
||||
`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],
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<typeof hydrogenPool, 'query'>;
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<RowDataPacket[]>(
|
||||
@@ -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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
|
||||
@@ -195,19 +227,90 @@ export function registerHydrogenStationBoardRoute(
|
||||
});
|
||||
|
||||
const [customerMonthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`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<RowDataPacket[]>(
|
||||
`${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<RowDataPacket[]>(
|
||||
`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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
+45
-31
@@ -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<string, Record<string, number>>();
|
||||
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<string, Record<string, number>>();
|
||||
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<{
|
||||
<div className="sd-hero-kpi__sub">{asOf.slice(0, 7)} · 仅计入查询区间</div>
|
||||
</div>
|
||||
<div className="sd-hero-kpi">
|
||||
<div className="sd-hero-kpi__label">查询区间现结</div>
|
||||
<div className="sd-hero-kpi__label">单站现结流水</div>
|
||||
<div className="sd-hero-kpi__value">
|
||||
{money(cashTotal)}
|
||||
<span className="sd-unit">元</span>
|
||||
</div>
|
||||
<div className="sd-hero-kpi__sub">
|
||||
{cashDays.length ? `${cashDays.length} 天有进账 · ${dateRangeLabel(startDate, end)}` : `0 天有进账 · ${dateRangeLabel(startDate, end)}`}
|
||||
<br />不含客户级充值/进账
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,11 +685,12 @@ export const StationDailyDetailView: React.FC<{
|
||||
onChange={setSelectedCustomers}
|
||||
/>
|
||||
</div>
|
||||
<p className="sd-panel__meta">自营站全部车辆与客户,包含羚牛及外部车辆;未关联客户保留统计,不含手工充值。</p>
|
||||
<MobileCustomerMonthList key={stationId} months={customerMonthKeys}
|
||||
volumeCustomers={allCustCells} feeCustomers={allFeeCells}
|
||||
month={mobileMonthKey} onMonthChange={setMobileMonthKey}
|
||||
metric={customerMonthlyMetric} onMetricChange={setCustomerMonthlyMetric}
|
||||
loading={liveLoading} error={liveError} />
|
||||
loading={liveLoading} error={liveError || (!customerMonthRows ? '客户数据暂不可用' : null)} />
|
||||
<div className="sd-table-scroll sd-table-scroll--matrix sd-desktop-matrix-table">
|
||||
<table className={`sd-bi-table sd-bi-table--matrix ${customerMonthlyMetric === 'fee' ? 'is-amount' : ''}`}>
|
||||
<thead>
|
||||
@@ -708,7 +716,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
{(customerMonthlyMetric === 'volume' ? custCells : feeCells).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={1 + customerMonthKeys.length} className="ehb-empty-cell">
|
||||
无匹配客户
|
||||
{!customerMonthRows ? '客户数据暂不可用' : '无匹配客户'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -745,6 +753,7 @@ export const StationDailyDetailView: React.FC<{
|
||||
<div className="sd-dual sd-dual--cash sd-dual--ledger">
|
||||
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'balance' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<h2 className="sd-panel__title">客户氢费收支汇总</h2>
|
||||
<p className="sd-panel__meta">完整账户余额未接入:新进账表不能单独推算余额或经营利润。</p>
|
||||
<div className="sd-mobile-record-list sd-mobile-balance-list">
|
||||
{balVisible.map((customer) => (
|
||||
<article key={customer.customerName} className="sd-mobile-record">
|
||||
@@ -813,16 +822,19 @@ export const StationDailyDetailView: React.FC<{
|
||||
<MoreToggle expanded={expandBal} total={balanceRows.length} onToggle={() => setExpandBal((v) => !v)} />
|
||||
</section>
|
||||
|
||||
<section className={`sd-panel sd-mobile-detail-panel ${mobileDetailTab === 'cash' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<div className="sd-panel__head-row">
|
||||
<h2 className="sd-panel__title">氢费充值/现结进账明细</h2>
|
||||
<span className="sd-panel__meta">合计 ¥{money(cashTotal)} · {cashLines.length} 笔</span>
|
||||
<section className={`sd-panel sd-mobile-detail-panel sd-external-receipts ${mobileDetailTab === 'cash' ? 'is-active' : ''}`} data-mobile-fullscreen-list>
|
||||
<div className="sd-panel__head-row sd-external-receipt-head">
|
||||
<h2 className="sd-panel__title">外部客户充值/现结进账</h2>
|
||||
<span className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? `合计 ¥${money(receiptTotal)} · ${cashLines.length} 条` : '金额暂不可用'}</span>
|
||||
</div>
|
||||
<p className="sd-panel__meta">当前租户全部外部客户,不按站点归属。现结自动汇总已包含在加氢业务中,不重复计入单站现结或收益;充值也不等于利润。</p>
|
||||
{!cashLines.length ? <p className="sd-panel__meta">{liveBoard?.selected?.externalReceipts ? '查询区间暂无客户进账记录' : '客户进账数据暂不可用:接口未返回新数据,请刷新或检查服务版本。'}</p> : null}
|
||||
<div className="sd-mobile-record-list sd-mobile-cash-list">
|
||||
{cashVisible.map((line) => (
|
||||
<article key={line.id} className="sd-mobile-record">
|
||||
<div className="sd-mobile-record__lead"><strong>{line.customerName}</strong><span>{line.bizDate}</span></div>
|
||||
<div className="sd-mobile-record__value"><strong className={businessValueClass(line.amount)}>¥{money(line.amount)}</strong><span className="sd-mobile-pay-tag">{SPOT_PAY_METHOD_LABEL[line.payMethod]}</span></div>
|
||||
<div className="sd-mobile-record__value"><strong className={businessValueClass(line.amount)}>¥{money(line.amount)}</strong><span className="sd-mobile-pay-tag">{line.payLabel}</span></div>
|
||||
<div className="sd-mobile-record__meta">{line.sourceLabel} · {line.sourceRecordCount} 条源记录{line.updatedAt ? ` · 更新 ${line.updatedAt}` : ''}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -833,14 +845,15 @@ export const StationDailyDetailView: React.FC<{
|
||||
<th>充值日期</th>
|
||||
<th>客户</th>
|
||||
<th>付款方式</th>
|
||||
<th>来源 / 源记录数</th>
|
||||
<th className="is-num">金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cashLines.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="ehb-empty-cell">
|
||||
本窗暂无进账明细
|
||||
<td colSpan={5} className="ehb-empty-cell">
|
||||
{liveBoard?.selected?.externalReceipts ? '本窗暂无进账明细' : '客户进账数据暂不可用'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -848,7 +861,8 @@ export const StationDailyDetailView: React.FC<{
|
||||
<tr key={l.id}>
|
||||
<td className="is-mono">{l.bizDate}</td>
|
||||
<td title={l.customerName}>{l.customerName}</td>
|
||||
<td>{SPOT_PAY_METHOD_LABEL[l.payMethod]}</td>
|
||||
<td>{l.payLabel}</td>
|
||||
<td>{l.sourceLabel} / {l.sourceRecordCount}</td>
|
||||
<td className={`is-num ${businessValueClass(l.amount)}`}>{money(l.amount)}</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user