fix: restore post-v1.1.14 features while preserving asset updates
Undo the full-tree rollback in 6ea1b3d and restore the pre-rollback v1.2.0 code. Preserve the new asset flow rules, abnormal inventory separation, range drilldowns and date picker; adapt the regression test to the restored routes layout.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildVehicleGrid,
|
||||
buildVehicleRanking,
|
||||
daysInclusive,
|
||||
filterVehicleRecordsByRadius,
|
||||
haversineKm,
|
||||
parseNearbyQuery,
|
||||
parseVehicleHeatmapQuery,
|
||||
summarizeVehicleRecords,
|
||||
vehicleGridPrecision,
|
||||
type VehicleHeatmapRecord,
|
||||
} 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 record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRecord {
|
||||
return {
|
||||
date: '2026-07-01',
|
||||
vin: 'VIN-A',
|
||||
plate: '粤A00001',
|
||||
time: '2026-07-01 08:00:00',
|
||||
longitude: 120,
|
||||
latitude: 30,
|
||||
source: 'test',
|
||||
sourceRecordId: '1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('车辆热力图查询默认使用近 30 天滚动窗口,并保留原有字符串处理规则', () => {
|
||||
assert.deepEqual(parseVehicleHeatmapQuery({}, NOW), {
|
||||
startDate: FALLBACK.start,
|
||||
endDate: FALLBACK.end,
|
||||
query: '',
|
||||
batchModel: '',
|
||||
metric: 'locations',
|
||||
});
|
||||
assert.deepEqual(parseVehicleHeatmapQuery({
|
||||
startDate: '2026-06-01',
|
||||
endDate: 'invalid',
|
||||
query: ' 粤A ',
|
||||
batchModel: ' 190台冷链 ',
|
||||
metric: 'vehicles',
|
||||
}, NOW), {
|
||||
startDate: '2026-06-01',
|
||||
endDate: FALLBACK.end,
|
||||
query: ' 粤A ',
|
||||
batchModel: '190台冷链',
|
||||
metric: 'vehicles',
|
||||
});
|
||||
});
|
||||
|
||||
test('附近查询继续沿用经纬度 Number 语义和 5 至 200 公里半径限制', () => {
|
||||
assert.deepEqual(parseNearbyQuery({ lng: '', lat: '0', radiusKm: '0' }), {
|
||||
center: { lng: 0, lat: 0 },
|
||||
radiusKm: 50,
|
||||
});
|
||||
assert.deepEqual(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '-1' }), {
|
||||
center: { lng: 120, lat: 30 },
|
||||
radiusKm: 5,
|
||||
});
|
||||
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '500' }).radiusKm, 200);
|
||||
assert.equal(parseNearbyQuery({ lng: 'bad', lat: '30' }).center, null);
|
||||
});
|
||||
|
||||
test('网格精度继续按含首尾天数和筛选条件决定', () => {
|
||||
assert.equal(daysInclusive('2026-07-01', '2026-07-14'), 14);
|
||||
assert.equal(daysInclusive('2026-07-14', '2026-07-01'), 0);
|
||||
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-14', '', ''), 3);
|
||||
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-15', '', ''), 2);
|
||||
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-15', ' ', ''), 3);
|
||||
});
|
||||
|
||||
test('车辆网格继续区分位置次数和去重车辆数并保留插入顺序', () => {
|
||||
const records = [
|
||||
record({ vin: 'VIN-A', longitude: 120.1231, latitude: 30.9878 }),
|
||||
record({ vin: 'VIN-A', longitude: 120.1232, latitude: 30.9879 }),
|
||||
record({ vin: 'VIN-B', longitude: 120.1233, latitude: 30.9877 }),
|
||||
record({ vin: 'VIN-C', longitude: 121, latitude: 31 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildVehicleGrid(records, 'locations', 3), {
|
||||
points: [
|
||||
{ lng: 120.123, lat: 30.988, count: 3 },
|
||||
{ lng: 121, lat: 31, count: 1 },
|
||||
],
|
||||
max: 3,
|
||||
});
|
||||
assert.deepEqual(buildVehicleGrid(records, 'vehicles', 3), {
|
||||
points: [
|
||||
{ lng: 120.123, lat: 30.988, count: 2 },
|
||||
{ lng: 121, lat: 31, count: 1 },
|
||||
],
|
||||
max: 2,
|
||||
});
|
||||
assert.deepEqual(buildVehicleGrid([], 'locations', 2), { points: [], max: 1 });
|
||||
});
|
||||
|
||||
test('车辆排名继续按记录数、车牌排序并保留首末出现时间', () => {
|
||||
const records = [
|
||||
record({ vin: 'VIN-B', plate: '', time: '2026-07-02 10:00:00' }),
|
||||
record({ vin: 'VIN-A', plate: '粤A00001', time: '2026-07-02 09:00:00' }),
|
||||
record({ vin: 'VIN-B', plate: '粤B00001', time: '2026-07-01 08:00:00' }),
|
||||
record({ vin: 'VIN-A', plate: '粤A00001', time: '2026-07-03 11:00:00' }),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildVehicleRanking(records), [
|
||||
{
|
||||
vin: 'VIN-A',
|
||||
plateNumber: '粤A00001',
|
||||
locationCount: 2,
|
||||
firstSeen: '2026-07-02 09:00:00',
|
||||
lastSeen: '2026-07-03 11:00:00',
|
||||
},
|
||||
{
|
||||
vin: 'VIN-B',
|
||||
plateNumber: '粤B00001',
|
||||
locationCount: 2,
|
||||
firstSeen: '2026-07-01 08:00:00',
|
||||
lastSeen: '2026-07-02 10:00:00',
|
||||
},
|
||||
]);
|
||||
assert.equal(buildVehicleRanking(records, 1).length, 1);
|
||||
});
|
||||
|
||||
test('车辆统计和附近筛选继续使用 VIN、自然日去重及 Haversine 距离', () => {
|
||||
const records = [
|
||||
record({ vin: 'VIN-A', date: '2026-07-01' }),
|
||||
record({ vin: 'VIN-A', date: '2026-07-02', longitude: 120.01 }),
|
||||
record({ vin: 'VIN-B', date: '2026-07-02', longitude: 121 }),
|
||||
];
|
||||
|
||||
assert.deepEqual(summarizeVehicleRecords(records), {
|
||||
locationCount: 3,
|
||||
vehicleCount: 2,
|
||||
dayCount: 2,
|
||||
});
|
||||
assert.equal(haversineKm(120, 30, 120, 30), 0);
|
||||
assert.ok(Math.abs(haversineKm(0, 0, 0, 1) - 111.195) < 0.01);
|
||||
assert.deepEqual(
|
||||
filterVehicleRecordsByRadius(records, { lng: 120, lat: 30 }, 5).map(({ vin, date }) => ({ vin, date })),
|
||||
[
|
||||
{ vin: 'VIN-A', date: '2026-07-01' },
|
||||
{ vin: 'VIN-A', date: '2026-07-02' },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { recentDayRange } from '../../../shared/date-range.js';
|
||||
export type VehicleHeatmapMetric = 'locations' | 'vehicles';
|
||||
|
||||
export type VehicleHeatmapRecord = {
|
||||
date: string;
|
||||
vin: string;
|
||||
plate: string;
|
||||
time: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
source: string;
|
||||
sourceRecordId: string;
|
||||
};
|
||||
|
||||
export type VehicleRank = {
|
||||
vin: string;
|
||||
plateNumber: string;
|
||||
locationCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
};
|
||||
|
||||
type VehicleHeatmapQueryInput = {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
query?: string;
|
||||
batchModel?: string;
|
||||
metric?: string;
|
||||
};
|
||||
|
||||
type NearbyQueryInput = {
|
||||
lng?: string;
|
||||
lat?: string;
|
||||
radiusKm?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 未显式传日期时的兜底窗口。
|
||||
* 此前写死为 2026-01-01 ~ 2026-07-13,会让默认查询永久指向一段历史数据;
|
||||
* 现改为"近 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));
|
||||
}
|
||||
|
||||
export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput, 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 || '',
|
||||
batchModel: (input.batchModel || '').trim(),
|
||||
metric: input.metric === 'vehicles' ? 'vehicles' as const : 'locations' as const,
|
||||
};
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
export function vehicleGridPrecision(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
query: string,
|
||||
batchModel: string,
|
||||
): number {
|
||||
return daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
|
||||
}
|
||||
|
||||
export function buildVehicleGrid(
|
||||
records: VehicleHeatmapRecord[],
|
||||
metric: VehicleHeatmapMetric,
|
||||
precision: number,
|
||||
) {
|
||||
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 { points, max };
|
||||
}
|
||||
|
||||
export function buildVehicleRanking(records: VehicleHeatmapRecord[], 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);
|
||||
}
|
||||
|
||||
export function summarizeVehicleRecords(records: VehicleHeatmapRecord[]) {
|
||||
return {
|
||||
locationCount: records.length,
|
||||
vehicleCount: new Set(records.map((record) => record.vin)).size,
|
||||
dayCount: new Set(records.map((record) => record.date)).size,
|
||||
};
|
||||
}
|
||||
|
||||
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 filterVehicleRecordsByRadius(
|
||||
records: VehicleHeatmapRecord[],
|
||||
center: { lng: number; lat: number },
|
||||
radiusKm: number,
|
||||
): VehicleHeatmapRecord[] {
|
||||
return records.filter((record) => (
|
||||
haversineKm(center.lng, center.lat, record.longitude, record.latitude) <= radiusKm
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { VehicleHeatmapRecord } from './model.js';
|
||||
|
||||
/**
|
||||
* 车辆热力图的数据访问。
|
||||
*
|
||||
* 该域同时使用两个库:主业务库(MySQL,考核批次 → 车牌映射)与车辆位置库
|
||||
* (PostgreSQL 只读,定位点)。两者的 query 返回形状不同,因此分别声明依赖,
|
||||
* 并由 routes.test.ts 的契约测试锁定 SQL 与参数。
|
||||
*/
|
||||
|
||||
/** MySQL 连接:只声明用到的能力。 */
|
||||
export interface MysqlDatabase {
|
||||
execute<T = any>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
|
||||
}
|
||||
|
||||
/** PostgreSQL 连接:pg 的返回形状是 { rows }。 */
|
||||
export interface PgDatabase {
|
||||
query<T = any>(sql: string, values?: any[]): Promise<{ rows: T[] }>;
|
||||
}
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
/** 考核批次 → 车牌集合。 */
|
||||
export async function loadBatchModelPlates(mysql: MysqlDatabase): Promise<Map<string, Set<string>>> {
|
||||
const [rows] = await mysql.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;
|
||||
}
|
||||
|
||||
/** 与既有实现一致:把首行直接当作 MetaRow(缺行时由调用方行为保持不变)。 */
|
||||
export async function loadMeta(pg: PgDatabase): Promise<MetaRow> {
|
||||
const metaResult = await pg.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
|
||||
`);
|
||||
return metaResult.rows[0] as MetaRow;
|
||||
}
|
||||
|
||||
export async function loadVehicleOptions(pg: PgDatabase): Promise<VehicleOptionRow[]> {
|
||||
const vehiclesResult = await pg.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
|
||||
`);
|
||||
return vehiclesResult.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐车每日首个有效定位点。
|
||||
* `$4` 沿用原实现的语义——是否选择了考核批次(而不是"车牌集合是否为空"),
|
||||
* 调用方需在批次无车牌时提前返回,不要靠这里兜底。
|
||||
*/
|
||||
export async function loadRecords(
|
||||
pg: PgDatabase,
|
||||
args: { startDate: string; endDate: string; query: string; batchModelSelected: boolean; batchPlates: string[] },
|
||||
): Promise<VehicleHeatmapRecord[]> {
|
||||
const result = await pg.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
|
||||
`, [args.startDate, args.endDate, args.query.trim(), args.batchModelSelected, args.batchPlates]);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
date: row.date,
|
||||
vin: row.vin,
|
||||
plate: row.plate,
|
||||
time: row.time,
|
||||
longitude: Number(row.longitude),
|
||||
latitude: Number(row.latitude),
|
||||
source: row.source,
|
||||
sourceRecordId: row.source_record_id,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { Hono } from "hono";
|
||||
import { registerVehicleHeatmapRoutes, type VehicleHeatmapDependencies } from "./routes.js";
|
||||
import type { MysqlDatabase, PgDatabase } from "./repository.js";
|
||||
|
||||
interface Call {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
||||
|
||||
function createMysql(results: unknown[] = []): { db: MysqlDatabase; calls: Call[] } {
|
||||
const calls: Call[] = [];
|
||||
const queue = [...results];
|
||||
return {
|
||||
calls,
|
||||
db: {
|
||||
async execute(sql: string, params?: any[]) {
|
||||
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||||
return [queue.shift() ?? [], []] as never;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPg(results: { rows: unknown[] }[] = []): { db: PgDatabase; calls: Call[] } {
|
||||
const calls: Call[] = [];
|
||||
const queue = [...results];
|
||||
return {
|
||||
calls,
|
||||
db: {
|
||||
async query(sql: string, params?: any[]) {
|
||||
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||||
return (queue.shift() ?? { rows: [] }) as never;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeApp(deps: VehicleHeatmapDependencies): Hono {
|
||||
const app = new Hono();
|
||||
registerVehicleHeatmapRoutes(app, deps);
|
||||
return app;
|
||||
}
|
||||
|
||||
const META_ROW = {
|
||||
start_date: "2026-01-01",
|
||||
end_date: "2026-07-13",
|
||||
location_count: "10",
|
||||
vehicle_count: "3",
|
||||
day_count: "5",
|
||||
total_location_count: "12",
|
||||
excluded_location_count: "2",
|
||||
outside_mainland_count: "1",
|
||||
tibet_count: "1",
|
||||
};
|
||||
|
||||
const RECORD_ROW = {
|
||||
date: "2026-07-01",
|
||||
vin: "VIN1",
|
||||
plate: "粤A1",
|
||||
time: "2026-07-01 08:00:00",
|
||||
longitude: "113.1",
|
||||
latitude: "23.2",
|
||||
source: "gps",
|
||||
source_record_id: "r1",
|
||||
};
|
||||
|
||||
const EMPTY_BATCH_PLATES = [[], []];
|
||||
|
||||
test("/config:缺少高德配置时返回 503", async () => {
|
||||
const prevKey = process.env.AMAP_WEB_KEY;
|
||||
const prevCode = process.env.AMAP_SECURITY_JS_CODE;
|
||||
delete process.env.AMAP_WEB_KEY;
|
||||
delete process.env.AMAP_SECURITY_JS_CODE;
|
||||
try {
|
||||
const app = makeApp({ mysql: createMysql().db, pg: createPg().db });
|
||||
const res = await app.request("/config");
|
||||
assert.equal(res.status, 503);
|
||||
} finally {
|
||||
if (prevKey !== undefined) process.env.AMAP_WEB_KEY = prevKey;
|
||||
if (prevCode !== undefined) process.env.AMAP_SECURITY_JS_CODE = prevCode;
|
||||
}
|
||||
});
|
||||
|
||||
test("/meta:两条定位库查询与批次映射的 SQL 原样保留", async () => {
|
||||
const mysql = createMysql([[]]);
|
||||
const pg = createPg([{ rows: [META_ROW] }, { rows: [{ vin: "VIN2", plate_number: "粤B2" }] }]);
|
||||
// /meta issues: loadMeta, loadVehicleOptions (parallel) + batch plates
|
||||
const app = makeApp({ mysql: mysql.db, pg: pg.db, cachedBatchPlates: () => new Map([["B1", new Set(["粤A1"])]]) });
|
||||
|
||||
const res = await app.request("/meta");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
assert.deepEqual(body, {
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-07-13",
|
||||
locationCount: 10,
|
||||
vehicleCount: 3,
|
||||
dayCount: 5,
|
||||
totalLocationCount: 12,
|
||||
excludedLocationCount: 2,
|
||||
outsideMainlandCount: 1,
|
||||
tibetCount: 1,
|
||||
batchModels: ["B1"],
|
||||
vehicles: [{ vin: "VIN2", plateNumber: "粤B2" }],
|
||||
});
|
||||
|
||||
assert.equal(pg.calls.length, 2);
|
||||
assert.equal(
|
||||
pg.calls[0].sql,
|
||||
"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",
|
||||
);
|
||||
assert.equal(pg.calls[1].sql.includes("select distinct on (vin) vin, plate_number"), true);
|
||||
assert.equal(mysql.calls.length, 0, "命中缓存的批次映射不应查主库");
|
||||
});
|
||||
|
||||
test("/meta:缓存缺失时查主库取批次车牌,失败按空映射降级", async () => {
|
||||
const mysql = createMysql([[{ target_name: "B1", plate_number: "粤A1" }, { target_name: "B1", plate_number: "粤A2" }]]);
|
||||
const pg = createPg([{ rows: [META_ROW] }, { rows: [] }]);
|
||||
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||
|
||||
const res = await app.request("/meta");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(mysql.calls.length, 1);
|
||||
assert.equal(
|
||||
mysql.calls[0].sql,
|
||||
"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",
|
||||
);
|
||||
});
|
||||
|
||||
test("/points:日期倒置返回 400 且不查定位库", async () => {
|
||||
const mysql = createMysql();
|
||||
const pg = createPg();
|
||||
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||
|
||||
const res = await app.request("/points?startDate=2026-07-10&endDate=2026-07-01");
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(pg.calls.length, 0);
|
||||
});
|
||||
|
||||
test("/points:定位点查询的 $4/$5 语义与参数顺序保持不变", async () => {
|
||||
const mysql = createMysql();
|
||||
const pg = createPg([{ rows: [RECORD_ROW] }]);
|
||||
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||
|
||||
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&query=%20VIN1%20&metric=locations");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
assert.equal(body.locationCount, 1);
|
||||
assert.equal(body.vehicleCount, 1);
|
||||
assert.equal(body.metric, "locations");
|
||||
assert.equal(Array.isArray(body.points), true);
|
||||
assert.deepEqual(body.topVehicles, [{ vin: "VIN1", plateNumber: "粤A1", locationCount: 1, firstSeen: RECORD_ROW.time, lastSeen: RECORD_ROW.time }]);
|
||||
|
||||
assert.equal(pg.calls.length, 1);
|
||||
assert.equal(pg.calls[0].sql.includes("from analytics.vehicle_daily_first_location where stat_date between $1::date and $2::date"), true);
|
||||
assert.equal(pg.calls[0].sql.includes("and ($4::boolean = false or plate_number = any($5::text[]))"), true);
|
||||
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "VIN1", false, []]);
|
||||
});
|
||||
|
||||
test("/points:选择批次但批次无车牌时直接返回空,不查定位库", async () => {
|
||||
const mysql = createMysql([[]]); // batch model exists but has no plates
|
||||
const pg = createPg();
|
||||
const app = makeApp({ mysql: mysql.db, pg: pg.db });
|
||||
|
||||
const res = await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=unknown");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
assert.equal(body.locationCount, 0);
|
||||
assert.deepEqual(body.points, []);
|
||||
assert.equal(pg.calls.length, 0, "无车牌时不应查定位库");
|
||||
});
|
||||
|
||||
test("/points:选定批次时 $4=true 且 $5 为车牌数组", async () => {
|
||||
const mysql = createMysql([[]]);
|
||||
const pg = createPg([{ rows: [] }]);
|
||||
const app = makeApp({
|
||||
mysql: mysql.db,
|
||||
pg: pg.db,
|
||||
cachedBatchPlates: () => new Map([["B1", new Set(["粤A1", "粤A2"])]]),
|
||||
});
|
||||
|
||||
await app.request("/points?startDate=2026-07-01&endDate=2026-07-02&batchModel=B1");
|
||||
assert.deepEqual(pg.calls[0].params, ["2026-07-01", "2026-07-02", "", true, ["粤A1", "粤A2"]]);
|
||||
});
|
||||
|
||||
test("/nearby:非法经纬度返回 400", async () => {
|
||||
const app = makeApp({ mysql: createMysql().db, pg: createPg().db });
|
||||
const res = await app.request("/nearby?lng=abc&lat=23");
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("/nearby:半径过滤与排名口径不变", async () => {
|
||||
const pg = createPg([{ rows: [
|
||||
{ ...RECORD_ROW, longitude: "113.1", latitude: "23.2" },
|
||||
{ ...RECORD_ROW, vin: "VIN2", source_record_id: "r2", longitude: "120.0", latitude: "30.0" },
|
||||
] }]);
|
||||
const app = makeApp({ mysql: createMysql().db, pg: pg.db });
|
||||
|
||||
const res = await app.request("/nearby?lng=113.1&lat=23.2&radiusKm=5&startDate=2026-07-01&endDate=2026-07-02");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
assert.equal(body.locationCount, 1, "只保留半径内的点");
|
||||
assert.equal(body.vehicleCount, 1);
|
||||
assert.equal(body.radiusKm, 5);
|
||||
});
|
||||
|
||||
test("分层:路由文件不再直接书写 SQL", () => {
|
||||
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
|
||||
const offenders = source.match(/\b(select|insert into|insert ignore|update |delete from|create table|alter table)\b/gi) ?? [];
|
||||
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts(注意本域 SQL 为小写)");
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Hono } from 'hono';
|
||||
import mysqlPool from '../../db/mysql.js';
|
||||
import heatmapPool from '../../db/heatmap.js';
|
||||
import { getCache } from '../mileage/cache.js';
|
||||
import {
|
||||
buildVehicleGrid,
|
||||
buildVehicleRanking,
|
||||
filterVehicleRecordsByRadius,
|
||||
parseNearbyQuery,
|
||||
parseVehicleHeatmapQuery,
|
||||
summarizeVehicleRecords,
|
||||
vehicleGridPrecision,
|
||||
type VehicleHeatmapRecord,
|
||||
} from './model.js';
|
||||
import {
|
||||
loadBatchModelPlates,
|
||||
loadMeta,
|
||||
loadRecords,
|
||||
loadVehicleOptions,
|
||||
type MysqlDatabase,
|
||||
type PgDatabase,
|
||||
} from './repository.js';
|
||||
|
||||
export interface VehicleHeatmapDependencies {
|
||||
mysql: MysqlDatabase;
|
||||
pg: PgDatabase;
|
||||
/** 里程缓存里的考核批次车牌映射(可命中热缓存,避免每次查主库)。 */
|
||||
cachedBatchPlates?: () => Map<string, Set<string>> | undefined;
|
||||
}
|
||||
|
||||
export function registerVehicleHeatmapRoutes(app: Hono, deps: VehicleHeatmapDependencies): void {
|
||||
const { mysql, pg } = deps;
|
||||
|
||||
/**
|
||||
* 批次 → 车牌映射。优先用里程缓存;缓存缺失时查主库。
|
||||
* 查询失败按"无批次数据"降级,不影响其他筛选(与既有行为一致)。
|
||||
*/
|
||||
async function resolveBatchModelPlates(): Promise<Map<string, Set<string>>> {
|
||||
const cached = deps.cachedBatchPlates?.() ?? getCache()?.targetPlatesMap;
|
||||
if (cached?.size) return cached;
|
||||
try {
|
||||
return await loadBatchModelPlates(mysql);
|
||||
} catch (error) {
|
||||
console.error('[vehicle-heatmap] batch model lookup failed', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRecords(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
query: string,
|
||||
batchModel: string,
|
||||
): Promise<VehicleHeatmapRecord[]> {
|
||||
let batchPlates: string[] = [];
|
||||
if (batchModel) {
|
||||
const modelPlates = await resolveBatchModelPlates();
|
||||
batchPlates = [...(modelPlates.get(batchModel) || [])];
|
||||
if (batchPlates.length === 0) return [];
|
||||
}
|
||||
return loadRecords(pg, {
|
||||
startDate,
|
||||
endDate,
|
||||
query,
|
||||
batchModelSelected: Boolean(batchModel),
|
||||
batchPlates,
|
||||
});
|
||||
}
|
||||
|
||||
app.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 });
|
||||
});
|
||||
|
||||
app.get('/meta', async (c) => {
|
||||
const [meta, vehicleRows, batchModelPlates] = await Promise.all([
|
||||
loadMeta(pg),
|
||||
loadVehicleOptions(pg),
|
||||
resolveBatchModelPlates(),
|
||||
]);
|
||||
const vehicles = vehicleRows
|
||||
.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,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/points', async (c) => {
|
||||
const { startDate, endDate, query, batchModel, metric } = parseVehicleHeatmapQuery({
|
||||
startDate: c.req.query('startDate'),
|
||||
endDate: c.req.query('endDate'),
|
||||
query: c.req.query('query'),
|
||||
batchModel: c.req.query('batchModel'),
|
||||
metric: c.req.query('metric'),
|
||||
});
|
||||
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
|
||||
|
||||
const records = await collectRecords(startDate, endDate, query, batchModel);
|
||||
const precision = vehicleGridPrecision(startDate, endDate, query, batchModel);
|
||||
const { points, max } = buildVehicleGrid(records, metric, precision);
|
||||
const summary = summarizeVehicleRecords(records);
|
||||
|
||||
return c.json({
|
||||
startDate,
|
||||
endDate,
|
||||
metric,
|
||||
locationCount: summary.locationCount,
|
||||
vehicleCount: summary.vehicleCount,
|
||||
dayCount: summary.dayCount,
|
||||
points,
|
||||
max,
|
||||
topVehicles: buildVehicleRanking(records),
|
||||
});
|
||||
});
|
||||
|
||||
app.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, batchModel } = parseVehicleHeatmapQuery({
|
||||
startDate: c.req.query('startDate'),
|
||||
endDate: c.req.query('endDate'),
|
||||
query: c.req.query('query'),
|
||||
batchModel: c.req.query('batchModel'),
|
||||
});
|
||||
const records = await collectRecords(startDate, endDate, query, batchModel);
|
||||
const nearby = filterVehicleRecordsByRadius(records, center, radiusKm);
|
||||
const summary = summarizeVehicleRecords(nearby);
|
||||
|
||||
return c.json({
|
||||
center,
|
||||
radiusKm,
|
||||
locationCount: summary.locationCount,
|
||||
vehicleCount: summary.vehicleCount,
|
||||
topVehicles: buildVehicleRanking(nearby),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 生产用路由器:绑定真实连接池。 */
|
||||
export function createVehicleHeatmapRouter(): Hono {
|
||||
const app = new Hono();
|
||||
registerVehicleHeatmapRoutes(app, { mysql: mysqlPool, pg: heatmapPool });
|
||||
return app;
|
||||
}
|
||||
|
||||
const app = createVehicleHeatmapRouter();
|
||||
export default app;
|
||||
Reference in New Issue
Block a user