1179 lines
48 KiB
TypeScript
1179 lines
48 KiB
TypeScript
import { Hono } from 'hono';
|
||
import type { RowDataPacket } from 'mysql2';
|
||
import pool from '../../db.js';
|
||
import { getHydrogenPool } from '../../hydrogen-db.js';
|
||
import { cached } from './cache.js';
|
||
import {
|
||
parseHydrogenCustomerKind,
|
||
parseElectricVehicleScope,
|
||
parseHydrogenCustomerName,
|
||
parseHydrogenOrderLimit,
|
||
parseHydrogenOrderPage,
|
||
parseHydrogenStationId,
|
||
parseEnergyDate,
|
||
parseEtcLimit,
|
||
parseEtcPage,
|
||
parseEtcSearch,
|
||
type HydrogenCustomerKind,
|
||
} from './query.js';
|
||
import type { AuthUser } from '../../auth/types.js';
|
||
import { canAccessEnergy } from '../../auth/types.js';
|
||
import { observeDataSource } from '../../source-telemetry.js';
|
||
|
||
const app = new Hono();
|
||
|
||
// 模块级访问守卫:dev 旁路 auth 时 user 为 undefined,直接放行;
|
||
// 生产环境必须具备 BI-LEADER-ENERGY 或全量权限角色
|
||
app.use('*', async (c, next) => {
|
||
const user = (c as { get: (k: string) => unknown }).get('user') as AuthUser | undefined;
|
||
if (user && !canAccessEnergy(user.roles)) {
|
||
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
||
}
|
||
return next();
|
||
});
|
||
|
||
app.onError((error, c) => {
|
||
const sourceCode = typeof error === 'object' && error !== null && 'code' in error
|
||
? String((error as { code?: unknown }).code ?? 'UNKNOWN')
|
||
: 'UNKNOWN';
|
||
if (c.req.path.includes('/hydrogen/')) {
|
||
console.error('[energy/hydrogen] data source unavailable', { code: sourceCode });
|
||
return c.json({
|
||
error: '当前无法读取氢能业务数据,请稍后重试',
|
||
code: 'HYDROGEN_SOURCE_UNAVAILABLE',
|
||
retryable: true,
|
||
}, 503);
|
||
}
|
||
console.error('[energy] request failed', { path: c.req.path, code: sourceCode });
|
||
return c.json({
|
||
error: '能源数据请求失败,请稍后重试',
|
||
code: 'ENERGY_REQUEST_FAILED',
|
||
retryable: true,
|
||
}, 500);
|
||
});
|
||
|
||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||
|
||
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
||
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
|
||
const HYDROGEN_LOCAL = `refuel_time`;
|
||
const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
|
||
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
|
||
const ELECTRIC_LOCAL = `charging_start_time`;
|
||
|
||
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
|
||
function customerClause(customer: HydrogenCustomerKind): string {
|
||
if (customer === 'external') return '1=0';
|
||
if (customer === 'lingniu') return '1=1';
|
||
return '1=1';
|
||
}
|
||
|
||
type Range = 'thisWeek' | 'thisMonth' | 'last15';
|
||
interface DateRange {
|
||
start: string;
|
||
end: string;
|
||
}
|
||
|
||
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
function fmtYmd(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function addDays(d: Date, days: number): Date {
|
||
const next = new Date(d);
|
||
next.setDate(next.getDate() + days);
|
||
return next;
|
||
}
|
||
|
||
function parseYmd(value: string | undefined): string | null {
|
||
if (!value || !YMD_RE.test(value)) return null;
|
||
const d = new Date(`${value}T00:00:00`);
|
||
return Number.isNaN(d.getTime()) ? null : value;
|
||
}
|
||
|
||
function resolveDateRange(range: Range, startParam?: string, endParam?: string): DateRange {
|
||
const customStart = parseYmd(startParam);
|
||
const customEnd = parseYmd(endParam);
|
||
if (customStart && customEnd) {
|
||
return customStart <= customEnd
|
||
? { start: customStart, end: customEnd }
|
||
: { start: customEnd, end: customStart };
|
||
}
|
||
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
if (range === 'thisWeek') {
|
||
const day = today.getDay() || 7;
|
||
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
|
||
}
|
||
if (range === 'thisMonth') {
|
||
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
|
||
}
|
||
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
|
||
}
|
||
|
||
function dateRangeClause(localExpr: string): string {
|
||
return `${localExpr} >= ? AND ${localExpr} < DATE_ADD(?, INTERVAL 1 DAY)`;
|
||
}
|
||
|
||
function rangeClause(localExpr: string, range: Range): string {
|
||
switch (range) {
|
||
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
|
||
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
|
||
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
|
||
}
|
||
}
|
||
|
||
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
|
||
function enumerateDates(range: Range): string[] {
|
||
const { start, end } = resolveDateRange(range);
|
||
return enumerateDateRange(start, end);
|
||
}
|
||
|
||
function enumerateDateRange(startYmd: string, endYmd: string): string[] {
|
||
const result: string[] = [];
|
||
const cur = new Date(`${startYmd}T00:00:00`);
|
||
const end = new Date(`${endYmd}T00:00:00`);
|
||
while (cur <= end) {
|
||
result.push(fmtYmd(cur));
|
||
cur.setDate(cur.getDate() + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// =========================================================
|
||
// 氢能 总览:KPI + Top5 + 区域占比
|
||
// =========================================================
|
||
app.get('/hydrogen/overview', async (c) => {
|
||
const yearParam = c.req.query('year');
|
||
const force = c.req.query('force') === '1';
|
||
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 hydrogenPool = getHydrogenPool();
|
||
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
|
||
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
|
||
FROM ${HYDROGEN_TABLE}
|
||
WHERE ${HYDROGEN_BASE_WHERE} AND ${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);
|
||
const isCurrentYear = year === todayYear;
|
||
|
||
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
|
||
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||
THEN amount_kg ELSE 0 END) AS yearKg,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||
THEN cost_total ELSE 0 END) AS yearFee,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||
THEN cost_total ELSE 0 END) AS yearCustomerCost,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
|
||
THEN fee_total ELSE 0 END) AS yearRevenue,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||
THEN amount_kg ELSE 0 END) AS ourYearKg,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
|
||
THEN cost_total ELSE 0 END) AS ourYearFee,
|
||
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||
THEN amount_kg ELSE 0 END) AS customerYearKg,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN amount_kg ELSE 0 END) AS monthKg,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN cost_total ELSE 0 END) AS monthFee,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||
THEN cost_total ELSE 0 END) AS monthCustomerCost,
|
||
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN fee_total ELSE 0 END) AS monthRevenue,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||
THEN amount_kg ELSE 0 END) AS todayKg,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||
THEN cost_total ELSE 0 END) AS todayFee,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
|
||
THEN cost_total ELSE 0 END) AS todayCustomerCost,
|
||
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
|
||
THEN fee_total ELSE 0 END) AS todayRevenue,
|
||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
|
||
SUM(CASE WHEN vehicle_id IS NOT NULL
|
||
THEN cost_total ELSE 0 END) AS lingniuBornFee
|
||
FROM ${HYDROGEN_TABLE}
|
||
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
|
||
[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],
|
||
);
|
||
const k = kpiRows[0] ?? {};
|
||
const yearFee = Number(k.yearFee) || 0;
|
||
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
|
||
const yearRevenue = Number(k.yearRevenue) || 0;
|
||
const monthFee = Number(k.monthFee) || 0;
|
||
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
|
||
const monthRevenue = Number(k.monthRevenue) || 0;
|
||
const todayFee = Number(k.todayFee) || 0;
|
||
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
|
||
const todayRevenue = Number(k.todayRevenue) || 0;
|
||
const kpi = {
|
||
yearKg: Number(k.yearKg) || 0,
|
||
yearFee,
|
||
yearRevenue,
|
||
yearProfit: yearRevenue - yearCustomerCost,
|
||
ourYearKg: Number(k.ourYearKg) || 0,
|
||
ourYearFee: Number(k.ourYearFee) || 0,
|
||
customerYearKg: Number(k.customerYearKg) || 0,
|
||
monthKg: Number(k.monthKg) || 0,
|
||
monthFee,
|
||
monthRevenue,
|
||
monthProfit: monthRevenue - monthCustomerCost,
|
||
todayKg: Number(k.todayKg) || 0,
|
||
todayFee,
|
||
todayRevenue,
|
||
todayProfit: todayRevenue - todayCustomerCost,
|
||
lingniuBornKg: Number(k.lingniuBornKg) || 0,
|
||
lingniuBornFee: Number(k.lingniuBornFee) || 0,
|
||
};
|
||
|
||
// Top5 加氢站(指定年份)
|
||
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT b.station_id AS id,
|
||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||
SUM(b.amount_kg) AS kg,
|
||
SUM(b.cost_total) AS fee
|
||
FROM ${HYDROGEN_TABLE} b
|
||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||
AND b.${HYDROGEN_LOCAL} >= ?
|
||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||
GROUP BY b.station_id
|
||
ORDER BY kg DESC
|
||
LIMIT 5`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
const top5KgSum = kpi.yearKg || 1;
|
||
const top5 = top5Rows.map((r, i) => ({
|
||
id: Number(r.id) || 0,
|
||
rank: i + 1,
|
||
name: r.name as string,
|
||
kg: Number(r.kg) || 0,
|
||
fee: Number(r.fee) || 0,
|
||
share: (Number(r.kg) || 0) / top5KgSum,
|
||
}));
|
||
|
||
// 加氢站全量汇总(同年所有站,按加氢量降序)
|
||
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT b.station_id AS id,
|
||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
|
||
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'
|
||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||
AND b.${HYDROGEN_LOCAL} >= ?
|
||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||
GROUP BY b.station_id
|
||
ORDER BY kg DESC`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
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,
|
||
kg: Number(r.kg) || 0,
|
||
revenue: Number(r.revenue) || 0,
|
||
share: (Number(r.kg) || 0) / stationKgSum,
|
||
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
|
||
}));
|
||
|
||
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
|
||
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT region, SUM(kg) AS kg FROM (
|
||
SELECT CASE
|
||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
|
||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
|
||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
|
||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
|
||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
|
||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
|
||
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
|
||
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
|
||
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'
|
||
WHERE ${HYDROGEN_BASE_WHERE_B}
|
||
AND b.${HYDROGEN_LOCAL} >= ?
|
||
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
|
||
) r
|
||
GROUP BY region
|
||
ORDER BY kg DESC`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
|
||
const TOP_REGIONS = 8;
|
||
const top = regionRows.slice(0, TOP_REGIONS);
|
||
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
|
||
const regions = [
|
||
...top.map(r => ({
|
||
region: r.region as string,
|
||
kg: Number(r.kg) || 0,
|
||
share: (Number(r.kg) || 0) / totalKg,
|
||
})),
|
||
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
|
||
];
|
||
|
||
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
|
||
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
|
||
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
|
||
ROUND(SUM(amount_kg), 2) AS kg,
|
||
ROUND(SUM(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}) = ?
|
||
GROUP BY m
|
||
ORDER BY m`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
|
||
for (const r of monthRows) {
|
||
monthMap.set(r.m as string, {
|
||
kg: Number(r.kg) || 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 }[] = [];
|
||
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 });
|
||
}
|
||
|
||
// 客户账单 Top(指定年份;按加氢量降序,前 30)
|
||
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
|
||
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,
|
||
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}) = ?
|
||
GROUP BY name
|
||
ORDER BY kg DESC
|
||
LIMIT 30`,
|
||
[HYDROGEN_MIN_DATE, year],
|
||
);
|
||
const customers = customerRows.map(r => ({
|
||
name: r.name as string,
|
||
payer: (r.payer as string) === 'lingniu' ? 'lingniu' 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 };
|
||
}, { force, source: 'hydrogenDatabase' });
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 氢能 每日:日期范围 + 车辆归属 + 站点/客户下钻
|
||
// =========================================================
|
||
app.get('/hydrogen/daily', async (c) => {
|
||
const range = (c.req.query('range') || 'last15') as Range;
|
||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
|
||
const stationIdParam = c.req.query('stationId');
|
||
const customerNameParam = c.req.query('customerName');
|
||
const stationId = parseHydrogenStationId(stationIdParam);
|
||
const customerName = parseHydrogenCustomerName(customerNameParam);
|
||
if (stationIdParam !== undefined && stationId === null) {
|
||
return c.json({ error: 'stationId 必须是非负整数' }, 400);
|
||
}
|
||
if (customerNameParam !== undefined && customerName === null) {
|
||
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
|
||
}
|
||
const force = c.req.query('force') === '1';
|
||
|
||
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}`, async () => {
|
||
const hydrogenPool = getHydrogenPool();
|
||
|
||
const whereParts = [
|
||
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'),
|
||
];
|
||
const whereParams: Array<string | number> = [dateRange.start, dateRange.end];
|
||
if (stationId !== null) {
|
||
whereParts.push('COALESCE(b.station_id, 0) = ?');
|
||
whereParams.push(stationId);
|
||
}
|
||
if (customerName !== null) {
|
||
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
|
||
whereParams.push(customerName);
|
||
}
|
||
const where = whereParts.join(' AND ');
|
||
|
||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
|
||
// 单价不重算:直接取账本成本价。
|
||
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
|
||
COALESCE(b.station_id, 0) AS stationId,
|
||
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
|
||
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
|
||
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
|
||
ROUND(SUM(b.amount_kg), 2) AS kg,
|
||
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
|
||
MAX(b.cost_price) AS pricePerKg
|
||
FROM ${HYDROGEN_TABLE} b
|
||
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
||
WHERE ${where}
|
||
GROUP BY d, COALESCE(b.station_id, 0)
|
||
ORDER BY d DESC, kg DESC`,
|
||
whereParams,
|
||
);
|
||
|
||
// 站点环比:同站点上一条记录的 kg
|
||
// 按 stationId 分组、按日期升序计算
|
||
type StationRow = { date: string; stationId: number; name: string; kg: 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,
|
||
pricePerKg: Number(r.pricePerKg) || 0,
|
||
}));
|
||
|
||
// 计算日级总量 + 日级环比
|
||
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
|
||
for (const s of flat) {
|
||
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
|
||
const e = dayMap.get(s.date)!;
|
||
e.totalKg += s.kg;
|
||
e.stations.push(s);
|
||
}
|
||
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
|
||
const dayChainPct = new Map<string, number>();
|
||
let prev = 0;
|
||
for (const d of dates) {
|
||
const cur = dayMap.get(d)!.totalKg;
|
||
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
|
||
prev = cur;
|
||
}
|
||
|
||
// 站点级环比:按 stationId 分组按日期升序
|
||
const stationPrev = new Map<number, number>();
|
||
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
|
||
// 需要按 stationId 分组排序
|
||
const byStation = new Map<number, StationRow[]>();
|
||
for (const s of flat) {
|
||
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
|
||
byStation.get(s.stationId)!.push(s);
|
||
}
|
||
for (const [, list] of byStation) {
|
||
list.sort((a, b) => a.date.localeCompare(b.date));
|
||
let p = 0;
|
||
for (const r of list) {
|
||
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
|
||
p = r.kg;
|
||
}
|
||
}
|
||
|
||
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
|
||
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||
const fullDays = allDates.map(date => {
|
||
const info = dayMap.get(date);
|
||
return {
|
||
date,
|
||
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
|
||
chainPct: dayChainPct.get(date) ?? 0,
|
||
customerType: customer,
|
||
stations: info
|
||
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
|
||
stationId: s.stationId,
|
||
name: s.name,
|
||
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
|
||
kg: Math.round(s.kg * 100) / 100,
|
||
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
|
||
}))
|
||
: [],
|
||
};
|
||
});
|
||
|
||
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
|
||
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
|
||
let prevKg = 0;
|
||
for (const d of ascDays) {
|
||
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
|
||
prevKg = d.totalKg;
|
||
}
|
||
|
||
// 按日期降序返回
|
||
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||
return result;
|
||
}, { force, source: 'hydrogenDatabase' });
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 氢能订单下钻:指定自然日 + 车辆归属 + 可选站点/客户
|
||
// =========================================================
|
||
app.get('/hydrogen/orders', async (c) => {
|
||
const date = parseEnergyDate(c.req.query('date'));
|
||
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
|
||
|
||
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
|
||
const stationIdParam = c.req.query('stationId');
|
||
const customerNameParam = c.req.query('customerName');
|
||
const stationId = parseHydrogenStationId(stationIdParam);
|
||
const customerName = parseHydrogenCustomerName(customerNameParam);
|
||
if (stationIdParam !== undefined && stationId === null) {
|
||
return c.json({ error: 'stationId 必须是非负整数' }, 400);
|
||
}
|
||
if (customerNameParam !== undefined && customerName === null) {
|
||
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
|
||
}
|
||
|
||
const page = parseHydrogenOrderPage(c.req.query('page'));
|
||
const limit = parseHydrogenOrderLimit(c.req.query('limit'));
|
||
const offset = (page - 1) * limit;
|
||
const whereParts = [
|
||
HYDROGEN_BASE_WHERE_B,
|
||
`b.${HYDROGEN_LOCAL} >= ?`,
|
||
`b.${HYDROGEN_LOCAL} < DATE_ADD(?, INTERVAL 1 DAY)`,
|
||
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
|
||
];
|
||
const params: Array<string | number> = [date, date];
|
||
if (stationId !== null) {
|
||
whereParts.push('COALESCE(b.station_id, 0) = ?');
|
||
params.push(stationId);
|
||
}
|
||
if (customerName !== null) {
|
||
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
|
||
params.push(customerName);
|
||
}
|
||
const where = whereParts.join(' AND ');
|
||
|
||
const data = await cached(
|
||
`hydrogen/orders?date=${date}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}&page=${page}&limit=${limit}`,
|
||
async () => {
|
||
const hydrogenPool = getHydrogenPool();
|
||
const [[summaryRows], [detailRows]] = await Promise.all([
|
||
hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT COUNT(*) AS recordCount,
|
||
SUM(b.amount_kg) AS totalKg,
|
||
SUM(b.cost_total) AS totalCost,
|
||
SUM(b.fee_total) AS totalRevenue
|
||
FROM ${HYDROGEN_TABLE} b
|
||
WHERE ${where}`,
|
||
params,
|
||
),
|
||
hydrogenPool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d %H:%i:%s') AS refuelTime,
|
||
COALESCE(NULLIF(TRIM(b.plate_number), ''), '未识别车辆') AS plate,
|
||
COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') AS customerName,
|
||
COALESCE(s.station_short_name, s.station_name, NULLIF(TRIM(b.station_name), ''),
|
||
CASE WHEN b.station_id IS NULL THEN '未关联站点'
|
||
ELSE CONCAT('未知站点 #', b.station_id) END) AS stationName,
|
||
b.amount_kg AS amountKg,
|
||
b.cost_price AS costPrice,
|
||
b.cost_total AS costTotal,
|
||
b.customer_price AS customerPrice,
|
||
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'
|
||
WHERE ${where}
|
||
ORDER BY b.${HYDROGEN_LOCAL} DESC,
|
||
b.plate_number ASC,
|
||
b.station_id ASC,
|
||
b.amount_kg DESC
|
||
LIMIT ? OFFSET ?`,
|
||
[...params, limit, offset],
|
||
),
|
||
]);
|
||
const summary = summaryRows[0] ?? {};
|
||
const recordCount = Number(summary.recordCount) || 0;
|
||
return {
|
||
date,
|
||
vehicleScope: customer,
|
||
stationId,
|
||
customerName,
|
||
page,
|
||
limit,
|
||
recordCount,
|
||
totalPages: Math.max(1, Math.ceil(recordCount / limit)),
|
||
totalKg: Math.round((Number(summary.totalKg) || 0) * 100) / 100,
|
||
totalCost: Math.round((Number(summary.totalCost) || 0) * 100) / 100,
|
||
totalRevenue: Math.round((Number(summary.totalRevenue) || 0) * 100) / 100,
|
||
items: detailRows.map((row, index) => ({
|
||
rowNumber: offset + index + 1,
|
||
refuelTime: String(row.refuelTime),
|
||
plate: String(row.plate),
|
||
customerName: String(row.customerName),
|
||
stationName: String(row.stationName),
|
||
amountKg: Math.round((Number(row.amountKg) || 0) * 100) / 100,
|
||
costPrice: Math.round((Number(row.costPrice) || 0) * 100) / 100,
|
||
costTotal: Math.round((Number(row.costTotal) || 0) * 100) / 100,
|
||
customerPrice: Math.round((Number(row.customerPrice) || 0) * 100) / 100,
|
||
revenue: Math.round((Number(row.revenue) || 0) * 100) / 100,
|
||
})),
|
||
};
|
||
},
|
||
{ source: 'hydrogenDatabase' },
|
||
);
|
||
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
||
// =========================================================
|
||
app.get('/electric/overview', async (c) => {
|
||
const force = c.req.query('force') === '1';
|
||
const data = await cached('electric/overview', async () => {
|
||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT
|
||
SUM(kwh) AS totalKwh,
|
||
SUM(fee) AS totalFee,
|
||
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
THEN kwh ELSE 0 END) AS monthKwh,
|
||
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,
|
||
DATE_FORMAT(MAX(start_time), '%Y-%m-%d %H:%i:%s') AS latestRecordAt
|
||
FROM bi_ele_charge_record`,
|
||
);
|
||
const k = kpiRows[0] ?? {};
|
||
const totalKwh = Number(k.totalKwh) || 0;
|
||
const totalFee = Number(k.totalFee) || 0;
|
||
const monthKwh = Number(k.monthKwh) || 0;
|
||
const monthFee = Number(k.monthFee) || 0;
|
||
const todayKwh = Number(k.todayKwh) || 0;
|
||
const todayFee = Number(k.todayFee) || 0;
|
||
const latestRecordAt = k.latestRecordAt ? String(k.latestRecordAt) : null;
|
||
|
||
// 本月每日(用于柱图)
|
||
const [trendRows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||
SUM(kwh) AS kwh,
|
||
SUM(fee) AS fee
|
||
FROM bi_ele_charge_record
|
||
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
|
||
GROUP BY date
|
||
ORDER BY date ASC`,
|
||
);
|
||
// 若本月无数据,降级展示最近一个有数据的自然月
|
||
let trend = trendRows;
|
||
if (trend.length === 0) {
|
||
const [fallback] = await pool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||
SUM(kwh) AS kwh,
|
||
SUM(fee) AS fee
|
||
FROM bi_ele_charge_record
|
||
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
|
||
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
|
||
)
|
||
GROUP BY date
|
||
ORDER BY date ASC`,
|
||
);
|
||
trend = fallback;
|
||
}
|
||
const trendArr = trend.map(r => ({
|
||
date: r.date as string,
|
||
kwh: Math.round((Number(r.kwh) || 0) * 100) / 100,
|
||
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
|
||
chainPct: 0,
|
||
}));
|
||
for (let i = 1; i < trendArr.length; i++) {
|
||
const prev = trendArr[i - 1].kwh;
|
||
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
|
||
}
|
||
|
||
let todayChainPct = 0;
|
||
if (todayKwh > 0) {
|
||
const [prevRow] = await pool.query<RowDataPacket[]>(
|
||
`SELECT SUM(kwh) AS kwh
|
||
FROM bi_ele_charge_record
|
||
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
|
||
);
|
||
const prevKwh = Number(prevRow[0]?.kwh) || 0;
|
||
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
|
||
}
|
||
|
||
return {
|
||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||
trend: trendArr,
|
||
latestRecordAt,
|
||
};
|
||
}, { force, source: 'electricDatabase' });
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 电能 订单下钻:指定自然日 + 车辆归属,最多返回 500 条明细
|
||
// =========================================================
|
||
app.get('/electric/orders', async (c) => {
|
||
const dateParam = c.req.query('date');
|
||
const date = parseEnergyDate(dateParam);
|
||
if (date === null) return c.json({ error: 'date 必须是有效的 YYYY-MM-DD 日期' }, 400);
|
||
|
||
const customerParam = c.req.query('customer');
|
||
const customer = parseElectricVehicleScope(customerParam);
|
||
if (customer === null) return c.json({ error: 'customer 必须是 all、lingniu 或 external' }, 400);
|
||
const vehicleKind = customer === 'all' ? null : customer === 'lingniu' ? 'internal' : 'external';
|
||
const kindClause = vehicleKind === null ? '' : 'AND vehicle_kind = ?';
|
||
|
||
const data = await cached(`electric/orders?date=${date}&customer=${customer}`, async () => {
|
||
const params = vehicleKind === null ? [date, date] : [date, date, vehicleKind];
|
||
const [[summaryRows], [detailRows]] = await Promise.all([
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT COUNT(*) AS recordCount,
|
||
SUM(kwh) AS totalKwh,
|
||
SUM(fee) AS totalFee
|
||
FROM bi_ele_charge_record
|
||
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||
${kindClause}`,
|
||
params,
|
||
),
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT id,
|
||
order_no AS orderNo,
|
||
DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS startTime,
|
||
COALESCE(NULLIF(TRIM(station_name), ''), '未指定站点') AS stationName,
|
||
COALESCE(NULLIF(TRIM(matched_plate), ''), NULLIF(TRIM(judged_plate), ''), NULLIF(TRIM(plate), ''), '未识别车辆') AS plate,
|
||
vehicle_kind AS vehicleKind,
|
||
COALESCE(NULLIF(TRIM(order_status), ''), '未指定状态') AS orderStatus,
|
||
kwh,
|
||
e_fee AS electricityFee,
|
||
service_fee AS serviceFee,
|
||
fee AS totalFee
|
||
FROM bi_ele_charge_record
|
||
WHERE start_time >= ? AND start_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||
${kindClause}
|
||
ORDER BY start_time DESC, id DESC
|
||
LIMIT 501`,
|
||
params,
|
||
),
|
||
]);
|
||
const summary = summaryRows[0] ?? {};
|
||
const truncated = detailRows.length > 500;
|
||
return {
|
||
date,
|
||
vehicleScope: customer,
|
||
recordCount: Number(summary.recordCount) || 0,
|
||
totalKwh: Math.round((Number(summary.totalKwh) || 0) * 100) / 100,
|
||
totalFee: Math.round((Number(summary.totalFee) || 0) * 100) / 100,
|
||
truncated,
|
||
items: detailRows.slice(0, 500).map(row => ({
|
||
id: Number(row.id),
|
||
orderNo: String(row.orderNo),
|
||
startTime: String(row.startTime),
|
||
stationName: String(row.stationName),
|
||
plate: String(row.plate),
|
||
vehicleKind: row.vehicleKind as 'internal' | 'external' | 'unknown',
|
||
orderStatus: String(row.orderStatus),
|
||
kwh: Math.round((Number(row.kwh) || 0) * 100) / 100,
|
||
electricityFee: Math.round((Number(row.electricityFee) || 0) * 100) / 100,
|
||
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
|
||
totalFee: Math.round((Number(row.totalFee) || 0) * 100) / 100,
|
||
})),
|
||
};
|
||
}, { source: 'electricDatabase' });
|
||
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
|
||
// 支持 range 参数(thisWeek / thisMonth / last15)
|
||
// 缺失日期补零
|
||
// =========================================================
|
||
app.get('/electric/monthly', async (c) => {
|
||
const customer = parseElectricVehicleScope(c.req.query('customer'));
|
||
if (customer === null) return c.json({ error: 'customer 必须是 all、lingniu 或 external' }, 400);
|
||
const range = (c.req.query('range') || 'last15') as Range;
|
||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||
const force = c.req.query('force') === '1';
|
||
|
||
const data = await cached(`electric/monthly?customer=${customer}&start=${dateRange.start}&end=${dateRange.end}`, async () => {
|
||
|
||
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
|
||
let kindClause = '1=1';
|
||
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
|
||
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
|
||
|
||
const [rows] = await pool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
|
||
SUM(kwh) AS kwh,
|
||
SUM(fee) AS fee
|
||
FROM bi_ele_charge_record
|
||
WHERE ${kindClause}
|
||
AND ${dateRangeClause('start_time')}
|
||
GROUP BY date`,
|
||
[dateRange.start, dateRange.end],
|
||
);
|
||
|
||
// 实际数据 map
|
||
const dataMap = new Map<string, { kwh: number; fee: number }>();
|
||
for (const r of rows) {
|
||
dataMap.set(r.date as string, {
|
||
kwh: Number(r.kwh) || 0,
|
||
fee: Number(r.fee) || 0,
|
||
});
|
||
}
|
||
|
||
// 补零:枚举 range 全部日期
|
||
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
|
||
const fullDays = allDates.map(date => {
|
||
const d = dataMap.get(date);
|
||
return {
|
||
date,
|
||
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
|
||
fee: d ? Math.round(d.fee * 100) / 100 : 0,
|
||
};
|
||
});
|
||
|
||
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
|
||
const monthMap = new Map<string, typeof fullDays>();
|
||
for (const d of fullDays) {
|
||
const m = d.date.slice(0, 7);
|
||
if (!monthMap.has(m)) monthMap.set(m, []);
|
||
monthMap.get(m)!.push(d);
|
||
}
|
||
|
||
const months = Array.from(monthMap.entries())
|
||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||
.map(([month, days]) => {
|
||
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||
const chain = new Map<string, number>();
|
||
let prev = 0;
|
||
for (const d of asc) {
|
||
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
|
||
prev = d.kwh;
|
||
}
|
||
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
|
||
const rowsWithChain = desc.map(d => ({
|
||
date: d.date,
|
||
kwh: d.kwh,
|
||
fee: d.fee,
|
||
chainPct: chain.get(d.date) ?? 0,
|
||
}));
|
||
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
|
||
const feeSum = days.reduce((s, d) => s + d.fee, 0);
|
||
return {
|
||
month,
|
||
kwh: Math.round(kwhSum * 100) / 100,
|
||
fee: Math.round(feeSum * 100) / 100,
|
||
rows: rowsWithChain,
|
||
};
|
||
});
|
||
|
||
return months;
|
||
}, { force, source: 'electricDatabase' });
|
||
return c.json(data);
|
||
});
|
||
|
||
// =========================================================
|
||
// ETC 总览:真实接入状态 + 通行记录/账单基础 KPI
|
||
// 不读取或返回同步账号、密码、API 地址等敏感配置。
|
||
// =========================================================
|
||
app.get('/etc/overview', async (c) => {
|
||
const force = c.req.query('force') === '1';
|
||
const data = await cached('etc/overview', async () => {
|
||
const [[recordRows], [billRows], [configRows], [logRows]] = await Promise.all([
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT COUNT(*) AS passageCount,
|
||
COUNT(DISTINCT plate_number) AS vehicleCount,
|
||
SUM(toll_amount) AS tollAmount,
|
||
SUM(service_fee) AS serviceFee,
|
||
SUM(total_amount) AS totalAmount,
|
||
DATE_FORMAT(MAX(trans_time), '%Y-%m-%d %H:%i:%s') AS latestTransactionTime
|
||
FROM etc_toll_record
|
||
WHERE del_flag = '0'`,
|
||
),
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT COUNT(*) AS billCount,
|
||
SUM(receivable_amount) AS receivableAmount,
|
||
SUM(paid_amount) AS paidAmount
|
||
FROM energy_etc_bill
|
||
WHERE del_flag = '0'`,
|
||
),
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT provider_type AS providerType,
|
||
sync_enabled AS syncEnabled,
|
||
DATE_FORMAT(last_sync_time, '%Y-%m-%d %H:%i:%s') AS lastSyncAt,
|
||
last_sync_status AS lastSyncStatus
|
||
FROM etc_sync_config
|
||
WHERE del_flag = '0'
|
||
ORDER BY update_time DESC, id DESC
|
||
LIMIT 1`,
|
||
),
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT DATE_FORMAT(sync_end_time, '%Y-%m-%d %H:%i:%s') AS lastSyncAt,
|
||
sync_status AS syncStatus
|
||
FROM etc_sync_log
|
||
ORDER BY sync_start_time DESC, id DESC
|
||
LIMIT 1`,
|
||
),
|
||
]);
|
||
|
||
const record = recordRows[0] ?? {};
|
||
const bill = billRows[0] ?? {};
|
||
const config = configRows[0];
|
||
const log = logRows[0];
|
||
const rawSyncStatus = log?.syncStatus ?? config?.lastSyncStatus;
|
||
const lastSyncStatus = rawSyncStatus === 1
|
||
? 'success'
|
||
: rawSyncStatus === 0
|
||
? 'failed'
|
||
: rawSyncStatus === 2
|
||
? 'partial'
|
||
: rawSyncStatus == null
|
||
? 'never'
|
||
: 'unknown';
|
||
|
||
return {
|
||
integration: {
|
||
providerConfigured: Boolean(config),
|
||
syncEnabled: Number(config?.syncEnabled) === 1,
|
||
providerType: config?.providerType ? String(config.providerType) : null,
|
||
lastSyncAt: log?.lastSyncAt ? String(log.lastSyncAt) : config?.lastSyncAt ? String(config.lastSyncAt) : null,
|
||
lastSyncStatus,
|
||
},
|
||
kpi: {
|
||
passageCount: Number(record.passageCount) || 0,
|
||
vehicleCount: Number(record.vehicleCount) || 0,
|
||
tollAmount: Math.round((Number(record.tollAmount) || 0) * 100) / 100,
|
||
serviceFee: Math.round((Number(record.serviceFee) || 0) * 100) / 100,
|
||
totalAmount: Math.round((Number(record.totalAmount) || 0) * 100) / 100,
|
||
billCount: Number(bill.billCount) || 0,
|
||
receivableAmount: Math.round((Number(bill.receivableAmount) || 0) * 100) / 100,
|
||
paidAmount: Math.round((Number(bill.paidAmount) || 0) * 100) / 100,
|
||
latestTransactionTime: record.latestTransactionTime ? String(record.latestTransactionTime) : null,
|
||
},
|
||
} as const;
|
||
}, { force, source: 'etcDatabase' });
|
||
|
||
return c.json(data);
|
||
});
|
||
|
||
app.get('/etc/records', async (c) => {
|
||
const page = parseEtcPage(c.req.query('page'));
|
||
const limit = parseEtcLimit(c.req.query('limit'));
|
||
const search = parseEtcSearch(c.req.query('search'));
|
||
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
|
||
|
||
const startParam = c.req.query('startDate');
|
||
const endParam = c.req.query('endDate');
|
||
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
|
||
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
|
||
if (parsedStart === null || parsedEnd === null) {
|
||
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
|
||
}
|
||
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
|
||
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
|
||
|
||
const where = ["del_flag = '0'"];
|
||
const params: Array<string | number> = [];
|
||
if (startDate) {
|
||
where.push('trans_time >= ?');
|
||
params.push(startDate);
|
||
}
|
||
if (endDate) {
|
||
where.push('trans_time < DATE_ADD(?, INTERVAL 1 DAY)');
|
||
params.push(endDate);
|
||
}
|
||
if (search) {
|
||
where.push('(plate_number LIKE ? OR customer_name LIKE ? OR record_code LIKE ?)');
|
||
const pattern = `%${search}%`;
|
||
params.push(pattern, pattern, pattern);
|
||
}
|
||
const whereSql = where.join(' AND ');
|
||
const offset = (page - 1) * limit;
|
||
|
||
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT COUNT(*) AS total,
|
||
COUNT(DISTINCT plate_number) AS vehicleCount,
|
||
SUM(toll_amount) AS tollAmount,
|
||
SUM(service_fee) AS serviceFee,
|
||
SUM(total_amount) AS totalAmount
|
||
FROM etc_toll_record
|
||
WHERE ${whereSql}`,
|
||
params,
|
||
),
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT id,
|
||
record_code AS recordCode,
|
||
plate_number AS plate,
|
||
customer_name AS customerName,
|
||
entry_station_name AS entryStation,
|
||
exit_station_name AS exitStation,
|
||
DATE_FORMAT(trans_time, '%Y-%m-%d %H:%i:%s') AS transTime,
|
||
toll_amount AS tollAmount,
|
||
service_fee AS serviceFee,
|
||
total_amount AS totalAmount,
|
||
cost_type AS costType,
|
||
contract_matched AS contractMatched,
|
||
review_status AS reviewStatus
|
||
FROM etc_toll_record
|
||
WHERE ${whereSql}
|
||
ORDER BY trans_time DESC, id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
[...params, limit, offset],
|
||
),
|
||
]));
|
||
|
||
const summary = summaryRows[0] ?? {};
|
||
const total = Number(summary.total) || 0;
|
||
return c.json({
|
||
page,
|
||
limit,
|
||
total,
|
||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||
filters: { startDate: startDate || null, endDate: endDate || null, search },
|
||
summary: {
|
||
vehicleCount: Number(summary.vehicleCount) || 0,
|
||
tollAmount: Math.round((Number(summary.tollAmount) || 0) * 100) / 100,
|
||
serviceFee: Math.round((Number(summary.serviceFee) || 0) * 100) / 100,
|
||
totalAmount: Math.round((Number(summary.totalAmount) || 0) * 100) / 100,
|
||
},
|
||
items: detailRows.map(row => ({
|
||
id: Number(row.id),
|
||
recordCode: String(row.recordCode),
|
||
plate: String(row.plate),
|
||
customerName: row.customerName ? String(row.customerName) : null,
|
||
entryStation: row.entryStation ? String(row.entryStation) : null,
|
||
exitStation: row.exitStation ? String(row.exitStation) : null,
|
||
transTime: String(row.transTime),
|
||
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
|
||
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
|
||
totalAmount: Math.round((Number(row.totalAmount) || 0) * 100) / 100,
|
||
costType: Number(row.costType) || 0,
|
||
contractMatched: Number(row.contractMatched) === 1,
|
||
reviewStatus: Number(row.reviewStatus) || 0,
|
||
})),
|
||
});
|
||
});
|
||
|
||
app.get('/etc/bills', async (c) => {
|
||
const page = parseEtcPage(c.req.query('page'));
|
||
const limit = parseEtcLimit(c.req.query('limit'));
|
||
const search = parseEtcSearch(c.req.query('search'));
|
||
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
|
||
|
||
const startParam = c.req.query('startDate');
|
||
const endParam = c.req.query('endDate');
|
||
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
|
||
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
|
||
if (parsedStart === null || parsedEnd === null) {
|
||
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
|
||
}
|
||
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
|
||
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
|
||
|
||
const where = ["del_flag = '0'"];
|
||
const params: Array<string | number> = [];
|
||
if (startDate) {
|
||
where.push('bill_period_end >= ?');
|
||
params.push(startDate);
|
||
}
|
||
if (endDate) {
|
||
where.push('bill_period_start <= ?');
|
||
params.push(endDate);
|
||
}
|
||
if (search) {
|
||
where.push('(customer_name LIKE ? OR bill_code LIKE ?)');
|
||
const pattern = `%${search}%`;
|
||
params.push(pattern, pattern);
|
||
}
|
||
const whereSql = where.join(' AND ');
|
||
const offset = (page - 1) * limit;
|
||
|
||
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT COUNT(*) AS total,
|
||
SUM(receivable_amount) AS receivableAmount,
|
||
SUM(paid_amount) AS paidAmount
|
||
FROM energy_etc_bill
|
||
WHERE ${whereSql}`,
|
||
params,
|
||
),
|
||
pool.query<RowDataPacket[]>(
|
||
`SELECT id,
|
||
bill_code AS billCode,
|
||
customer_name AS customerName,
|
||
DATE_FORMAT(bill_period_start, '%Y-%m-%d') AS periodStart,
|
||
DATE_FORMAT(bill_period_end, '%Y-%m-%d') AS periodEnd,
|
||
total_toll_count AS tollCount,
|
||
total_toll_amount AS tollAmount,
|
||
total_service_fee AS serviceFee,
|
||
receivable_amount AS receivableAmount,
|
||
paid_amount AS paidAmount,
|
||
payment_status AS paymentStatus,
|
||
review_status AS reviewStatus
|
||
FROM energy_etc_bill
|
||
WHERE ${whereSql}
|
||
ORDER BY bill_period_end DESC, id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
[...params, limit, offset],
|
||
),
|
||
]));
|
||
|
||
const summary = summaryRows[0] ?? {};
|
||
const total = Number(summary.total) || 0;
|
||
return c.json({
|
||
page,
|
||
limit,
|
||
total,
|
||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||
filters: { startDate: startDate || null, endDate: endDate || null, search },
|
||
summary: {
|
||
receivableAmount: Math.round((Number(summary.receivableAmount) || 0) * 100) / 100,
|
||
paidAmount: Math.round((Number(summary.paidAmount) || 0) * 100) / 100,
|
||
},
|
||
items: detailRows.map(row => ({
|
||
id: Number(row.id),
|
||
billCode: String(row.billCode),
|
||
customerName: row.customerName ? String(row.customerName) : null,
|
||
periodStart: String(row.periodStart),
|
||
periodEnd: String(row.periodEnd),
|
||
tollCount: Number(row.tollCount) || 0,
|
||
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
|
||
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
|
||
receivableAmount: Math.round((Number(row.receivableAmount) || 0) * 100) / 100,
|
||
paidAmount: Math.round((Number(row.paidAmount) || 0) * 100) / 100,
|
||
paymentStatus: Number(row.paymentStatus) || 0,
|
||
reviewStatus: Number(row.reviewStatus) || 0,
|
||
})),
|
||
});
|
||
});
|
||
|
||
export default app;
|