291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import { Hono } from 'hono';
|
|
import type { RowDataPacket } from 'mysql2';
|
|
import pool from '../db.js';
|
|
import type { AuthUser } from '../auth/types.js';
|
|
import { canAccessEnergy } from '../auth/types.js';
|
|
|
|
type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
|
|
type Payer = 'all' | 'lingniu' | 'customer';
|
|
|
|
type StationRow = RowDataPacket & {
|
|
station_id: string;
|
|
station_name: string;
|
|
address: string;
|
|
longitude: string | number;
|
|
latitude: string | number;
|
|
kg: string | number;
|
|
refuel_count: string | number;
|
|
vehicle_count: string | number;
|
|
first_refuel: string;
|
|
last_refuel: string;
|
|
};
|
|
|
|
const DEFAULT_START = '2026-01-01';
|
|
const DEFAULT_END = '2026-07-13';
|
|
const VALID_COORDINATE = `
|
|
o.longitude BETWEEN 73.5 AND 135.1
|
|
AND o.latitude BETWEEN 18 AND 53.6
|
|
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
|
|
`;
|
|
|
|
function isDate(value: string | undefined): value is string {
|
|
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
|
|
}
|
|
|
|
function parseMetric(value: string | undefined): HydrogenHeatmapMetric {
|
|
if (value === 'refuels' || value === 'vehicles') return value;
|
|
return 'kg';
|
|
}
|
|
|
|
function parsePayer(value: string | undefined): Payer {
|
|
if (value === 'lingniu' || value === 'customer') return value;
|
|
return 'all';
|
|
}
|
|
|
|
function buildWhere(startDate: string, endDate: string, query: string, payer: Payer) {
|
|
const clauses = [
|
|
`b.del_flag = '0'`,
|
|
`DATE(b.refuel_time) BETWEEN ? AND ?`,
|
|
VALID_COORDINATE,
|
|
];
|
|
const params: Array<string | number> = [startDate, endDate];
|
|
|
|
if (query) {
|
|
clauses.push(`CONVERT(CONCAT_WS(' ', s.station_name, s.station_short_name, b.station_name, o.station_name, o.fixed_station_name, o.station_address, o.city) USING utf8mb4) COLLATE utf8mb4_unicode_ci LIKE CONVERT(? USING utf8mb4) COLLATE utf8mb4_unicode_ci`);
|
|
params.push(`%${query}%`);
|
|
}
|
|
if (payer === 'lingniu') {
|
|
clauses.push(`COALESCE(b.customer_price, 0) <= 0 AND COALESCE(b.fee_total, 0) <= 0`);
|
|
} else if (payer === 'customer') {
|
|
clauses.push(`(COALESCE(b.customer_price, 0) > 0 OR COALESCE(b.fee_total, 0) > 0)`);
|
|
}
|
|
return { sql: clauses.join(' AND '), params };
|
|
}
|
|
|
|
async function loadStations(startDate: string, endDate: string, query: string, payer: Payer): Promise<StationRow[]> {
|
|
const where = buildWhere(startDate, endDate, query, payer);
|
|
const [rows] = await pool.execute<StationRow[]>(`
|
|
SELECT
|
|
CAST(b.station_id AS CHAR) AS station_id,
|
|
COALESCE(
|
|
NULLIF(MAX(s.station_short_name), ''),
|
|
NULLIF(MAX(s.station_name), ''),
|
|
NULLIF(MAX(b.station_name), ''),
|
|
NULLIF(MAX(o.fixed_station_name), ''),
|
|
CONCAT('未知站点 #', b.station_id)
|
|
) AS station_name,
|
|
COALESCE(NULLIF(MAX(o.station_address), ''), NULLIF(MAX(s.station_address), ''), '') AS address,
|
|
MAX(o.longitude) AS longitude,
|
|
MAX(o.latitude) AS latitude,
|
|
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
|
|
COUNT(*) AS refuel_count,
|
|
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
|
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS first_refuel,
|
|
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d %H:%i:%s') AS last_refuel
|
|
FROM hydrogen_fuel_ledger b
|
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
|
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
|
WHERE ${where.sql}
|
|
GROUP BY b.station_id
|
|
`, where.params);
|
|
return rows;
|
|
}
|
|
|
|
function metricValue(row: StationRow, metric: HydrogenHeatmapMetric): number {
|
|
if (metric === 'refuels') return Number(row.refuel_count) || 0;
|
|
if (metric === 'vehicles') return Number(row.vehicle_count) || 0;
|
|
return Number(row.kg) || 0;
|
|
}
|
|
|
|
function serializeStation(row: StationRow, metric: HydrogenHeatmapMetric) {
|
|
return {
|
|
stationId: row.station_id,
|
|
stationName: row.station_name,
|
|
address: row.address,
|
|
lng: Number(row.longitude),
|
|
lat: Number(row.latitude),
|
|
kg: Number(row.kg) || 0,
|
|
refuelCount: Number(row.refuel_count) || 0,
|
|
vehicleCount: Number(row.vehicle_count) || 0,
|
|
firstRefuel: row.first_refuel,
|
|
lastRefuel: row.last_refuel,
|
|
metricValue: metricValue(row, metric),
|
|
};
|
|
}
|
|
|
|
function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number {
|
|
const radians = (degrees: number) => degrees * Math.PI / 180;
|
|
const earthRadius = 6371;
|
|
const dLat = radians(rightLat - leftLat);
|
|
const dLng = radians(rightLng - leftLng);
|
|
const a = Math.sin(dLat / 2) ** 2
|
|
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
|
|
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
const router = new Hono();
|
|
|
|
router.use('*', async (c, next) => {
|
|
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
|
|
if (user && !canAccessEnergy(user.roles)) {
|
|
return c.json({ error: 'Forbidden: 能源管理访问需要 BI-LEADER-ENERGY 角色' }, 403);
|
|
}
|
|
return next();
|
|
});
|
|
|
|
router.get('/config', (c) => {
|
|
const key = process.env.AMAP_WEB_KEY;
|
|
const securityCode = process.env.AMAP_SECURITY_JS_CODE;
|
|
if (!key || !securityCode) return c.json({ error: '高德地图配置缺失' }, 503);
|
|
return c.json({ key, securityCode });
|
|
});
|
|
|
|
router.get('/meta', async (c) => {
|
|
const [profileRows, optionRows] = await Promise.all([
|
|
pool.query<RowDataPacket[]>(`
|
|
SELECT
|
|
DATE_FORMAT(MIN(b.refuel_time), '%Y-%m-%d') AS start_date,
|
|
DATE_FORMAT(MAX(b.refuel_time), '%Y-%m-%d') AS end_date,
|
|
COUNT(*) AS total_refuel_count,
|
|
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS total_kg,
|
|
COUNT(DISTINCT b.station_id) AS total_station_count,
|
|
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
|
SUM(CASE WHEN ${VALID_COORDINATE} THEN 1 ELSE 0 END) AS eligible_refuel_count,
|
|
ROUND(SUM(CASE WHEN ${VALID_COORDINATE} THEN COALESCE(b.amount_kg, 0) ELSE 0 END), 2) AS eligible_kg,
|
|
COUNT(DISTINCT CASE WHEN ${VALID_COORDINATE} THEN b.station_id END) AS eligible_station_count
|
|
FROM hydrogen_fuel_ledger b
|
|
LEFT JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
|
WHERE b.del_flag = '0'
|
|
`),
|
|
pool.query<RowDataPacket[]>(`
|
|
SELECT
|
|
CAST(s.id AS CHAR) AS station_id,
|
|
COALESCE(NULLIF(s.station_short_name, ''), s.station_name) AS station_name
|
|
FROM hydrogen_station s
|
|
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = s.id
|
|
INNER JOIN hydrogen_fuel_ledger b ON b.station_id = s.id AND b.del_flag = '0'
|
|
WHERE s.del_flag = '0' AND ${VALID_COORDINATE}
|
|
GROUP BY s.id, s.station_short_name, s.station_name
|
|
ORDER BY station_name
|
|
`),
|
|
]);
|
|
const profile = profileRows[0][0] || {};
|
|
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
|
|
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
|
|
return c.json({
|
|
startDate: profile.start_date || DEFAULT_START,
|
|
endDate: profile.end_date || DEFAULT_END,
|
|
totalRefuelCount,
|
|
eligibleRefuelCount,
|
|
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
|
|
gpsCoverageRate: totalRefuelCount ? eligibleRefuelCount / totalRefuelCount : 0,
|
|
totalKg: Number(profile.total_kg) || 0,
|
|
eligibleKg: Number(profile.eligible_kg) || 0,
|
|
vehicleCount: Number(profile.vehicle_count) || 0,
|
|
totalStationCount: Number(profile.total_station_count) || 0,
|
|
eligibleStationCount: Number(profile.eligible_station_count) || 0,
|
|
stations: optionRows[0].map((row) => ({
|
|
stationId: String(row.station_id),
|
|
stationName: String(row.station_name),
|
|
})),
|
|
});
|
|
});
|
|
|
|
router.get('/points', async (c) => {
|
|
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
|
|
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
|
|
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
|
|
const query = (c.req.query('query') || '').trim();
|
|
const payer = parsePayer(c.req.query('payer'));
|
|
const metric = parseMetric(c.req.query('metric'));
|
|
const stations = await loadStations(startDate, endDate, query, payer);
|
|
const ranked = [...stations].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
|
|
const points = ranked.map((row) => ({
|
|
lng: Number(row.longitude),
|
|
lat: Number(row.latitude),
|
|
count: metricValue(row, metric),
|
|
}));
|
|
const where = buildWhere(startDate, endDate, query, payer);
|
|
const [summaryRows] = await pool.execute<RowDataPacket[]>(`
|
|
SELECT
|
|
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
|
|
COUNT(*) AS refuel_count,
|
|
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
|
COUNT(DISTINCT b.station_id) AS station_count,
|
|
COUNT(DISTINCT DATE(b.refuel_time)) AS day_count
|
|
FROM hydrogen_fuel_ledger b
|
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
|
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
|
WHERE ${where.sql}
|
|
`, where.params);
|
|
const summary = summaryRows[0] || {};
|
|
return c.json({
|
|
startDate,
|
|
endDate,
|
|
metric,
|
|
payer,
|
|
kg: Number(summary.kg) || 0,
|
|
refuelCount: Number(summary.refuel_count) || 0,
|
|
vehicleCount: Number(summary.vehicle_count) || 0,
|
|
stationCount: Number(summary.station_count) || 0,
|
|
dayCount: Number(summary.day_count) || 0,
|
|
points,
|
|
max: Math.max(1, ...points.map((point) => point.count)),
|
|
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
|
|
});
|
|
});
|
|
|
|
router.get('/nearby', async (c) => {
|
|
const longitude = Number(c.req.query('lng'));
|
|
const latitude = Number(c.req.query('lat'));
|
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return c.json({ error: '经纬度参数无效' }, 400);
|
|
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
|
|
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
|
|
const query = (c.req.query('query') || '').trim();
|
|
const payer = parsePayer(c.req.query('payer'));
|
|
const metric = parseMetric(c.req.query('metric'));
|
|
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
|
|
const stations = await loadStations(startDate, endDate, query, payer);
|
|
const nearby = stations.filter((row) => haversineKm(
|
|
longitude,
|
|
latitude,
|
|
Number(row.longitude),
|
|
Number(row.latitude),
|
|
) <= radiusKm);
|
|
const nearbyIds = nearby.map((row) => row.station_id);
|
|
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
|
|
if (nearbyIds.length) {
|
|
const placeholders = nearbyIds.map(() => '?').join(',');
|
|
const where = buildWhere(startDate, endDate, query, payer);
|
|
const [rows] = await pool.execute<RowDataPacket[]>(`
|
|
SELECT
|
|
ROUND(SUM(COALESCE(b.amount_kg, 0)), 2) AS kg,
|
|
COUNT(*) AS refuel_count,
|
|
COUNT(DISTINCT COALESCE(NULLIF(TRIM(b.license_plate), ''), CONCAT('vehicle#', b.vehicle_id))) AS vehicle_count,
|
|
COUNT(DISTINCT b.station_id) AS station_count
|
|
FROM hydrogen_fuel_ledger b
|
|
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
|
|
INNER JOIN tab_outside_hydrogen_site o ON o.inner_site_id = b.station_id
|
|
WHERE ${where.sql} AND CAST(b.station_id AS CHAR) IN (${placeholders})
|
|
`, [...where.params, ...nearbyIds]);
|
|
nearbySummary = {
|
|
kg: Number(rows[0]?.kg) || 0,
|
|
refuelCount: Number(rows[0]?.refuel_count) || 0,
|
|
vehicleCount: Number(rows[0]?.vehicle_count) || 0,
|
|
stationCount: Number(rows[0]?.station_count) || 0,
|
|
};
|
|
}
|
|
const ranked = [...nearby].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
|
|
return c.json({
|
|
center: { lng: longitude, lat: latitude },
|
|
radiusKm,
|
|
kg: nearbySummary.kg,
|
|
refuelCount: nearbySummary.refuelCount,
|
|
vehicleCount: nearbySummary.vehicleCount,
|
|
stationCount: nearbySummary.stationCount,
|
|
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
|
|
});
|
|
});
|
|
|
|
export default router;
|