Files
ln-bi/src/server/routes/energy/index.ts
T

1179 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;