feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -32,7 +32,8 @@ export function registerElectricOverviewRoute(
|
||||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||||
THEN fee ELSE 0 END) AS monthFee,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
|
||||
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee,
|
||||
DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestChargeTime
|
||||
FROM bi_ele_charge_record`,
|
||||
);
|
||||
const k = kpiRows[0] ?? {};
|
||||
@@ -94,6 +95,7 @@ export function registerElectricOverviewRoute(
|
||||
return {
|
||||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||
trend: trendArr,
|
||||
latestChargeTime: typeof k.latestChargeTime === 'string' ? k.latestChargeTime : null,
|
||||
};
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type pool from '../../db.js';
|
||||
import type { cached } from './cache.js';
|
||||
|
||||
export interface EtcOverviewDependencies {
|
||||
pool: Pick<typeof pool, 'query'>;
|
||||
cached: typeof cached;
|
||||
}
|
||||
|
||||
// ETC 仅聚合已入库的账单与通行明细;空表应明确返回空状态,不能伪造经营数据。
|
||||
export function registerEtcOverviewRoute(
|
||||
app: Hono,
|
||||
{ pool, cached }: EtcOverviewDependencies,
|
||||
) {
|
||||
app.get('/etc/overview', async (c) => {
|
||||
const force = c.req.query('force') === '1';
|
||||
const data = await cached('etc/overview', async () => {
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM etc_toll_record WHERE del_flag = '0') AS tollRecordCount,
|
||||
(SELECT COUNT(DISTINCT plate_number) FROM etc_toll_record WHERE del_flag = '0') AS vehicleCount,
|
||||
(SELECT ROUND(SUM(total_amount), 2) FROM etc_toll_record WHERE del_flag = '0') AS totalAmount,
|
||||
(SELECT DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') FROM etc_toll_record WHERE del_flag = '0') AS latestTollTime,
|
||||
(SELECT COUNT(*) FROM energy_etc_bill WHERE del_flag = '0') AS billCount,
|
||||
(SELECT ROUND(SUM(receivable_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS receivableAmount,
|
||||
(SELECT ROUND(SUM(paid_amount), 2) FROM energy_etc_bill WHERE del_flag = '0') AS paidAmount`,
|
||||
);
|
||||
const row = rows[0] ?? {};
|
||||
const tollRecordCount = Number(row.tollRecordCount) || 0;
|
||||
const billCount = Number(row.billCount) || 0;
|
||||
return {
|
||||
tollRecordCount,
|
||||
vehicleCount: Number(row.vehicleCount) || 0,
|
||||
totalAmount: Number(row.totalAmount) || 0,
|
||||
latestTollTime: typeof row.latestTollTime === 'string' ? row.latestTollTime : null,
|
||||
billCount,
|
||||
receivableAmount: Number(row.receivableAmount) || 0,
|
||||
paidAmount: Number(row.paidAmount) || 0,
|
||||
hasData: tollRecordCount > 0 || billCount > 0,
|
||||
};
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { RowDataPacket } from "mysql2";
|
||||
import type hydrogenPool from "../../hydrogen-db.js";
|
||||
import {
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
HYDROGEN_LOCAL,
|
||||
HYDROGEN_MIN_DATE,
|
||||
HYDROGEN_TABLE,
|
||||
} from "./constants.js";
|
||||
|
||||
/**
|
||||
* The BI v2 contract is intentionally independent of the legacy hydrogen
|
||||
* endpoints. It is shaped around the prototype screens so the client never
|
||||
* has to merge multiple historical responses or recompute a business metric.
|
||||
*/
|
||||
export interface HydrogenBiV2Dependencies {
|
||||
hydrogenPool: Pick<typeof hydrogenPool, "query">;
|
||||
}
|
||||
|
||||
type VehicleScope = "all" | "lingniu" | "external";
|
||||
type VerifyScope = "all" | "verified";
|
||||
type AmountScope = "all" | "customer" | "company" | "other";
|
||||
type GroupBy = "station" | "customer" | "date" | "vehicle" | "record";
|
||||
type RegionGranularity = "province" | "city";
|
||||
|
||||
type Filter = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
// Station IDs are bigint values. Keep them as strings end-to-end: converting
|
||||
// them to JavaScript Number merges distinct OneOS station IDs above 2^53.
|
||||
stationId: string | null;
|
||||
customerId: number | null;
|
||||
customerName: string | null;
|
||||
plateNo: string | null;
|
||||
date: string | null;
|
||||
month: string | null;
|
||||
region: string | null;
|
||||
regionGranularity: RegionGranularity;
|
||||
vehicleScope: VehicleScope;
|
||||
verifyScope: VerifyScope;
|
||||
clauses: string[];
|
||||
params: unknown[];
|
||||
};
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||
|
||||
function validDate(value: string | undefined): value is string {
|
||||
if (!value || !DATE_PATTERN.test(value)) return false;
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return (
|
||||
!Number.isNaN(parsed.getTime()) &&
|
||||
parsed.toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
|
||||
function endOfMonth(month: string) {
|
||||
const [year, monthNumber] = month.split("-").map(Number);
|
||||
return new Date(Date.UTC(year, monthNumber, 0)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function stationRegionSql(granularity: RegionGranularity) {
|
||||
// new_hydrogen_site is the OneOS station master (453 active stations). The
|
||||
// historical hydrogen_station table only contains 89 records, so it cannot
|
||||
// be used to decide a ledger station's province/city.
|
||||
const districtColumn = granularity === "province" ? "rs.province" : "rs.city";
|
||||
const stationName = `COALESCE(
|
||||
(SELECT COALESCE(NULLIF(rs.site_short_name, ''), NULLIF(rs.site_name, ''))
|
||||
FROM new_hydrogen_site rs
|
||||
WHERE rs.id = b.station_id AND rs.del_flag = '0'
|
||||
LIMIT 1),
|
||||
b.station_name,
|
||||
''
|
||||
)`;
|
||||
const fallback =
|
||||
granularity === "province"
|
||||
? `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '浙江省'
|
||||
WHEN ${stationName} LIKE '%广州%' OR ${stationName} LIKE '%佛山%' THEN '广东省'
|
||||
WHEN ${stationName} LIKE '%成都%' THEN '四川省'
|
||||
WHEN ${stationName} LIKE '%昆山%' THEN '江苏省'
|
||||
ELSE '未归属省份' END`
|
||||
: `CASE WHEN ${stationName} LIKE '%嘉兴%' OR ${stationName} LIKE '%平湖%' THEN '嘉兴市'
|
||||
WHEN ${stationName} LIKE '%广州%' THEN '广州市'
|
||||
WHEN ${stationName} LIKE '%佛山%' THEN '佛山市'
|
||||
WHEN ${stationName} LIKE '%成都%' THEN '成都市'
|
||||
WHEN ${stationName} LIKE '%昆山%' THEN '昆山市'
|
||||
ELSE '未归属区域' END`;
|
||||
return `COALESCE(
|
||||
(SELECT NULLIF(rd.NAME, '')
|
||||
FROM new_hydrogen_site rs
|
||||
LEFT JOIN common_district rd
|
||||
ON CONVERT(rd.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(${districtColumn} USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND rd.STATUS = 'VALID'
|
||||
WHERE rs.id = b.station_id AND rs.del_flag = '0'
|
||||
LIMIT 1),
|
||||
${fallback}
|
||||
)`;
|
||||
}
|
||||
|
||||
function todayYmd() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dateForYear(year: number, edge: "start" | "end") {
|
||||
return `${year}-${edge === "start" ? "01-01" : "12-31"}`;
|
||||
}
|
||||
|
||||
function validYear(value: string | undefined) {
|
||||
const year = Number(value);
|
||||
// 原型允许查看 2020 起的历史年份。即使真实台账尚未覆盖某一年,
|
||||
// 也必须保持该筛选范围并返回空聚合,不能静默回退到当前年。
|
||||
return Number.isInteger(year) && year >= 2020 && year <= 2100 ? year : null;
|
||||
}
|
||||
|
||||
function resolveFilter(query: (key: string) => string | undefined): Filter {
|
||||
const requestedYear = validYear(query("year"));
|
||||
const currentYear = new Date().getFullYear();
|
||||
const year = requestedYear ?? currentYear;
|
||||
const exactDate = validDate(query("date")) ? query("date")! : null;
|
||||
const requestedMonth = MONTH_PATTERN.test(query("month") ?? "")
|
||||
? query("month")!
|
||||
: null;
|
||||
const startDate =
|
||||
exactDate ??
|
||||
(requestedMonth
|
||||
? `${requestedMonth}-01`
|
||||
: validDate(query("startDate"))
|
||||
? query("startDate")!
|
||||
: dateForYear(year, "start"));
|
||||
// A current-year dashboard must never render future zero months/days by
|
||||
// default. Historical years retain their complete calendar range.
|
||||
const endDate =
|
||||
exactDate ??
|
||||
(requestedMonth
|
||||
? endOfMonth(requestedMonth)
|
||||
: validDate(query("endDate"))
|
||||
? query("endDate")!
|
||||
: year === currentYear
|
||||
? todayYmd()
|
||||
: dateForYear(year, "end"));
|
||||
const [safeStart, safeEnd] =
|
||||
startDate <= endDate ? [startDate, endDate] : [endDate, startDate];
|
||||
const stationValue = query("stationId");
|
||||
const stationId =
|
||||
stationValue !== undefined && /^\d+$/.test(stationValue)
|
||||
? stationValue
|
||||
: null;
|
||||
const customerValue = query("customerId");
|
||||
const customerRaw = Number(customerValue);
|
||||
const customerId =
|
||||
customerValue !== undefined &&
|
||||
Number.isInteger(customerRaw) &&
|
||||
customerRaw >= 0
|
||||
? customerRaw
|
||||
: null;
|
||||
const customerName =
|
||||
(query("customerName") ?? "").trim().slice(0, 100) || null;
|
||||
const plateNo = (query("plateNo") ?? "").trim().slice(0, 32) || null;
|
||||
const region = (query("region") ?? "").trim().slice(0, 64) || null;
|
||||
const regionGranularity: RegionGranularity =
|
||||
query("regionGranularity") === "province" ? "province" : "city";
|
||||
const vehicleScope: VehicleScope =
|
||||
query("vehicleScope") === "lingniu" || query("vehicleScope") === "external"
|
||||
? (query("vehicleScope") as VehicleScope)
|
||||
: "all";
|
||||
const verifyScope: VerifyScope =
|
||||
query("verifyScope") === "verified" ? "verified" : "all";
|
||||
const clauses = [
|
||||
HYDROGEN_BASE_WHERE_B,
|
||||
`b.${HYDROGEN_LOCAL} >= ?`,
|
||||
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
|
||||
];
|
||||
const params: unknown[] = [
|
||||
safeStart < HYDROGEN_MIN_DATE ? HYDROGEN_MIN_DATE : safeStart,
|
||||
safeEnd,
|
||||
];
|
||||
if (stationId !== null) {
|
||||
clauses.push("COALESCE(b.station_id, 0) = ?");
|
||||
params.push(stationId);
|
||||
}
|
||||
if (customerId !== null) {
|
||||
clauses.push("COALESCE(b.system_customer_id, b.customer_id, 0) = ?");
|
||||
params.push(customerId);
|
||||
}
|
||||
if (customerName) {
|
||||
clauses.push(
|
||||
"COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') = ?",
|
||||
);
|
||||
params.push(customerName);
|
||||
}
|
||||
if (plateNo) {
|
||||
clauses.push("COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?");
|
||||
params.push(plateNo);
|
||||
}
|
||||
if (region) {
|
||||
clauses.push(`${stationRegionSql(regionGranularity)} = ?`);
|
||||
params.push(region);
|
||||
}
|
||||
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'",
|
||||
);
|
||||
return {
|
||||
startDate: safeStart,
|
||||
endDate: safeEnd,
|
||||
stationId,
|
||||
customerId,
|
||||
customerName,
|
||||
plateNo,
|
||||
date: exactDate,
|
||||
month: requestedMonth,
|
||||
region,
|
||||
regionGranularity,
|
||||
vehicleScope,
|
||||
verifyScope,
|
||||
clauses,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function where(filter: Filter) {
|
||||
return filter.clauses.join(" AND ");
|
||||
}
|
||||
function filterContext(filter: Filter) {
|
||||
return {
|
||||
startDate: filter.startDate,
|
||||
endDate: filter.endDate,
|
||||
stationId: filter.stationId,
|
||||
customerId: filter.customerId,
|
||||
customerName: filter.customerName,
|
||||
plateNo: filter.plateNo,
|
||||
date: filter.date,
|
||||
month: filter.month,
|
||||
region: filter.region,
|
||||
regionGranularity: filter.regionGranularity,
|
||||
vehicleScope: filter.vehicleScope,
|
||||
verifyScope: filter.verifyScope,
|
||||
};
|
||||
}
|
||||
function number(value: unknown, digits = 2) {
|
||||
const numeric = Number(value) || 0;
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(numeric * factor) / factor;
|
||||
}
|
||||
|
||||
// 承担方必须以账本专用字段 settlement_type 为准,不能由对客单价/金额反推。
|
||||
// hydrogen_fuel_ledger_log 中的业务映射:1 = 客户承担,2 = 我司承担,3 = 客户自行结算。
|
||||
// 其余值(含 3、4、NULL)统一归入“其他”,避免把未知/自行结算误计为我司承担。
|
||||
const CUSTOMER_BEARING_ORDER = "b.settlement_type = 1";
|
||||
const COMPANY_BEARING_ORDER = "b.settlement_type = 2";
|
||||
const OTHER_BEARING_ORDER =
|
||||
"(b.settlement_type NOT IN (1, 2) OR b.settlement_type IS NULL)";
|
||||
|
||||
function resolveAmountScope(value: string | undefined): AmountScope {
|
||||
return value === "customer" || value === "company" || value === "other"
|
||||
? value
|
||||
: "all";
|
||||
}
|
||||
|
||||
function formatWatermark(value: unknown) {
|
||||
return value === null || value === undefined ? null : String(value);
|
||||
}
|
||||
|
||||
function monthKeys(start: string, end: string) {
|
||||
const list: string[] = [];
|
||||
const cursor = new Date(`${start.slice(0, 7)}-01T00:00:00`);
|
||||
const finish = new Date(`${end.slice(0, 7)}-01T00:00:00`);
|
||||
while (cursor <= finish && list.length <= 36) {
|
||||
list.push(
|
||||
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}`,
|
||||
);
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function dateKeys(start: string, end: string) {
|
||||
const list: string[] = [];
|
||||
const cursor = new Date(`${start}T00:00:00`);
|
||||
const finish = new Date(`${end}T00:00:00`);
|
||||
while (cursor <= finish && list.length <= 400) {
|
||||
list.push(
|
||||
`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}`,
|
||||
);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async function meta(hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"]) {
|
||||
const [yearRows, stationRows, watermarkRows] = await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT YEAR(${HYDROGEN_LOCAL}) AS value,
|
||||
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} >= ?
|
||||
GROUP BY YEAR(${HYDROGEN_LOCAL})
|
||||
ORDER BY value DESC`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(s.id AS CHAR) AS id,
|
||||
COALESCE(NULLIF(s.site_short_name, ''), NULLIF(s.site_name, ''), CONCAT('站点 #', s.id)) AS name,
|
||||
COALESCE(NULLIF(p.NAME, ''), '未归属') AS province,
|
||||
COALESCE(NULLIF(ct.NAME, ''), '未归属') AS city
|
||||
FROM new_hydrogen_site s
|
||||
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||
WHERE s.del_flag = '0'
|
||||
ORDER BY name`,
|
||||
),
|
||||
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} >= ?`,
|
||||
[HYDROGEN_MIN_DATE],
|
||||
),
|
||||
]);
|
||||
return {
|
||||
years: yearRows[0].map((row) => ({
|
||||
value: Number(row.value),
|
||||
startDate: String(row.startDate),
|
||||
endDate: String(row.endDate),
|
||||
})),
|
||||
stations: stationRows[0].map((row) => ({
|
||||
id: String(row.id),
|
||||
name: String(row.name),
|
||||
province: String(row.province),
|
||||
city: String(row.city),
|
||||
})),
|
||||
watermark: {
|
||||
ledgerAt: formatWatermark(watermarkRows[0][0]?.ledgerAt),
|
||||
paymentAt: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function overview(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const month = filter.endDate.slice(0, 7);
|
||||
const [summaryRows, monthlyRows, stationRows, customerRows] =
|
||||
await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS totalCost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS totalRevenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS monthKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ? THEN b.cost_total ELSE 0 END), 0), 2) AS monthCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.amount_kg ELSE 0 END), 0), 3) AS todayKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN DATE(b.${HYDROGEN_LOCAL}) = ? THEN b.cost_total ELSE 0 END), 0), 2) AS todayCost,
|
||||
COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}`,
|
||||
[month, month, filter.endDate, filter.endDate, ...filter.params],
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') AS month,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS totalKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m')
|
||||
ORDER BY month`,
|
||||
filter.params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
||||
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
|
||||
COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province,
|
||||
COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
||||
COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.station_id, 0)
|
||||
ORDER BY kg DESC`,
|
||||
filter.params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS customerBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS companyBearingKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.amount_kg ELSE 0 END), 0), 3) AS otherBearingKg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.fee_total ELSE 0 END), 0), 2) AS customerRevenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${CUSTOMER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${COMPANY_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS companyCost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN ${OTHER_BEARING_ORDER} THEN b.cost_total ELSE 0 END), 0), 2) AS otherCost,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
||||
ORDER BY kg DESC
|
||||
LIMIT 200`,
|
||||
filter.params,
|
||||
),
|
||||
]);
|
||||
const summary: RowDataPacket = summaryRows[0][0] ?? ({} as RowDataPacket);
|
||||
const totalKg = number(summary.totalKg, 3);
|
||||
const totalRevenue = number(summary.totalRevenue);
|
||||
const customerRevenue = number(summary.customerRevenue);
|
||||
const customerCost = number(summary.customerCost);
|
||||
const monthlyMap = new Map(
|
||||
monthlyRows[0].map((row) => [String(row.month), row]),
|
||||
);
|
||||
const monthly = monthKeys(filter.startDate, filter.endDate).map((key) => {
|
||||
const row = monthlyMap.get(key);
|
||||
const revenue = number(row?.revenue);
|
||||
const customerMonthRevenue = number(row?.customerRevenue);
|
||||
const cost = number(row?.cost);
|
||||
const customerMonthCost = number(row?.customerCost);
|
||||
const companyMonthCost = number(row?.companyCost);
|
||||
const otherMonthCost = number(row?.otherCost);
|
||||
return {
|
||||
month: key,
|
||||
totalKg: number(row?.totalKg, 3),
|
||||
lingniuKg: number(row?.lingniuKg, 3),
|
||||
externalKg: number(row?.externalKg, 3),
|
||||
cost,
|
||||
revenue,
|
||||
customerRevenue: customerMonthRevenue,
|
||||
customerCost: customerMonthCost,
|
||||
companyCost: companyMonthCost,
|
||||
otherCost: otherMonthCost,
|
||||
customerGrossProfit: number(customerMonthRevenue - customerMonthCost),
|
||||
};
|
||||
});
|
||||
const stations = stationRows[0].map((row) => ({
|
||||
id: String(row.id),
|
||||
name: String(row.name),
|
||||
province: String(row.province),
|
||||
city: String(row.city),
|
||||
kg: number(row.kg, 3),
|
||||
lingniuKg: number(row.lingniuKg, 3),
|
||||
externalKg: number(row.externalKg, 3),
|
||||
cost: number(row.cost),
|
||||
revenue: number(row.revenue),
|
||||
customerRevenue: number(row.customerRevenue),
|
||||
customerCost: number(row.customerCost),
|
||||
companyCost: number(row.companyCost),
|
||||
otherCost: number(row.otherCost),
|
||||
recordCount: Number(row.recordCount) || 0,
|
||||
customerCount: Number(row.customerCount) || 0,
|
||||
share: totalKg ? number((Number(row.kg) / totalKg) * 100, 2) : 0,
|
||||
}));
|
||||
// The station aggregation above already joins the district dimension. Reusing
|
||||
// it avoids a second full-ledger scan with a correlated subquery for regions.
|
||||
const regionMap = new Map<string, number>();
|
||||
for (const station of stations) {
|
||||
const rawRegion =
|
||||
filter.regionGranularity === "province" ? station.province : station.city;
|
||||
const region =
|
||||
rawRegion === "未归属"
|
||||
? filter.regionGranularity === "province"
|
||||
? "未归属省份"
|
||||
: "未归属区域"
|
||||
: rawRegion;
|
||||
regionMap.set(region, (regionMap.get(region) || 0) + station.kg);
|
||||
}
|
||||
const regionSum = [...regionMap.values()].reduce((sum, kg) => sum + kg, 0);
|
||||
const regions = [...regionMap.entries()]
|
||||
.map(([region, kg]) => ({
|
||||
region,
|
||||
kg: number(kg, 3),
|
||||
share: regionSum ? number((kg / regionSum) * 100, 2) : 0,
|
||||
}))
|
||||
.sort((left, right) => right.kg - left.kg);
|
||||
return {
|
||||
range: { startDate: filter.startDate, endDate: filter.endDate },
|
||||
watermark: { ledgerAt: formatWatermark(summary.ledgerAt), paymentAt: null },
|
||||
filters: filterContext(filter),
|
||||
kpis: {
|
||||
totalKg,
|
||||
totalCost: number(summary.totalCost),
|
||||
customerBearingKg: number(summary.customerBearingKg, 3),
|
||||
companyBearingKg: number(summary.companyBearingKg, 3),
|
||||
otherBearingKg: number(summary.otherBearingKg, 3),
|
||||
totalRevenue,
|
||||
customerRevenue,
|
||||
customerCost,
|
||||
companyCost: number(summary.companyCost),
|
||||
otherCost: number(summary.otherCost),
|
||||
customerGrossProfit: number(customerRevenue - customerCost),
|
||||
monthKg: number(summary.monthKg, 3),
|
||||
monthCost: number(summary.monthCost),
|
||||
monthShareOfRange: totalKg
|
||||
? number((number(summary.monthKg, 3) / totalKg) * 100, 2)
|
||||
: 0,
|
||||
todayKg: number(summary.todayKg, 3),
|
||||
todayCost: number(summary.todayCost),
|
||||
month: filter.endDate.slice(0, 7),
|
||||
todayShareOfMonth: number(summary.monthKg, 3)
|
||||
? number(
|
||||
(number(summary.todayKg, 3) / number(summary.monthKg, 3)) * 100,
|
||||
2,
|
||||
)
|
||||
: 0,
|
||||
recordCount: Number(summary.recordCount) || 0,
|
||||
stationCount: Number(summary.stationCount) || 0,
|
||||
},
|
||||
monthly,
|
||||
topStations: stations.slice(0, 5),
|
||||
stations,
|
||||
regions,
|
||||
customers: customerRows[0].map((row) => {
|
||||
const customerBearingKg = number(row.customerBearingKg, 3);
|
||||
const companyBearingKg = number(row.companyBearingKg, 3);
|
||||
const otherBearingKg = number(row.otherBearingKg, 3);
|
||||
return {
|
||||
id: Number(row.id) || 0,
|
||||
name: String(row.name),
|
||||
kg: number(row.kg, 3),
|
||||
customerBearingKg,
|
||||
companyBearingKg,
|
||||
otherBearingKg,
|
||||
bearer:
|
||||
otherBearingKg > 0
|
||||
? "other"
|
||||
: customerBearingKg > 0 && companyBearingKg > 0
|
||||
? "both"
|
||||
: customerBearingKg > 0
|
||||
? "customer"
|
||||
: "company",
|
||||
cost: number(row.cost),
|
||||
revenue: number(row.revenue),
|
||||
customerRevenue: number(row.customerRevenue),
|
||||
customerCost: number(row.customerCost),
|
||||
companyCost: number(row.companyCost),
|
||||
otherCost: number(row.otherCost),
|
||||
recordCount: Number(row.recordCount) || 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function daily(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const [rows, watermarkRows] = await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date`,
|
||||
filter.params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS ledgerAt
|
||||
FROM ${HYDROGEN_TABLE} b WHERE ${sqlWhere}`,
|
||||
filter.params,
|
||||
),
|
||||
]);
|
||||
const map = new Map(rows[0].map((row) => [String(row.date), row]));
|
||||
let previousKg = 0;
|
||||
const days = dateKeys(filter.startDate, filter.endDate).map((date) => {
|
||||
const row = map.get(date);
|
||||
const kg = number(row?.kg, 3);
|
||||
const chainPct = previousKg
|
||||
? number(((kg - previousKg) / previousKg) * 100, 2)
|
||||
: 0;
|
||||
previousKg = kg;
|
||||
return {
|
||||
date,
|
||||
kg,
|
||||
lingniuKg: number(row?.lingniuKg, 3),
|
||||
externalKg: number(row?.externalKg, 3),
|
||||
cost: number(row?.cost),
|
||||
recordCount: Number(row?.recordCount) || 0,
|
||||
stationCount: Number(row?.stationCount) || 0,
|
||||
chainPct,
|
||||
};
|
||||
});
|
||||
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
|
||||
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
|
||||
return {
|
||||
range: { startDate: filter.startDate, endDate: filter.endDate },
|
||||
watermark: {
|
||||
ledgerAt: formatWatermark(watermarkRows[0][0]?.ledgerAt),
|
||||
paymentAt: null,
|
||||
},
|
||||
filters: filterContext(filter),
|
||||
kpis: {
|
||||
totalKg: number(totalKg, 3),
|
||||
totalCost: number(totalCost),
|
||||
averageDailyKg: number(totalKg / Math.max(1, days.length), 3),
|
||||
stationCount: Math.max(0, ...days.map((row) => row.stationCount)),
|
||||
activeDays: days.filter((row) => row.kg > 0).length,
|
||||
},
|
||||
trend: days,
|
||||
days: [...days].reverse(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prototype daily detail is a real hierarchy, not a flattened table: one
|
||||
* business day expands into station, customer and then the existing record
|
||||
* drill. Records remain paged through /drill to keep a busy day bounded.
|
||||
*/
|
||||
async function dailyTree(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const [stationRows, customerRows] = await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id,
|
||||
COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.station_id, 0)
|
||||
ORDER BY kg DESC`,
|
||||
filter.params,
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS stationId,
|
||||
COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${sqlWhere}
|
||||
GROUP BY COALESCE(b.station_id, 0), COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
||||
ORDER BY stationId, kg DESC`,
|
||||
filter.params,
|
||||
),
|
||||
]);
|
||||
const customersByStation = new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
cost: number;
|
||||
recordCount: number;
|
||||
}>
|
||||
>();
|
||||
customerRows[0].forEach((row) => {
|
||||
const stationId = String(row.stationId);
|
||||
const list = customersByStation.get(stationId) || [];
|
||||
list.push({
|
||||
id: Number(row.id) || 0,
|
||||
name: String(row.name),
|
||||
kg: number(row.kg, 3),
|
||||
cost: number(row.cost),
|
||||
recordCount: Number(row.recordCount) || 0,
|
||||
});
|
||||
customersByStation.set(stationId, list);
|
||||
});
|
||||
return {
|
||||
date: filter.startDate,
|
||||
stations: stationRows[0].map((row) => ({
|
||||
id: String(row.id),
|
||||
name: String(row.name),
|
||||
kg: number(row.kg, 3),
|
||||
cost: number(row.cost),
|
||||
recordCount: Number(row.recordCount) || 0,
|
||||
customers: customersByStation.get(String(row.id)) || [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function drill(
|
||||
hydrogenPool: HydrogenBiV2Dependencies["hydrogenPool"],
|
||||
filter: Filter,
|
||||
groupBy: GroupBy,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
amountScope: AmountScope,
|
||||
) {
|
||||
const sqlWhere = where(filter);
|
||||
const scopedWhere =
|
||||
amountScope === "customer"
|
||||
? `${sqlWhere} AND ${CUSTOMER_BEARING_ORDER}`
|
||||
: amountScope === "company"
|
||||
? `${sqlWhere} AND ${COMPANY_BEARING_ORDER}`
|
||||
: amountScope === "other"
|
||||
? `${sqlWhere} AND ${OTHER_BEARING_ORDER}`
|
||||
: sqlWhere;
|
||||
const params = filter.params;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const groupSelect =
|
||||
groupBy === "station"
|
||||
? "CAST(MAX(COALESCE(b.station_id, 0)) AS CHAR) AS id, COALESCE(MAX(NULLIF(s.site_short_name, '')), MAX(NULLIF(s.site_name, '')), MAX(NULLIF(b.station_name, '')), '未关联站点') AS name, COALESCE(MAX(NULLIF(p.NAME, '')), '未归属') AS province, COALESCE(MAX(NULLIF(ct.NAME, '')), '未归属') AS city"
|
||||
: 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"
|
||||
: groupBy === "date"
|
||||
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS name, NULL AS province, NULL AS city`
|
||||
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id, COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name, NULL AS province, NULL AS city";
|
||||
const groupExpression =
|
||||
groupBy === "station"
|
||||
? "COALESCE(b.station_id, 0)"
|
||||
: groupBy === "customer"
|
||||
? "COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')"
|
||||
: groupBy === "date"
|
||||
? `DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')`
|
||||
: "COALESCE(NULLIF(b.license_plate, ''), '无车牌')";
|
||||
const groupOrder = groupBy === "date" ? "id DESC" : "kg DESC";
|
||||
const [summaryRows, groupRows, recordRows] = await Promise.all([
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) AS customerCount,
|
||||
SUM(CASE WHEN NULLIF(b.record_source, '') IS NOT NULL OR b.source IS NOT NULL THEN 1 ELSE 0 END) AS traceableRecordCount,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost, ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
|
||||
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}`,
|
||||
params,
|
||||
),
|
||||
groupBy === "record"
|
||||
? Promise.resolve([[] as RowDataPacket[]])
|
||||
: hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT ${groupSelect},
|
||||
COUNT(*) AS recordCount, COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||
COUNT(DISTINCT COALESCE(b.system_customer_id, b.customer_id, 0)) 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(b.fee_total), 0), 2) AS revenue,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NOT NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS lingniuKg,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.amount_kg ELSE 0 END), 0), 3) AS externalKg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN new_hydrogen_site s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN common_district p ON CONVERT(p.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci AND p.STATUS = 'VALID'
|
||||
LEFT JOIN common_district ct ON CONVERT(ct.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci = CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci AND ct.STATUS = 'VALID'
|
||||
WHERE ${scopedWhere}
|
||||
GROUP BY ${groupExpression}
|
||||
ORDER BY ${groupOrder} LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
),
|
||||
hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.id, DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS time, b.order_no AS orderNo,
|
||||
CAST(COALESCE(b.station_id, 0) AS CHAR) AS stationId, COALESCE(NULLIF(b.station_name, ''), '未关联站点') AS stationName,
|
||||
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, COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
|
||||
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus, b.vehicle_id AS vehicleId,
|
||||
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg, ROUND(COALESCE(b.cost_price, 0), 2) AS unitPrice,
|
||||
ROUND(COALESCE(b.cost_total, 0), 2) AS cost, ROUND(COALESCE(b.fee_total, 0), 2) AS revenue
|
||||
FROM ${HYDROGEN_TABLE} b WHERE ${scopedWhere}
|
||||
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
),
|
||||
]);
|
||||
const summary = summaryRows[0][0] ?? {};
|
||||
return {
|
||||
groupBy,
|
||||
amountScope,
|
||||
filters: filterContext(filter),
|
||||
summary: {
|
||||
recordCount: Number(summary.recordCount) || 0,
|
||||
stationCount: Number(summary.stationCount) || 0,
|
||||
customerCount: Number(summary.customerCount) || 0,
|
||||
traceableRecordCount: Number(summary.traceableRecordCount) || 0,
|
||||
kg: number(summary.kg, 3),
|
||||
cost: number(summary.cost),
|
||||
revenue: number(summary.revenue),
|
||||
},
|
||||
groups: groupRows[0].map((row) => ({
|
||||
id: String(row.id),
|
||||
name: String(row.name),
|
||||
province: row.province ? String(row.province) : null,
|
||||
city: row.city ? String(row.city) : null,
|
||||
recordCount: Number(row.recordCount) || 0,
|
||||
stationCount: Number(row.stationCount) || 0,
|
||||
customerCount: Number(row.customerCount) || 0,
|
||||
kg: number(row.kg, 3),
|
||||
cost: number(row.cost),
|
||||
revenue: number(row.revenue),
|
||||
lingniuKg: number(row.lingniuKg, 3),
|
||||
externalKg: number(row.externalKg, 3),
|
||||
})),
|
||||
records: recordRows[0].map((row) => ({
|
||||
id: String(row.id),
|
||||
time: String(row.time),
|
||||
orderNo: String(row.orderNo || ""),
|
||||
stationId: String(row.stationId),
|
||||
stationName: String(row.stationName),
|
||||
customerId: Number(row.customerId) || 0,
|
||||
customerName: String(row.customerName),
|
||||
plateNo: String(row.plateNo),
|
||||
source: String(row.source),
|
||||
verifyStatus: String(row.verifyStatus),
|
||||
vehicleScope:
|
||||
row.vehicleId === null || row.vehicleId === undefined
|
||||
? "external"
|
||||
: "lingniu",
|
||||
kg: number(row.kg, 3),
|
||||
unitPrice: number(row.unitPrice),
|
||||
cost: number(row.cost),
|
||||
revenue: number(row.revenue),
|
||||
})),
|
||||
page: { page, pageSize, hasMore: recordRows[0].length === pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
export function registerHydrogenBiV2Routes(
|
||||
app: Hono,
|
||||
{ hydrogenPool }: HydrogenBiV2Dependencies,
|
||||
) {
|
||||
app.get("/h2/v2/meta", async (c) => c.json(await meta(hydrogenPool)));
|
||||
app.get("/h2/v2/overview", async (c) =>
|
||||
c.json(
|
||||
await overview(
|
||||
hydrogenPool,
|
||||
resolveFilter((key) => c.req.query(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
app.get("/h2/v2/daily", async (c) =>
|
||||
c.json(
|
||||
await daily(
|
||||
hydrogenPool,
|
||||
resolveFilter((key) => c.req.query(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
app.get("/h2/v2/daily-tree", async (c) => {
|
||||
const date = c.req.query("date");
|
||||
if (!validDate(date))
|
||||
return c.json({ message: "date 必须为有效的 YYYY-MM-DD" }, 400);
|
||||
const filter = resolveFilter((key) =>
|
||||
key === "date" ? date : c.req.query(key),
|
||||
);
|
||||
return c.json(await dailyTree(hydrogenPool, filter));
|
||||
});
|
||||
app.get("/h2/v2/drill", async (c) => {
|
||||
const groupBy: GroupBy = [
|
||||
"station",
|
||||
"customer",
|
||||
"date",
|
||||
"vehicle",
|
||||
"record",
|
||||
].includes(c.req.query("groupBy") ?? "")
|
||||
? (c.req.query("groupBy") as GroupBy)
|
||||
: "station";
|
||||
const requestedPage = Number(c.req.query("page"));
|
||||
const page =
|
||||
Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
||||
const requestedSize = Number(c.req.query("pageSize"));
|
||||
const pageSize =
|
||||
Number.isInteger(requestedSize) && requestedSize > 0
|
||||
? Math.min(requestedSize, 200)
|
||||
: 100;
|
||||
return c.json(
|
||||
await drill(
|
||||
hydrogenPool,
|
||||
resolveFilter((key) => c.req.query(key)),
|
||||
groupBy,
|
||||
page,
|
||||
pageSize,
|
||||
resolveAmountScope(c.req.query("amountScope")),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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 { customerClause, type CustomerKind } from './query-model.js';
|
||||
|
||||
export interface HydrogenDailyDetailDependencies {
|
||||
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
||||
cached: typeof cached;
|
||||
}
|
||||
|
||||
const YMD = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function registerHydrogenDailyDetailRoute(
|
||||
app: Hono,
|
||||
{ hydrogenPool, cached }: HydrogenDailyDetailDependencies,
|
||||
) {
|
||||
app.get('/hydrogen/daily-detail', async (c) => {
|
||||
const date = c.req.query('date') || '';
|
||||
if (!YMD.test(date)) return c.json({ error: 'date must be YYYY-MM-DD' }, 400);
|
||||
const customer = (c.req.query('customer') || 'all') as CustomerKind;
|
||||
const verifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
|
||||
const force = c.req.query('force') === '1';
|
||||
const stationValue = Number(c.req.query('stationId'));
|
||||
const stationId = Number.isInteger(stationValue) && stationValue > 0 ? stationValue : null;
|
||||
const stationClause = stationId ? ' AND b.station_id = ?' : '';
|
||||
const params: unknown[] = [date, ...(stationId ? [stationId] : [])];
|
||||
|
||||
const data = await cached(
|
||||
`hydrogen/daily-detail?date=${date}&customer=${customer}${stationId ? `&station=${stationId}` : ''}${verifyScope === 'verified' ? '&verify=verified' : ''}`,
|
||||
async () => {
|
||||
const [rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.id,
|
||||
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%H:%i') AS time,
|
||||
COALESCE(b.station_id, 0) AS stationId,
|
||||
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS stationName,
|
||||
COALESCE(s.station_type, 'unknown') AS stationType,
|
||||
COALESCE(b.system_customer_id, b.customer_id, 0) AS customerId,
|
||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS customerName,
|
||||
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS plateNo,
|
||||
b.vehicle_id AS vehicleId,
|
||||
COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
|
||||
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus,
|
||||
ROUND(COALESCE(b.cost_price, 0), 2) AS unitPrice,
|
||||
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg,
|
||||
ROUND(COALESCE(b.cost_total, 0), 2) AS fee
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND DATE(b.${HYDROGEN_LOCAL}) = ?
|
||||
AND ${customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id')}${verifyScope === 'verified' ? " AND LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'" : ''}${stationClause}
|
||||
ORDER BY b.${HYDROGEN_LOCAL} ASC, b.id ASC
|
||||
LIMIT 3000`,
|
||||
params,
|
||||
);
|
||||
|
||||
type Vehicle = {
|
||||
id: string;
|
||||
time: string;
|
||||
plateNo: string;
|
||||
vehicleScope: 'lingniu' | 'external';
|
||||
source: string;
|
||||
verifyStatus: string;
|
||||
unitPrice: number;
|
||||
kg: number;
|
||||
fee: number;
|
||||
};
|
||||
type Customer = { id: number; name: string; kg: number; fee: number; vehicles: Vehicle[] };
|
||||
type Station = { id: number; name: string; stationType: string; kg: number; fee: number; customers: Map<number, Customer> };
|
||||
const stations = new Map<number, Station>();
|
||||
|
||||
for (const row of rows) {
|
||||
const currentStationId = Number(row.stationId) || 0;
|
||||
const currentCustomerId = Number(row.customerId) || 0;
|
||||
let station = stations.get(currentStationId);
|
||||
if (!station) {
|
||||
station = { id: currentStationId, name: String(row.stationName), stationType: String(row.stationType), kg: 0, fee: 0, customers: new Map() };
|
||||
stations.set(currentStationId, station);
|
||||
}
|
||||
let customerRow = station.customers.get(currentCustomerId);
|
||||
if (!customerRow) {
|
||||
customerRow = { id: currentCustomerId, name: String(row.customerName), kg: 0, fee: 0, vehicles: [] };
|
||||
station.customers.set(currentCustomerId, customerRow);
|
||||
}
|
||||
const kg = Number(row.kg) || 0;
|
||||
const fee = Number(row.fee) || 0;
|
||||
station.kg += kg;
|
||||
station.fee += fee;
|
||||
customerRow.kg += kg;
|
||||
customerRow.fee += fee;
|
||||
customerRow.vehicles.push({
|
||||
// Ledger ids can exceed Number.MAX_SAFE_INTEGER. Keep the database
|
||||
// identifier lossless so the final vehicle/source drill-down has
|
||||
// stable React keys.
|
||||
id: String(row.id),
|
||||
time: String(row.time),
|
||||
plateNo: String(row.plateNo),
|
||||
vehicleScope: row.vehicleId === null || row.vehicleId === undefined ? 'external' : 'lingniu',
|
||||
source: String(row.source),
|
||||
verifyStatus: String(row.verifyStatus),
|
||||
unitPrice: Number(row.unitPrice) || 0,
|
||||
kg,
|
||||
fee,
|
||||
});
|
||||
}
|
||||
|
||||
// `new_hydrogen_site_balance_record` is the only persisted station-balance
|
||||
// source. It is intentionally queried separately so a missing balance record
|
||||
// stays null instead of becoming a fabricated zero balance.
|
||||
const stationIds = [...stations.keys()].filter(id => id > 0);
|
||||
const balances = new Map<number, { amount: number; effectiveTime: string | null }>();
|
||||
if (stationIds.length > 0) {
|
||||
const placeholders = stationIds.map(() => '?').join(', ');
|
||||
const [balanceRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT r.site_id AS stationId,
|
||||
r.ending_balance AS amount,
|
||||
DATE_FORMAT(r.effective_time, '%Y-%m-%d %H:%i:%s') AS effectiveTime
|
||||
FROM new_hydrogen_site_balance_record r
|
||||
INNER JOIN (
|
||||
SELECT site_id, MAX(effective_time) AS maxEffectiveTime
|
||||
FROM new_hydrogen_site_balance_record
|
||||
WHERE del_flag = '0' AND effective_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
AND site_id IN (${placeholders})
|
||||
GROUP BY site_id
|
||||
) latest ON latest.site_id = r.site_id AND latest.maxEffectiveTime = r.effective_time
|
||||
WHERE r.del_flag = '0'`,
|
||||
[date, ...stationIds],
|
||||
);
|
||||
for (const row of balanceRows) {
|
||||
balances.set(Number(row.stationId), {
|
||||
amount: Number(row.amount),
|
||||
effectiveTime: row.effectiveTime === null || row.effectiveTime === undefined ? null : String(row.effectiveTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
recordCount: rows.length,
|
||||
stations: [...stations.values()].map(station => ({
|
||||
...station,
|
||||
balance: balances.get(station.id)?.amount ?? null,
|
||||
balanceEffectiveTime: balances.get(station.id)?.effectiveTime ?? null,
|
||||
customers: [...station.customers.values()],
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ force },
|
||||
);
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
@@ -31,20 +31,24 @@ export function registerHydrogenDailyRoute(
|
||||
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}`, async () => {
|
||||
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,
|
||||
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
|
||||
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
|
||||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||||
customerClause(customer).replaceAll('vehicle_id', 'b.vehicle_id'),
|
||||
...(verifyScope === 'verified'
|
||||
? ["LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'"]
|
||||
: []),
|
||||
].join(' AND ');
|
||||
|
||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||||
// 单价不重算:直接取账本成本价。
|
||||
// 单价不重算:直接取账本成本价;费用直接累计账本成本总额。
|
||||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||||
COALESCE(b.station_id, 0) AS stationId,
|
||||
@@ -52,6 +56,7 @@ export function registerHydrogenDailyRoute(
|
||||
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
|
||||
@@ -64,21 +69,30 @@ export function registerHydrogenDailyRoute(
|
||||
|
||||
// 站点环比:同站点上一条记录的 kg
|
||||
// 按 stationId 分组、按日期升序计算
|
||||
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
|
||||
type StationRow = {
|
||||
date: string;
|
||||
stationId: number;
|
||||
name: string;
|
||||
kg: number;
|
||||
fee: number;
|
||||
pricePerKg: number;
|
||||
};
|
||||
const flat: StationRow[] = stationRows.map(r => ({
|
||||
date: r.d as string,
|
||||
stationId: Number(r.stationId),
|
||||
name: r.stationName as string,
|
||||
kg: Number(r.kg) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
pricePerKg: Number(r.pricePerKg) || 0,
|
||||
}));
|
||||
|
||||
// 计算日级总量 + 日级环比
|
||||
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
|
||||
const dayMap = new Map<string, { totalKg: number; totalFee: number; stations: typeof flat }>();
|
||||
for (const s of flat) {
|
||||
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
|
||||
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
|
||||
@@ -115,13 +129,16 @@ export function registerHydrogenDailyRoute(
|
||||
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 => ({
|
||||
? 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,0 +1,271 @@
|
||||
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';
|
||||
|
||||
export interface HydrogenOverviewDetailDependencies {
|
||||
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
||||
cached: typeof cached;
|
||||
}
|
||||
|
||||
type VehicleScope = 'all' | 'lingniu' | 'external';
|
||||
type VerifyScope = 'all' | 'verified';
|
||||
type GroupBy = 'station' | 'customer' | 'vehicle';
|
||||
|
||||
export function registerHydrogenOverviewDetailRoute(
|
||||
app: Hono,
|
||||
{ hydrogenPool, cached }: HydrogenOverviewDetailDependencies,
|
||||
) {
|
||||
app.get('/hydrogen/overview-detail', async (c) => {
|
||||
const requestedYear = Number(c.req.query('year'));
|
||||
const year = Number.isInteger(requestedYear) && requestedYear >= 2020 && requestedYear <= 2100
|
||||
? requestedYear
|
||||
: new Date().getFullYear();
|
||||
const scopeParam = c.req.query('vehicleScope');
|
||||
const vehicleScope: VehicleScope = scopeParam === 'lingniu' || scopeParam === 'external' ? scopeParam : 'all';
|
||||
const verifyScope: VerifyScope = c.req.query('verifyScope') === 'verified' ? 'verified' : 'all';
|
||||
const stationValue = Number(c.req.query('stationId'));
|
||||
const stationId = Number.isInteger(stationValue) && stationValue > 0 ? stationValue : null;
|
||||
const month = /^\d{4}-\d{2}$/.test(c.req.query('month') || '') ? c.req.query('month')! : null;
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/.test(c.req.query('date') || '') ? c.req.query('date')! : null;
|
||||
const customerName = (c.req.query('customerName') || '').trim().slice(0, 128) || null;
|
||||
const customerValue = Number(c.req.query('customerId'));
|
||||
const customerId = Number.isInteger(customerValue) && customerValue >= 0 ? customerValue : null;
|
||||
const plateNo = (c.req.query('plateNo') || '').trim().slice(0, 32) || null;
|
||||
const region = (c.req.query('region') || '').trim().slice(0, 64) || null;
|
||||
const regionGranularity = c.req.query('regionGranularity') === 'city' ? 'city' : 'province';
|
||||
const groupByParam = c.req.query('groupBy');
|
||||
const groupBy: GroupBy | null = groupByParam === 'station' || groupByParam === 'customer' || groupByParam === 'vehicle' ? groupByParam : null;
|
||||
const requestedLimit = Number(c.req.query('limit'));
|
||||
// 普通下钻避免一次返回过多账本;导出明确申请时,仍限制在可控范围。
|
||||
const recordLimit = Number.isInteger(requestedLimit) && requestedLimit > 0
|
||||
? Math.min(requestedLimit, 20_000)
|
||||
: 2_000;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const clauses = [HYDROGEN_BASE_WHERE_B, `b.${HYDROGEN_LOCAL} >= ?`, `YEAR(b.${HYDROGEN_LOCAL}) = ?`];
|
||||
const params: unknown[] = [HYDROGEN_MIN_DATE, year];
|
||||
if (vehicleScope === 'lingniu') clauses.push('b.vehicle_id IS NOT NULL');
|
||||
if (vehicleScope === 'external') clauses.push('b.vehicle_id IS NULL');
|
||||
if (verifyScope === 'verified') {
|
||||
clauses.push("LOWER(COALESCE(NULLIF(TRIM(b.verify_status), ''), 'unverified')) = 'verified'");
|
||||
}
|
||||
if (stationId) { clauses.push('b.station_id = ?'); params.push(stationId); }
|
||||
if (month) { clauses.push(`DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m') = ?`); params.push(month); }
|
||||
if (date) { clauses.push(`DATE(b.${HYDROGEN_LOCAL}) = ?`); params.push(date); }
|
||||
if (customerName) {
|
||||
clauses.push(`COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') = ?`);
|
||||
params.push(customerName);
|
||||
}
|
||||
if (customerId !== null) {
|
||||
clauses.push('COALESCE(b.system_customer_id, b.customer_id, 0) = ?');
|
||||
params.push(customerId);
|
||||
}
|
||||
if (plateNo) {
|
||||
clauses.push(`COALESCE(NULLIF(b.license_plate, ''), '无车牌') = ?`);
|
||||
params.push(plateNo);
|
||||
}
|
||||
if (region) {
|
||||
clauses.push(regionGranularity === 'city'
|
||||
? `COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') = ?`
|
||||
: `COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') = ?`);
|
||||
params.push(region);
|
||||
}
|
||||
const cacheKey = [
|
||||
`hydrogen/overview-detail?year=${year}`,
|
||||
`vehicle=${vehicleScope}`,
|
||||
verifyScope !== 'all' ? `verify=${verifyScope}` : '',
|
||||
stationId ? `station=${stationId}` : '',
|
||||
month ? `month=${month}` : '',
|
||||
date ? `date=${date}` : '',
|
||||
customerName ? `customer=${customerName}` : '',
|
||||
customerId !== null ? `customerId=${customerId}` : '',
|
||||
plateNo ? `plate=${plateNo}` : '',
|
||||
region ? `${regionGranularity}=${region}` : '',
|
||||
groupBy ? `groupBy=${groupBy}` : '',
|
||||
].filter(Boolean).join('&');
|
||||
|
||||
const data = await cached(cacheKey, async () => {
|
||||
const [summaryRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||
COUNT(DISTINCT CONCAT(
|
||||
COALESCE(b.system_customer_id, b.customer_id, 0), '|',
|
||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')
|
||||
)) AS customerCount,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN common_district dp
|
||||
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dp.STATUS = 'VALID'
|
||||
LEFT JOIN common_district dc
|
||||
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dc.STATUS = 'VALID'
|
||||
WHERE ${clauses.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
// 总览第一层先返回业务维度汇总;只有用户点选某一行时,才读取账本流水。
|
||||
const groupSelect = groupBy === 'station'
|
||||
? `COALESCE(b.station_id, 0) AS id,
|
||||
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS name,
|
||||
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
|
||||
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
|
||||
COUNT(*) AS recordCount,
|
||||
1 AS stationCount,
|
||||
COUNT(DISTINCT CONCAT(COALESCE(b.system_customer_id, b.customer_id, 0), '|', COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户'))) AS customerCount,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue`
|
||||
: groupBy === 'customer'
|
||||
? `COALESCE(b.system_customer_id, b.customer_id, 0) AS id,
|
||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS name,
|
||||
NULL AS province,
|
||||
NULL AS city,
|
||||
COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||
1 AS customerCount,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue`
|
||||
: `COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS id,
|
||||
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS name,
|
||||
NULL AS province,
|
||||
NULL AS city,
|
||||
COUNT(*) AS recordCount,
|
||||
COUNT(DISTINCT COALESCE(b.station_id, 0)) AS stationCount,
|
||||
COUNT(DISTINCT CONCAT(COALESCE(b.system_customer_id, b.customer_id, 0), '|', COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户'))) AS customerCount,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 3) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS cost,
|
||||
ROUND(COALESCE(SUM(CASE WHEN b.vehicle_id IS NULL THEN b.cost_total ELSE 0 END), 0), 2) AS customerCost,
|
||||
ROUND(COALESCE(SUM(b.fee_total), 0), 2) AS revenue,
|
||||
CASE WHEN MAX(CASE WHEN b.vehicle_id IS NULL THEN 0 ELSE 1 END) = 1 THEN 'lingniu' ELSE 'external' END AS vehicleScope,
|
||||
COALESCE(NULLIF(MAX(b.record_source), ''), CAST(MAX(b.source) AS CHAR), '未知来源') AS source,
|
||||
COALESCE(NULLIF(MAX(b.verify_status), ''), 'UNVERIFIED') AS verifyStatus`;
|
||||
const groupBySql = groupBy === 'station'
|
||||
? `COALESCE(b.station_id, 0), COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点'), COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属'), COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属')`
|
||||
: groupBy === 'customer'
|
||||
? `COALESCE(b.system_customer_id, b.customer_id, 0), COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户')`
|
||||
: `COALESCE(NULLIF(b.license_plate, ''), '无车牌')`;
|
||||
const [groupRows] = groupBy
|
||||
? await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT ${groupSelect}
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN common_district dp
|
||||
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dp.STATUS = 'VALID'
|
||||
LEFT JOIN common_district dc
|
||||
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dc.STATUS = 'VALID'
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
GROUP BY ${groupBySql}
|
||||
ORDER BY kg DESC, recordCount DESC
|
||||
LIMIT 200`,
|
||||
params,
|
||||
)
|
||||
: [[] as RowDataPacket[]];
|
||||
const [rows] = groupBy ? [[] as RowDataPacket[]] : await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.id,
|
||||
b.order_no AS orderNo,
|
||||
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS refuelTime,
|
||||
COALESCE(b.station_id, 0) AS stationId,
|
||||
COALESCE(s.station_short_name, s.station_name, b.station_name, '未关联站点') AS stationName,
|
||||
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
|
||||
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
|
||||
COALESCE(b.system_customer_id, b.customer_id, 0) AS customerId,
|
||||
COALESCE(NULLIF(b.system_customer_name, ''), NULLIF(b.customer_name, ''), '未关联客户') AS customerName,
|
||||
COALESCE(NULLIF(b.license_plate, ''), '无车牌') AS plateNo,
|
||||
b.vehicle_id AS vehicleId,
|
||||
COALESCE(NULLIF(b.record_source, ''), CAST(b.source AS CHAR), '未知来源') AS source,
|
||||
COALESCE(NULLIF(b.verify_status, ''), 'UNVERIFIED') AS verifyStatus,
|
||||
ROUND(COALESCE(b.amount_kg, 0), 3) AS kg,
|
||||
ROUND(COALESCE(b.cost_price, 0), 2) AS costPrice,
|
||||
ROUND(COALESCE(b.cost_total, 0), 2) AS cost,
|
||||
ROUND(COALESCE(b.customer_price, 0), 2) AS customerPrice,
|
||||
ROUND(COALESCE(b.fee_total, 0), 2) AS revenue,
|
||||
b.payment_status AS paymentStatus
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
LEFT JOIN common_district dp
|
||||
ON CONVERT(dp.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.province USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dp.STATUS = 'VALID'
|
||||
LEFT JOIN common_district dc
|
||||
ON CONVERT(dc.CODE USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
= CONVERT(s.city USING utf8mb4) COLLATE utf8mb4_unicode_ci
|
||||
AND dc.STATUS = 'VALID'
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY b.${HYDROGEN_LOCAL} DESC, b.id DESC
|
||||
LIMIT ${recordLimit}`,
|
||||
params,
|
||||
);
|
||||
const groups = groupRows.map(row => ({
|
||||
id: groupBy === 'vehicle' ? String(row.id || '无车牌') : Number(row.id) || 0,
|
||||
name: String(row.name || (groupBy === 'station' ? '未关联站点' : groupBy === 'customer' ? '未关联客户' : '无车牌')),
|
||||
province: row.province === null || row.province === undefined ? null : String(row.province),
|
||||
city: row.city === null || row.city === undefined ? null : String(row.city),
|
||||
recordCount: Number(row.recordCount) || 0,
|
||||
stationCount: Number(row.stationCount) || 0,
|
||||
customerCount: Number(row.customerCount) || 0,
|
||||
kg: Number(row.kg) || 0,
|
||||
cost: Number(row.cost) || 0,
|
||||
customerCost: Number(row.customerCost) || 0,
|
||||
revenue: Number(row.revenue) || 0,
|
||||
vehicleScope: row.vehicleScope === 'lingniu' ? 'lingniu' : row.vehicleScope === 'external' ? 'external' : undefined,
|
||||
source: row.source === null || row.source === undefined ? undefined : String(row.source),
|
||||
verifyStatus: row.verifyStatus === null || row.verifyStatus === undefined ? undefined : String(row.verifyStatus),
|
||||
}));
|
||||
const records = rows.map(row => ({
|
||||
id: Number(row.id),
|
||||
orderNo: String(row.orderNo || ''),
|
||||
refuelTime: String(row.refuelTime),
|
||||
stationId: Number(row.stationId) || 0,
|
||||
stationName: String(row.stationName),
|
||||
province: String(row.province),
|
||||
city: String(row.city),
|
||||
customerId: Number(row.customerId) || 0,
|
||||
customerName: String(row.customerName),
|
||||
plateNo: String(row.plateNo),
|
||||
vehicleScope: row.vehicleId === null || row.vehicleId === undefined ? 'external' as const : 'lingniu' as const,
|
||||
source: String(row.source),
|
||||
verifyStatus: String(row.verifyStatus),
|
||||
kg: Number(row.kg) || 0,
|
||||
costPrice: Number(row.costPrice) || 0,
|
||||
cost: Number(row.cost) || 0,
|
||||
customerPrice: Number(row.customerPrice) || 0,
|
||||
revenue: Number(row.revenue) || 0,
|
||||
paymentStatus: row.paymentStatus === null || row.paymentStatus === undefined ? null : Number(row.paymentStatus),
|
||||
}));
|
||||
const summaryRow = summaryRows[0] || {};
|
||||
const recordCount = Number(summaryRow.recordCount) || 0;
|
||||
return {
|
||||
filter: { year, vehicleScope, verifyScope, stationId, month, date, customerName, customerId, plateNo, region, regionGranularity, groupBy },
|
||||
groupBy,
|
||||
groups,
|
||||
truncated: !groupBy && recordCount > rows.length,
|
||||
summary: {
|
||||
recordCount,
|
||||
stationCount: Number(summaryRow.stationCount) || 0,
|
||||
customerCount: Number(summaryRow.customerCount) || 0,
|
||||
kg: Number(summaryRow.kg) || 0,
|
||||
cost: Number(summaryRow.cost) || 0,
|
||||
customerCost: Number(summaryRow.customerCost) || 0,
|
||||
revenue: Number(summaryRow.revenue) || 0,
|
||||
},
|
||||
records,
|
||||
};
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,68 @@ export interface HydrogenOverviewDependencies {
|
||||
cached: typeof cached;
|
||||
}
|
||||
|
||||
// 氢能总览:KPI + Top5 + 区域占比。
|
||||
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,
|
||||
@@ -23,26 +84,35 @@ export function registerHydrogenOverviewRoute(
|
||||
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}`, async () => {
|
||||
const data = await cached(`hydrogen/overview?year=${requestedYear}${filter.cacheKey}`, async () => {
|
||||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||||
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||||
`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} >= ?
|
||||
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 时取本月/今日)
|
||||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)。
|
||||
// yearProfit 是客户单毛利:仅以存在对客价/对客收入的订单成本抵扣对客收入。
|
||||
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
`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}) = ?
|
||||
@@ -80,13 +150,14 @@ 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} >= ?`,
|
||||
[year, year, year, year, year, year, year,
|
||||
WHERE ${HYDROGEN_BASE_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],
|
||||
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;
|
||||
@@ -128,15 +199,16 @@ export function registerHydrogenOverviewRoute(
|
||||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(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],
|
||||
[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,
|
||||
@@ -149,21 +221,28 @@ export function registerHydrogenOverviewRoute(
|
||||
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_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
|
||||
GROUP BY b.station_id
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
[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,
|
||||
@@ -174,6 +253,7 @@ export function registerHydrogenOverviewRoute(
|
||||
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT region, SUM(kg) AS kg FROM (
|
||||
SELECT CASE
|
||||
WHEN NULLIF(TRIM(d.NAME), '') IS NOT NULL THEN d.NAME
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
|
||||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
|
||||
@@ -181,18 +261,23 @@ export function registerHydrogenOverviewRoute(
|
||||
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 COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
|
||||
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_BASE_WHERE_B}
|
||||
AND b.${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?${filter.billSql}
|
||||
) r
|
||||
GROUP BY region
|
||||
ORDER BY kg DESC`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
[HYDROGEN_MIN_DATE, year, ...filter.params],
|
||||
);
|
||||
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
|
||||
const TOP_REGIONS = 8;
|
||||
@@ -212,61 +297,77 @@ export function registerHydrogenOverviewRoute(
|
||||
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||||
ROUND(SUM(amount_kg), 2) AS kg,
|
||||
ROUND(SUM(CASE WHEN vehicle_id IS NOT NULL THEN amount_kg ELSE 0 END), 2) AS lingniuKg,
|
||||
ROUND(SUM(CASE WHEN vehicle_id IS NULL THEN amount_kg ELSE 0 END), 2) AS externalKg,
|
||||
ROUND(SUM(cost_total), 2) AS fee,
|
||||
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
|
||||
ROUND(SUM(fee_total), 2) AS revenue
|
||||
FROM ${HYDROGEN_TABLE}
|
||||
WHERE ${HYDROGEN_BASE_WHERE}
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
|
||||
GROUP BY m
|
||||
ORDER BY m`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
[HYDROGEN_MIN_DATE, year, ...filter.params],
|
||||
);
|
||||
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
|
||||
const monthMap = new Map<string, { kg: number; lingniuKg: number; externalKg: number; fee: number; revenue: number; customerCost: number }>();
|
||||
for (const r of monthRows) {
|
||||
monthMap.set(r.m as string, {
|
||||
kg: Number(r.kg) || 0,
|
||||
lingniuKg: Number(r.lingniuKg) || 0,
|
||||
externalKg: Number(r.externalKg) || 0,
|
||||
fee: Number(r.fee) || 0,
|
||||
revenue: Number(r.revenue) || 0,
|
||||
customerCost: Number(r.customerCost) || 0,
|
||||
});
|
||||
}
|
||||
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
|
||||
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
|
||||
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, fee: 0, revenue: 0, customerCost: 0 };
|
||||
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
|
||||
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)
|
||||
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
||||
// 客户账单 Top(指定年份;按加氢量降序,前 30)。
|
||||
// 同一客户可能同时存在客户承担与羚牛承担订单,必须显示为混合,不能按任一条记录覆盖。
|
||||
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
|
||||
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
|
||||
ELSE 'customer' END AS payer,
|
||||
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_BASE_WHERE}
|
||||
AND ${HYDROGEN_LOCAL} >= ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?
|
||||
AND YEAR(${HYDROGEN_LOCAL}) = ?${filter.ledgerSql}
|
||||
GROUP BY name
|
||||
ORDER BY kg DESC
|
||||
LIMIT 30`,
|
||||
[HYDROGEN_MIN_DATE, year],
|
||||
[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 : 'customer' as const,
|
||||
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 };
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import type hydrogenPool from '../../hydrogen-db.js';
|
||||
import type { cached } from './cache.js';
|
||||
import { resolveDateRange, type EnergyDateRangeKind } from './query-model.js';
|
||||
|
||||
type SettlementRange = EnergyDateRangeKind | 'latest' | 'custom';
|
||||
|
||||
export interface HydrogenSettlementDependencies {
|
||||
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
||||
cached: typeof cached;
|
||||
}
|
||||
|
||||
function toDateString(value: unknown): string | null {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function minusDays(date: string, days: number): string {
|
||||
// 用 UTC 正午作为日历运算锚点,避免东八区转 ISO 日期时落到前一天。
|
||||
const result = new Date(`${date}T12:00:00Z`);
|
||||
result.setUTCDate(result.getUTCDate() - days);
|
||||
return result.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeMatchMode(value: unknown): 'exact' | 'manual' | 'group' | 'unmatched' {
|
||||
if (value === 'exact' || value === 'manual' || value === 'group') return value;
|
||||
return 'unmatched';
|
||||
}
|
||||
|
||||
// 站日现结只读台账:数据来自已入库的付款流水,不承担登记或审批职责。
|
||||
export function registerHydrogenSettlementRoute(
|
||||
app: Hono,
|
||||
{ hydrogenPool, cached }: HydrogenSettlementDependencies,
|
||||
) {
|
||||
app.get('/hydrogen/settlement', async (c) => {
|
||||
const requestedRange = (c.req.query('range') || 'latest') as SettlementRange;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const [latestRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
|
||||
FROM hydrogen_station_payment
|
||||
WHERE del_flag = '0'`,
|
||||
);
|
||||
const latestPaymentDate = toDateString(latestRows[0]?.latestPaymentDate);
|
||||
|
||||
const dateRange = requestedRange === 'latest'
|
||||
? latestPaymentDate
|
||||
? { start: minusDays(latestPaymentDate, 14), end: latestPaymentDate, mode: 'latest' as const }
|
||||
: { start: null, end: null, mode: 'latest' as const }
|
||||
: (() => {
|
||||
const resolved = resolveDateRange(
|
||||
requestedRange as EnergyDateRangeKind,
|
||||
c.req.query('startDate'),
|
||||
c.req.query('endDate'),
|
||||
);
|
||||
return { ...resolved, mode: requestedRange === 'custom' ? 'custom' as const : requestedRange };
|
||||
})();
|
||||
|
||||
if (!dateRange.start || !dateRange.end) {
|
||||
return c.json({
|
||||
range: dateRange,
|
||||
summary: { amount: 0, paymentCount: 0, stationDayCount: 0, stationCount: 0, latestPaymentDate },
|
||||
rows: [],
|
||||
});
|
||||
}
|
||||
|
||||
const data = await cached(
|
||||
`hydrogen/settlement?start=${dateRange.start}&end=${dateRange.end}`,
|
||||
async () => {
|
||||
const [rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(p.payment_date, '%Y-%m-%d') AS date,
|
||||
p.station_id AS stationId,
|
||||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(p.raw_station_name), '未匹配加氢站') AS stationName,
|
||||
ROUND(SUM(p.amount), 2) AS amount,
|
||||
COUNT(*) AS paymentCount,
|
||||
CASE
|
||||
WHEN SUM(p.match_mode = 'exact') = COUNT(*) THEN 'exact'
|
||||
WHEN SUM(p.match_mode = 'manual') = COUNT(*) THEN 'manual'
|
||||
WHEN SUM(p.match_mode = 'group') = COUNT(*) THEN 'group'
|
||||
ELSE 'unmatched'
|
||||
END AS matchMode
|
||||
FROM hydrogen_station_payment p
|
||||
LEFT JOIN hydrogen_station s ON s.id = p.station_id AND s.del_flag = '0'
|
||||
WHERE p.del_flag = '0'
|
||||
AND p.payment_date >= ?
|
||||
AND p.payment_date <= ?
|
||||
GROUP BY DATE_FORMAT(p.payment_date, '%Y-%m-%d'), p.station_id, p.raw_station_name
|
||||
ORDER BY date DESC, amount DESC
|
||||
LIMIT 500`,
|
||||
[dateRange.start, dateRange.end],
|
||||
);
|
||||
|
||||
const normalizedRows = rows.map(row => ({
|
||||
date: String(row.date),
|
||||
stationId: row.stationId === null || row.stationId === undefined ? null : Number(row.stationId),
|
||||
stationName: String(row.stationName),
|
||||
amount: Number(row.amount) || 0,
|
||||
paymentCount: Number(row.paymentCount) || 0,
|
||||
matchMode: normalizeMatchMode(row.matchMode),
|
||||
}));
|
||||
const stationIds = new Set(normalizedRows.map(row => row.stationId ?? `name:${row.stationName}`));
|
||||
return {
|
||||
range: dateRange,
|
||||
summary: {
|
||||
amount: normalizedRows.reduce((total, row) => total + row.amount, 0),
|
||||
paymentCount: normalizedRows.reduce((total, row) => total + row.paymentCount, 0),
|
||||
stationDayCount: normalizedRows.length,
|
||||
stationCount: stationIds.size,
|
||||
latestPaymentDate,
|
||||
},
|
||||
rows: normalizedRows,
|
||||
};
|
||||
},
|
||||
{ force },
|
||||
);
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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 { dateRangeClause, enumerateDateRange, resolveDateRange } from './query-model.js';
|
||||
|
||||
export interface HydrogenStationBoardDependencies {
|
||||
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
|
||||
cached: typeof cached;
|
||||
}
|
||||
|
||||
function safeStationId(value: string | undefined): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
// 单站经营看板只做只读聚合。站点列表保留区间内零业务站点,便于核对站点覆盖范围。
|
||||
export function registerHydrogenStationBoardRoute(
|
||||
app: Hono,
|
||||
{ hydrogenPool, cached }: HydrogenStationBoardDependencies,
|
||||
) {
|
||||
app.get('/hydrogen/station-board', async (c) => {
|
||||
const range = resolveDateRange(
|
||||
'last15',
|
||||
c.req.query('startDate'),
|
||||
c.req.query('endDate'),
|
||||
);
|
||||
const stationId = safeStationId(c.req.query('stationId'));
|
||||
const force = c.req.query('force') === '1';
|
||||
const cacheKey = `hydrogen/station-board?start=${range.start}&end=${range.end}${stationId ? `&station=${stationId}` : ''}`;
|
||||
|
||||
const data = await cached(cacheKey, async () => {
|
||||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT s.id,
|
||||
COALESCE(NULLIF(s.station_short_name, ''), NULLIF(s.station_name, ''), CONCAT('未知站点 #', s.id)) AS name,
|
||||
COALESCE(NULLIF(dp.NAME, ''), NULLIF(s.province, ''), '未归属') AS province,
|
||||
COALESCE(NULLIF(dc.NAME, ''), NULLIF(s.city, ''), '未归属') AS city,
|
||||
ROUND(COALESCE(SUM(b.amount_kg), 0), 2) AS kg,
|
||||
ROUND(COALESCE(SUM(b.cost_total), 0), 2) AS fee,
|
||||
COUNT(b.id) AS recordCount,
|
||||
DATE_FORMAT(MAX(b.${HYDROGEN_LOCAL}), '%Y-%m-%d %H:%i:%s') AS latestLedgerTime
|
||||
FROM hydrogen_station s
|
||||
LEFT JOIN ${HYDROGEN_TABLE} b
|
||||
ON b.station_id = s.id
|
||||
AND ${HYDROGEN_BASE_WHERE_B}
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
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 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],
|
||||
);
|
||||
|
||||
const [paymentRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT station_id AS stationId,
|
||||
ROUND(SUM(amount), 2) AS amount,
|
||||
COUNT(*) AS paymentCount,
|
||||
DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS latestPaymentDate
|
||||
FROM hydrogen_station_payment
|
||||
WHERE del_flag = '0'
|
||||
AND payment_date >= ?
|
||||
AND payment_date <= ?
|
||||
GROUP BY station_id`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
const [summaryDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(b.cost_total), 2) AS fee,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
AND ${dateRangeClause(`b.${HYDROGEN_LOCAL}`)}
|
||||
GROUP BY DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
const [stationDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT b.station_id AS stationId,
|
||||
DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
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],
|
||||
);
|
||||
const [summaryPaymentDailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(amount), 2) AS amount,
|
||||
COUNT(*) AS paymentCount
|
||||
FROM hydrogen_station_payment
|
||||
WHERE del_flag = '0'
|
||||
AND payment_date >= ?
|
||||
AND payment_date <= ?
|
||||
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[range.start, range.end],
|
||||
);
|
||||
const paymentByStation = new Map(paymentRows.map(row => [numberValue(row.stationId), row]));
|
||||
const dailyKgByStation = new Map<number, Map<string, number>>();
|
||||
for (const row of stationDailyRows) {
|
||||
const id = numberValue(row.stationId);
|
||||
const values = dailyKgByStation.get(id) ?? new Map<string, number>();
|
||||
values.set(String(row.date), numberValue(row.kg));
|
||||
dailyKgByStation.set(id, values);
|
||||
}
|
||||
const totalKg = stationRows.reduce((sum, row) => sum + numberValue(row.kg), 0);
|
||||
const stations = stationRows.map(row => {
|
||||
const id = numberValue(row.id);
|
||||
const payment = paymentByStation.get(id);
|
||||
return {
|
||||
id,
|
||||
name: String(row.name),
|
||||
province: String(row.province),
|
||||
city: String(row.city),
|
||||
kg: numberValue(row.kg),
|
||||
fee: numberValue(row.fee),
|
||||
recordCount: numberValue(row.recordCount),
|
||||
paymentAmount: numberValue(payment?.amount),
|
||||
paymentCount: numberValue(payment?.paymentCount),
|
||||
share: totalKg > 0 ? numberValue(row.kg) / totalKg : 0,
|
||||
latestLedgerTime: typeof row.latestLedgerTime === 'string' ? row.latestLedgerTime : null,
|
||||
latestPaymentDate: typeof payment?.latestPaymentDate === 'string' ? payment.latestPaymentDate : null,
|
||||
dailyKg: enumerateDateRange(range.start, range.end).map(date => ({
|
||||
date,
|
||||
kg: dailyKgByStation.get(id)?.get(date) ?? 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
let selected = null;
|
||||
if (stationId) {
|
||||
const [dailyRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(b.cost_total), 2) AS fee,
|
||||
ROUND(AVG(NULLIF(b.cost_price, 0)), 2) AS avgPrice,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
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],
|
||||
);
|
||||
const [dailyPaymentRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||||
`SELECT DATE_FORMAT(payment_date, '%Y-%m-%d') AS date,
|
||||
ROUND(SUM(amount), 2) AS amount,
|
||||
COUNT(*) AS paymentCount
|
||||
FROM hydrogen_station_payment
|
||||
WHERE del_flag = '0'
|
||||
AND station_id = ?
|
||||
AND payment_date >= ?
|
||||
AND payment_date <= ?
|
||||
GROUP BY DATE_FORMAT(payment_date, '%Y-%m-%d')
|
||||
ORDER BY date ASC`,
|
||||
[stationId, range.start, range.end],
|
||||
);
|
||||
const paymentByDate = new Map(dailyPaymentRows.map(row => [String(row.date), row]));
|
||||
const ledgerByDate = new Map(dailyRows.map(row => [String(row.date), row]));
|
||||
let previousKg = 0;
|
||||
const daily = enumerateDateRange(range.start, range.end).map(date => {
|
||||
const ledger = ledgerByDate.get(date);
|
||||
const kg = numberValue(ledger?.kg);
|
||||
const payment = paymentByDate.get(date);
|
||||
const result = {
|
||||
date,
|
||||
kg,
|
||||
fee: numberValue(ledger?.fee),
|
||||
avgPrice: numberValue(ledger?.avgPrice),
|
||||
recordCount: numberValue(ledger?.recordCount),
|
||||
changeKg: kg - previousKg,
|
||||
paymentAmount: numberValue(payment?.amount),
|
||||
paymentCount: numberValue(payment?.paymentCount),
|
||||
};
|
||||
previousKg = kg;
|
||||
return result;
|
||||
});
|
||||
|
||||
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,
|
||||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||||
ROUND(SUM(b.cost_total), 2) AS fee,
|
||||
COUNT(*) AS recordCount
|
||||
FROM ${HYDROGEN_TABLE} b
|
||||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||||
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)
|
||||
ORDER BY month ASC, kg DESC`,
|
||||
[stationId, range.end, range.end],
|
||||
);
|
||||
selected = {
|
||||
daily,
|
||||
customerMonths: customerMonthRows.map(row => ({
|
||||
month: String(row.month),
|
||||
customerName: String(row.customerName),
|
||||
kg: numberValue(row.kg),
|
||||
fee: numberValue(row.fee),
|
||||
recordCount: numberValue(row.recordCount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const paymentTotal = stations.reduce((sum, station) => sum + station.paymentAmount, 0);
|
||||
const summaryLedgerByDate = new Map(summaryDailyRows.map(row => [String(row.date), row]));
|
||||
const summaryPaymentByDate = new Map(summaryPaymentDailyRows.map(row => [String(row.date), row]));
|
||||
const summaryDaily = enumerateDateRange(range.start, range.end).map(date => {
|
||||
const ledger = summaryLedgerByDate.get(date);
|
||||
const payment = summaryPaymentByDate.get(date);
|
||||
return {
|
||||
date,
|
||||
kg: numberValue(ledger?.kg),
|
||||
fee: numberValue(ledger?.fee),
|
||||
recordCount: numberValue(ledger?.recordCount),
|
||||
paymentAmount: numberValue(payment?.amount),
|
||||
paymentCount: numberValue(payment?.paymentCount),
|
||||
};
|
||||
});
|
||||
const latestLedgerTime = stations
|
||||
.map(station => station.latestLedgerTime)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
return {
|
||||
range,
|
||||
summary: {
|
||||
stationCount: stations.length,
|
||||
activeStationCount: stations.filter(station => station.kg > 0).length,
|
||||
totalKg,
|
||||
totalFee: stations.reduce((sum, station) => sum + station.fee, 0),
|
||||
recordCount: stations.reduce((sum, station) => sum + station.recordCount, 0),
|
||||
paymentAmount: paymentTotal,
|
||||
paymentCount: stations.reduce((sum, station) => sum + station.paymentCount, 0),
|
||||
latestLedgerTime,
|
||||
daily: summaryDaily,
|
||||
},
|
||||
stations,
|
||||
selected,
|
||||
};
|
||||
}, { force });
|
||||
|
||||
return c.json(data);
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,14 @@ import {
|
||||
registerElectricMonthlyRoute,
|
||||
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';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -26,8 +32,14 @@ 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 });
|
||||
registerHydrogenStationBoardRoute(app, { ...cacheDependency, hydrogenPool });
|
||||
registerHydrogenBiV2Routes(app, { hydrogenPool });
|
||||
registerElectricOverviewRoute(app, { ...cacheDependency, pool });
|
||||
registerElectricMonthlyRoute(app, { ...cacheDependency, pool });
|
||||
registerEtcOverviewRoute(app, { ...cacheDependency, pool });
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
resolveDateRange,
|
||||
} from './query-model.js';
|
||||
|
||||
test('客户范围继续让外部车辆返回空数据', () => {
|
||||
assert.equal(customerClause('external'), '1=0');
|
||||
assert.equal(customerClause('lingniu'), '1=1');
|
||||
test('车辆归属按账本 vehicle_id 区分羚牛和外部车辆', () => {
|
||||
assert.equal(customerClause('external'), 'vehicle_id IS NULL');
|
||||
assert.equal(customerClause('lingniu'), 'vehicle_id IS NOT NULL');
|
||||
assert.equal(customerClause('all'), '1=1');
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ export interface EnergyDateRange {
|
||||
|
||||
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。
|
||||
// 车辆归属以账本 vehicle_id 为准,与氢能总览的筛选口径一致。
|
||||
// 账本未关联车辆的记录归入外部车辆,避免在日报中遗漏真实加氢数据。
|
||||
export function customerClause(customer: CustomerKind): string {
|
||||
if (customer === 'external') return '1=0';
|
||||
if (customer === 'lingniu') return '1=1';
|
||||
if (customer === 'external') return 'vehicle_id IS NULL';
|
||||
if (customer === 'lingniu') return 'vehicle_id IS NOT NULL';
|
||||
return '1=1';
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user