refactor(stage9): 后端业务域目录形状统一
- 每个域现在都是 src/server/routes/<domain>/ 目录:
vehicles/routes.ts(原 routes/vehicles.ts)、vehicle-heatmap/{routes,model}.ts、
hydrogen-heatmap/{routes,model}.ts、ele/routes.ts、feedback/routes.ts。
- 命名约定:index.ts 是聚合器(挂子路由),routes.ts 是叶子路由。mileage/energy/
scheduling 保持 index.ts 聚合,其余统一为 routes.ts。
- 纯移动与改名,未改动任何 SQL、参数顺序、路由挂载前缀或处理器逻辑。
import 改写由一次性 codemod 完成;vehicles/repository.test.ts 读取的源码路径同步更新。
- docs/ARCHITECTURE.md 补充"后端业务域形状"表,并如实标注 ele / feedback 尚未拆出
repository(这两个域缺 SQL 指纹测试,建议先补测试再拆)。
lint / test(139) / build 全绿,可达性 0 未引用文件。
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildHydrogenPoints,
|
||||
filterHydrogenStationsByRadius,
|
||||
haversineKm,
|
||||
hydrogenMetricValue,
|
||||
parseHydrogenHeatmapQuery,
|
||||
parseNearbyQuery,
|
||||
rankHydrogenStations,
|
||||
serializeHydrogenStation,
|
||||
type HydrogenStationRecord,
|
||||
} from './model.js';
|
||||
import { recentDayRange } from '../../../shared/date-range.js';
|
||||
|
||||
// 默认区间是"近 30 天"滚动窗口,注入固定时钟让断言可复现。
|
||||
const NOW = new Date('2026-07-13T12:00:00');
|
||||
const FALLBACK = recentDayRange(30, NOW);
|
||||
|
||||
function station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStationRecord {
|
||||
return {
|
||||
station_id: '1',
|
||||
station_name: '测试加氢站',
|
||||
address: '测试路 1 号',
|
||||
longitude: '120',
|
||||
latitude: '30',
|
||||
kg: '10.5',
|
||||
refuel_count: '3',
|
||||
vehicle_count: '2',
|
||||
first_refuel: '2026-07-01 08:00:00',
|
||||
last_refuel: '2026-07-02 18:00:00',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('加氢热力图查询默认使用近 30 天滚动窗口、去除搜索空白并限制枚举值', () => {
|
||||
assert.deepEqual(parseHydrogenHeatmapQuery({}, NOW), {
|
||||
startDate: FALLBACK.start,
|
||||
endDate: FALLBACK.end,
|
||||
query: '',
|
||||
payer: 'all',
|
||||
metric: 'kg',
|
||||
});
|
||||
assert.deepEqual(parseHydrogenHeatmapQuery({
|
||||
startDate: '2026-06-01',
|
||||
endDate: 'bad',
|
||||
query: ' 嘉兴 ',
|
||||
payer: 'customer',
|
||||
metric: 'vehicles',
|
||||
}, NOW), {
|
||||
startDate: '2026-06-01',
|
||||
endDate: FALLBACK.end,
|
||||
query: '嘉兴',
|
||||
payer: 'customer',
|
||||
metric: 'vehicles',
|
||||
});
|
||||
assert.equal(parseHydrogenHeatmapQuery({ payer: 'bad' }).payer, 'all');
|
||||
assert.equal(parseHydrogenHeatmapQuery({ metric: 'bad' }).metric, 'kg');
|
||||
});
|
||||
|
||||
test('加氢附近查询继续沿用经纬度 Number 语义和 5 至 200 公里半径限制', () => {
|
||||
assert.deepEqual(parseNearbyQuery({ lng: '', lat: '0', radiusKm: '0' }), {
|
||||
center: { lng: 0, lat: 0 },
|
||||
radiusKm: 50,
|
||||
});
|
||||
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '-10' }).radiusKm, 5);
|
||||
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '201' }).radiusKm, 200);
|
||||
assert.equal(parseNearbyQuery({ lng: '120', lat: 'bad' }).center, null);
|
||||
});
|
||||
|
||||
test('加氢指标取值和站点序列化继续保持字段、数值转换及零值回退', () => {
|
||||
const row = station();
|
||||
assert.equal(hydrogenMetricValue(row, 'kg'), 10.5);
|
||||
assert.equal(hydrogenMetricValue(row, 'refuels'), 3);
|
||||
assert.equal(hydrogenMetricValue(row, 'vehicles'), 2);
|
||||
assert.equal(hydrogenMetricValue(station({ kg: 'bad' }), 'kg'), 0);
|
||||
|
||||
assert.deepEqual(serializeHydrogenStation(row, 'refuels'), {
|
||||
stationId: '1',
|
||||
stationName: '测试加氢站',
|
||||
address: '测试路 1 号',
|
||||
lng: 120,
|
||||
lat: 30,
|
||||
kg: 10.5,
|
||||
refuelCount: 3,
|
||||
vehicleCount: 2,
|
||||
firstRefuel: '2026-07-01 08:00:00',
|
||||
lastRefuel: '2026-07-02 18:00:00',
|
||||
metricValue: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('站点排名和热力点继续按所选指标降序并保留同值输入顺序', () => {
|
||||
const stations = [
|
||||
station({ station_id: 'A', kg: 10, longitude: 120, latitude: 30 }),
|
||||
station({ station_id: 'B', kg: 20, longitude: 121, latitude: 31 }),
|
||||
station({ station_id: 'C', kg: 20, longitude: 122, latitude: 32 }),
|
||||
];
|
||||
const ranked = rankHydrogenStations(stations, 'kg');
|
||||
|
||||
assert.deepEqual(ranked.map((row) => row.station_id), ['B', 'C', 'A']);
|
||||
assert.deepEqual(buildHydrogenPoints(ranked, 'kg'), {
|
||||
points: [
|
||||
{ lng: 121, lat: 31, count: 20 },
|
||||
{ lng: 122, lat: 32, count: 20 },
|
||||
{ lng: 120, lat: 30, count: 10 },
|
||||
],
|
||||
max: 20,
|
||||
});
|
||||
assert.deepEqual(buildHydrogenPoints([], 'kg'), { points: [], max: 1 });
|
||||
});
|
||||
|
||||
test('加氢附近筛选继续使用 Haversine 距离并包含边界内站点', () => {
|
||||
const stations = [
|
||||
station({ station_id: 'near', longitude: 120.01 }),
|
||||
station({ station_id: 'far', longitude: 121 }),
|
||||
];
|
||||
|
||||
assert.equal(haversineKm(120, 30, 120, 30), 0);
|
||||
assert.ok(Math.abs(haversineKm(0, 0, 0, 1) - 111.195) < 0.01);
|
||||
assert.deepEqual(
|
||||
filterHydrogenStationsByRadius(stations, { lng: 120, lat: 30 }, 5)
|
||||
.map((row) => row.station_id),
|
||||
['near'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { recentDayRange } from '../../../shared/date-range.js';
|
||||
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
|
||||
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
|
||||
|
||||
export type HydrogenStationRecord = {
|
||||
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;
|
||||
};
|
||||
|
||||
type HydrogenHeatmapQueryInput = {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
query?: string;
|
||||
payer?: string;
|
||||
metric?: string;
|
||||
};
|
||||
|
||||
type NearbyQueryInput = {
|
||||
lng?: string;
|
||||
lat?: string;
|
||||
radiusKm?: string;
|
||||
};
|
||||
|
||||
/** 未显式传日期时的兜底窗口:近 N 天滚动,不再写死历史区间。 */
|
||||
export const HEATMAP_DEFAULT_WINDOW_DAYS = 30;
|
||||
|
||||
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): HydrogenPayer {
|
||||
if (value === 'lingniu' || value === 'customer') return value;
|
||||
return 'all';
|
||||
}
|
||||
|
||||
export function parseHydrogenHeatmapQuery(input: HydrogenHeatmapQueryInput, now: Date = new Date()) {
|
||||
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS, now);
|
||||
return {
|
||||
startDate: isDate(input.startDate) ? input.startDate : fallback.start,
|
||||
endDate: isDate(input.endDate) ? input.endDate : fallback.end,
|
||||
query: (input.query || '').trim(),
|
||||
payer: parsePayer(input.payer),
|
||||
metric: parseMetric(input.metric),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNearbyQuery(input: NearbyQueryInput) {
|
||||
const longitude = Number(input.lng);
|
||||
const latitude = Number(input.lat);
|
||||
return {
|
||||
center: Number.isFinite(longitude) && Number.isFinite(latitude)
|
||||
? { lng: longitude, lat: latitude }
|
||||
: null,
|
||||
radiusKm: Math.min(200, Math.max(5, Number(input.radiusKm) || 50)),
|
||||
};
|
||||
}
|
||||
|
||||
export function hydrogenMetricValue(
|
||||
row: HydrogenStationRecord,
|
||||
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;
|
||||
}
|
||||
|
||||
export function serializeHydrogenStation(
|
||||
row: HydrogenStationRecord,
|
||||
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: hydrogenMetricValue(row, metric),
|
||||
};
|
||||
}
|
||||
|
||||
export function rankHydrogenStations(
|
||||
stations: HydrogenStationRecord[],
|
||||
metric: HydrogenHeatmapMetric,
|
||||
): HydrogenStationRecord[] {
|
||||
return [...stations].sort((left, right) => (
|
||||
hydrogenMetricValue(right, metric) - hydrogenMetricValue(left, metric)
|
||||
));
|
||||
}
|
||||
|
||||
export function buildHydrogenPoints(
|
||||
stations: HydrogenStationRecord[],
|
||||
metric: HydrogenHeatmapMetric,
|
||||
) {
|
||||
const points = stations.map((row) => ({
|
||||
lng: Number(row.longitude),
|
||||
lat: Number(row.latitude),
|
||||
count: hydrogenMetricValue(row, metric),
|
||||
}));
|
||||
return {
|
||||
points,
|
||||
max: Math.max(1, ...points.map((point) => point.count)),
|
||||
};
|
||||
}
|
||||
|
||||
export 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));
|
||||
}
|
||||
|
||||
export function filterHydrogenStationsByRadius(
|
||||
stations: HydrogenStationRecord[],
|
||||
center: { lng: number; lat: number },
|
||||
radiusKm: number,
|
||||
): HydrogenStationRecord[] {
|
||||
return stations.filter((row) => haversineKm(
|
||||
center.lng,
|
||||
center.lat,
|
||||
Number(row.longitude),
|
||||
Number(row.latitude),
|
||||
) <= radiusKm);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db/mysql.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { canAccessEnergy } from '../../auth/types.js';
|
||||
import { recentDayRange } from '../../../shared/date-range.js';
|
||||
import {
|
||||
buildHydrogenPoints,
|
||||
filterHydrogenStationsByRadius,
|
||||
HEATMAP_DEFAULT_WINDOW_DAYS,
|
||||
parseHydrogenHeatmapQuery,
|
||||
parseNearbyQuery,
|
||||
rankHydrogenStations,
|
||||
serializeHydrogenStation,
|
||||
type HydrogenPayer,
|
||||
type HydrogenStationRecord,
|
||||
} from './model.js';
|
||||
|
||||
type StationRow = RowDataPacket & HydrogenStationRecord;
|
||||
|
||||
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 buildWhere(startDate: string, endDate: string, query: string, payer: HydrogenPayer) {
|
||||
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: HydrogenPayer,
|
||||
): 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;
|
||||
}
|
||||
|
||||
const router = new Hono();
|
||||
|
||||
router.use('*', async (c, next) => {
|
||||
const user = (c as { get: (key: string) => unknown }).get('user') as AuthUser | undefined;
|
||||
if (!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;
|
||||
const fallback = recentDayRange(HEATMAP_DEFAULT_WINDOW_DAYS);
|
||||
return c.json({
|
||||
startDate: profile.start_date || fallback.start,
|
||||
endDate: profile.end_date || fallback.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, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
|
||||
startDate: c.req.query('startDate'),
|
||||
endDate: c.req.query('endDate'),
|
||||
query: c.req.query('query'),
|
||||
payer: c.req.query('payer'),
|
||||
metric: c.req.query('metric'),
|
||||
});
|
||||
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
|
||||
const stations = await loadStations(startDate, endDate, query, payer);
|
||||
const ranked = rankHydrogenStations(stations, metric);
|
||||
const { points, max } = buildHydrogenPoints(ranked, 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,
|
||||
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/nearby', async (c) => {
|
||||
const { center, radiusKm } = parseNearbyQuery({
|
||||
lng: c.req.query('lng'),
|
||||
lat: c.req.query('lat'),
|
||||
radiusKm: c.req.query('radiusKm'),
|
||||
});
|
||||
if (!center) return c.json({ error: '经纬度参数无效' }, 400);
|
||||
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
|
||||
startDate: c.req.query('startDate'),
|
||||
endDate: c.req.query('endDate'),
|
||||
query: c.req.query('query'),
|
||||
payer: c.req.query('payer'),
|
||||
metric: c.req.query('metric'),
|
||||
});
|
||||
const stations = await loadStations(startDate, endDate, query, payer);
|
||||
const nearby = filterHydrogenStationsByRadius(stations, center, 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 = rankHydrogenStations(nearby, metric);
|
||||
return c.json({
|
||||
center,
|
||||
radiusKm,
|
||||
kg: nearbySummary.kg,
|
||||
refuelCount: nearbySummary.refuelCount,
|
||||
vehicleCount: nearbySummary.vehicleCount,
|
||||
stationCount: nearbySummary.stationCount,
|
||||
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user