This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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;
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../db.js';
|
||||
import heatmapPool from '../heatmap-db.js';
|
||||
import { getCache } from './mileage/cache.js';
|
||||
|
||||
type HeatmapRecord = {
|
||||
date: string;
|
||||
vin: string;
|
||||
plate: string;
|
||||
time: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
source: string;
|
||||
sourceRecordId: string;
|
||||
};
|
||||
|
||||
type VehicleRank = {
|
||||
vin: string;
|
||||
plateNumber: string;
|
||||
locationCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
};
|
||||
|
||||
type MetaRow = {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
location_count: string;
|
||||
vehicle_count: string;
|
||||
day_count: string;
|
||||
total_location_count: string;
|
||||
excluded_location_count: string;
|
||||
outside_mainland_count: string;
|
||||
tibet_count: string;
|
||||
};
|
||||
|
||||
type VehicleOptionRow = {
|
||||
vin: string;
|
||||
plate_number: string;
|
||||
};
|
||||
|
||||
type RecordRow = {
|
||||
date: string;
|
||||
vin: string;
|
||||
plate: string;
|
||||
time: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
source: string;
|
||||
source_record_id: string;
|
||||
};
|
||||
|
||||
type BatchModelRow = {
|
||||
target_name: string;
|
||||
plate_number: string;
|
||||
};
|
||||
|
||||
function isDate(value: string | undefined): value is string {
|
||||
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
|
||||
}
|
||||
|
||||
function daysInclusive(startDate: string, endDate: string): number {
|
||||
const start = Date.parse(`${startDate}T00:00:00+08:00`);
|
||||
const end = Date.parse(`${endDate}T00:00:00+08:00`);
|
||||
return Math.max(0, Math.floor((end - start) / 86_400_000) + 1);
|
||||
}
|
||||
|
||||
async function loadBatchModelPlates(): Promise<Map<string, Set<string>>> {
|
||||
const cached = getCache()?.targetPlatesMap;
|
||||
if (cached?.size) return cached;
|
||||
|
||||
try {
|
||||
const [rows] = await pool.execute(`
|
||||
select t.target_name, v.plate_number
|
||||
from lingniu_prod.tab_mileage_assessment_target t
|
||||
join lingniu_prod.tab_mileage_assessment_vehicle v
|
||||
on v.target_id = t.id and v.is_deleted = 0
|
||||
where t.is_deleted = 0
|
||||
`) as [BatchModelRow[], unknown];
|
||||
const result = new Map<string, Set<string>>();
|
||||
for (const row of rows) {
|
||||
const plates = result.get(row.target_name) || new Set<string>();
|
||||
plates.add(row.plate_number);
|
||||
result.set(row.target_name, plates);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[vehicle-heatmap] batch model lookup failed', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecords(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
query: string,
|
||||
batchModel: string,
|
||||
): Promise<HeatmapRecord[]> {
|
||||
let batchPlates: string[] = [];
|
||||
if (batchModel) {
|
||||
const modelPlates = await loadBatchModelPlates();
|
||||
batchPlates = [...(modelPlates.get(batchModel) || [])];
|
||||
if (batchPlates.length === 0) return [];
|
||||
}
|
||||
|
||||
const result = await heatmapPool.query<RecordRow>(`
|
||||
select
|
||||
to_char(stat_date, 'YYYY-MM-DD') as date,
|
||||
vin,
|
||||
plate_number as plate,
|
||||
to_char(first_event_time at time zone 'Asia/Shanghai',
|
||||
'YYYY-MM-DD HH24:MI:SS') as time,
|
||||
longitude,
|
||||
latitude,
|
||||
source_name as source,
|
||||
coalesce(source_record_id::text, '') as source_record_id
|
||||
from analytics.vehicle_daily_first_location
|
||||
where stat_date between $1::date and $2::date
|
||||
and is_heatmap_eligible
|
||||
and ($4::boolean = false or plate_number = any($5::text[]))
|
||||
and (
|
||||
$3 = ''
|
||||
or upper(vin) like '%' || upper($3) || '%'
|
||||
or upper(plate_number) like '%' || upper($3) || '%'
|
||||
)
|
||||
order by stat_date, vin
|
||||
`, [startDate, endDate, query.trim(), Boolean(batchModel), batchPlates]);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
...row,
|
||||
longitude: Number(row.longitude),
|
||||
latitude: Number(row.latitude),
|
||||
sourceRecordId: row.source_record_id,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildRanking(records: HeatmapRecord[], limit = 10): VehicleRank[] {
|
||||
const vehicles = new Map<string, VehicleRank>();
|
||||
for (const record of records) {
|
||||
const current = vehicles.get(record.vin);
|
||||
if (!current) {
|
||||
vehicles.set(record.vin, {
|
||||
vin: record.vin,
|
||||
plateNumber: record.plate,
|
||||
locationCount: 1,
|
||||
firstSeen: record.time,
|
||||
lastSeen: record.time,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current.locationCount += 1;
|
||||
if (record.time < current.firstSeen) current.firstSeen = record.time;
|
||||
if (record.time > current.lastSeen) current.lastSeen = record.time;
|
||||
if (!current.plateNumber && record.plate) current.plateNumber = record.plate;
|
||||
}
|
||||
return [...vehicles.values()]
|
||||
.sort((left, right) => right.locationCount - left.locationCount
|
||||
|| left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
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.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 [metaResult, vehiclesResult, batchModelPlates] = await Promise.all([
|
||||
heatmapPool.query<MetaRow>(`
|
||||
select
|
||||
to_char(min(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as start_date,
|
||||
to_char(max(stat_date) filter (where is_heatmap_eligible), 'YYYY-MM-DD') as end_date,
|
||||
count(*) filter (where is_heatmap_eligible)::text as location_count,
|
||||
count(distinct vin) filter (where is_heatmap_eligible)::text as vehicle_count,
|
||||
count(distinct stat_date) filter (where is_heatmap_eligible)::text as day_count,
|
||||
count(*)::text as total_location_count,
|
||||
count(*) filter (where not is_heatmap_eligible)::text as excluded_location_count,
|
||||
count(*) filter (where exclusion_reason = 'outside_mainland_china')::text as outside_mainland_count,
|
||||
count(*) filter (where exclusion_reason = 'tibet')::text as tibet_count
|
||||
from analytics.vehicle_daily_first_location
|
||||
`),
|
||||
heatmapPool.query<VehicleOptionRow>(`
|
||||
select distinct on (vin) vin, plate_number
|
||||
from analytics.vehicle_daily_first_location
|
||||
where is_heatmap_eligible
|
||||
order by vin, stat_date desc
|
||||
`),
|
||||
loadBatchModelPlates(),
|
||||
]);
|
||||
const meta = metaResult.rows[0];
|
||||
const vehicles = vehiclesResult.rows
|
||||
.map(({ vin, plate_number: plateNumber }) => ({ vin, plateNumber }))
|
||||
.sort((left, right) => left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'));
|
||||
return c.json({
|
||||
startDate: meta.start_date,
|
||||
endDate: meta.end_date,
|
||||
locationCount: Number(meta.location_count),
|
||||
vehicleCount: Number(meta.vehicle_count),
|
||||
dayCount: Number(meta.day_count),
|
||||
totalLocationCount: Number(meta.total_location_count),
|
||||
excludedLocationCount: Number(meta.excluded_location_count),
|
||||
outsideMainlandCount: Number(meta.outside_mainland_count),
|
||||
tibetCount: Number(meta.tibet_count),
|
||||
batchModels: [...batchModelPlates.keys()].sort((left, right) => left.localeCompare(right, 'zh-CN')),
|
||||
vehicles,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/points', async (c) => {
|
||||
const requestedStart = c.req.query('startDate');
|
||||
const requestedEnd = c.req.query('endDate');
|
||||
const startDate = isDate(requestedStart) ? requestedStart : '2026-01-01';
|
||||
const endDate = isDate(requestedEnd) ? requestedEnd : '2026-07-13';
|
||||
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
|
||||
|
||||
const query = c.req.query('query') || '';
|
||||
const batchModel = (c.req.query('batchModel') || '').trim();
|
||||
const metric = c.req.query('metric') === 'vehicles' ? 'vehicles' : 'locations';
|
||||
const records = await loadRecords(startDate, endDate, query, batchModel);
|
||||
const precision = daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
|
||||
const grid = new Map<string, { lng: number; lat: number; count: number; vins?: Set<string> }>();
|
||||
|
||||
for (const record of records) {
|
||||
const lng = Number(record.longitude.toFixed(precision));
|
||||
const lat = Number(record.latitude.toFixed(precision));
|
||||
const key = `${lng},${lat}`;
|
||||
let point = grid.get(key);
|
||||
if (!point) {
|
||||
point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set<string>() : undefined };
|
||||
grid.set(key, point);
|
||||
}
|
||||
if (point.vins) {
|
||||
point.vins.add(record.vin);
|
||||
point.count = point.vins.size;
|
||||
} else {
|
||||
point.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count }));
|
||||
let max = 1;
|
||||
for (const point of points) max = Math.max(max, point.count);
|
||||
|
||||
return c.json({
|
||||
startDate,
|
||||
endDate,
|
||||
metric,
|
||||
locationCount: records.length,
|
||||
vehicleCount: new Set(records.map((record) => record.vin)).size,
|
||||
dayCount: new Set(records.map((record) => record.date)).size,
|
||||
points,
|
||||
max,
|
||||
topVehicles: buildRanking(records),
|
||||
});
|
||||
});
|
||||
|
||||
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')! : '2026-01-01';
|
||||
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : '2026-07-13';
|
||||
const query = c.req.query('query') || '';
|
||||
const batchModel = (c.req.query('batchModel') || '').trim();
|
||||
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
|
||||
const records = await loadRecords(startDate, endDate, query, batchModel);
|
||||
const nearby = records.filter((record) => (
|
||||
haversineKm(longitude, latitude, record.longitude, record.latitude) <= radiusKm
|
||||
));
|
||||
|
||||
return c.json({
|
||||
center: { lng: longitude, lat: latitude },
|
||||
radiusKm,
|
||||
locationCount: nearby.length,
|
||||
vehicleCount: new Set(nearby.map((record) => record.vin)).size,
|
||||
topVehicles: buildRanking(nearby),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user