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( `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( `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( `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( `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( `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( `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(); 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( `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 = [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( `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(); 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(); 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(); const stationChain = new Map(); // key = `${date}|${stationId}` // 需要按 stationId 分组排序 const byStation = new Map(); 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 = [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( `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( `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( `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( `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( `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( `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( `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( `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( `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(); 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(); 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(); 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( `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( `SELECT COUNT(*) AS billCount, SUM(receivable_amount) AS receivableAmount, SUM(paid_amount) AS paidAmount FROM energy_etc_bill WHERE del_flag = '0'`, ), pool.query( `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( `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 = []; 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( `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( `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 = []; 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( `SELECT COUNT(*) AS total, SUM(receivable_amount) AS receivableAmount, SUM(paid_amount) AS paidAmount FROM energy_etc_bill WHERE ${whereSql}`, params, ), pool.query( `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;