feat(gps): last_seen по времени приёма, фильтр джиттера (настройки), линейка, трек по скорости; fix: карта не перекрывает диалоги (isolation)

This commit is contained in:
2026-08-25 17:01:44 +03:00
parent 670cecfe05
commit b635d1e16e
12 changed files with 453 additions and 34 deletions

View File

@@ -31,6 +31,18 @@ export function pointInPolygon(lat: number, lng: number, polygon: Array<[number,
return inside;
}
/** Расстояние между двумя точками (haversine), в метрах. Без зависимостей. */
export function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371000; // радиус Земли, м
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
export interface ProcessPositionInput {
lat: number;
lng: number;
@@ -43,6 +55,16 @@ export interface ProcessPositionInput {
/**
* Основная точка входа: сохраняет позицию, обновляет last_* объекта,
* проверяет геозоны и рассылает SSE/уведомления.
*
* Семантика времени:
* - last_seen_at = ВСЕГДА время ПРИЁМА сервером (онлайн-индикатор на карте);
* - last_recorded_at = время точки по устройству (fixTime трекера);
* - gps_positions.recorded_at = время устройства (для треков).
*
* Фильтр джиттера: если в настройках jitterMeters > 0, у объекта есть
* предыдущая точка, расстояние до неё < jitterMeters и скорость мала
* (null или < 5 км/ч) — точка отбрасывается: не пишется в gps_positions,
* координаты и геозоны не трогаются, но last_seen_at/is_online обновляются.
*/
export async function processPosition(
assetId: number,
@@ -51,7 +73,26 @@ export async function processPosition(
): Promise<void> {
const recordedAt = input.recordedAt ?? new Date();
// 1. Сохраняем точку в историю
const asset = await gpsStorage.getAsset(assetId, organizationId);
if (!asset) return;
// Фильтр джиттера (округление мелких перемещений)
const settings = await gpsStorage.getSettings(organizationId);
if (
settings.jitterMeters > 0 &&
asset.lastLat !== null &&
asset.lastLng !== null
) {
const distance = haversineMeters(asset.lastLat, asset.lastLng, input.lat, input.lng);
const speed = input.speed ?? null;
if (distance < settings.jitterMeters && (speed === null || speed < 5)) {
// Устройство живо — фиксируем факт приёма, точку не сохраняем
await gpsStorage.touchAssetSeen(assetId);
return;
}
}
// 1. Сохраняем точку в историю (recorded_at — время устройства, для треков)
await gpsStorage.insertPosition({
assetId,
organizationId,
@@ -63,7 +104,7 @@ export async function processPosition(
recordedAt,
});
// 2. Обновляем последнюю позицию объекта (и переводим в online)
// 2. Обновляем последнюю позицию объекта (last_seen_at = время приёма, online)
await gpsStorage.updateAssetLastPosition(assetId, {
lat: input.lat,
lng: input.lng,
@@ -88,21 +129,19 @@ export async function processPosition(
});
// 4. Проверка геозон
await checkGeozones(assetId, organizationId, input, recordedAt);
await checkGeozones(asset, input, recordedAt);
}
async function checkGeozones(
assetId: number,
organizationId: number,
asset: GpsAsset,
input: ProcessPositionInput,
recordedAt: Date
): Promise<void> {
const { id: assetId, organizationId } = asset;
const geozones = await gpsStorage.listActiveGeozones(organizationId);
if (geozones.length === 0) return;
const states = await gpsStorage.getGeozoneStatesForAsset(assetId);
const asset = await gpsStorage.getAsset(assetId, organizationId);
if (!asset) return;
for (const geozone of geozones) {
try {

View File

@@ -167,6 +167,24 @@ export function registerGpsRoutes(app: Express) {
res.json({ success: true, yandexMapsKey });
});
// =====================
// Настройки GPS организации (фильтр джиттера)
// =====================
router.get("/settings", async (req: AuthenticatedRequest, res: Response) => {
const settings = await gpsStorage.getSettings(req.user!.organizationId);
res.json({ success: true, jitterMeters: settings.jitterMeters });
});
router.put("/settings", async (req: AuthenticatedRequest, res: Response) => {
const jitterMeters = Number(req.body?.jitterMeters);
if (!Number.isInteger(jitterMeters) || jitterMeters < 0 || jitterMeters > 1000) {
return badRequest(res, "jitterMeters: целое число от 0 до 1000");
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, jitterMeters);
res.json({ success: true, jitterMeters: row.jitterMeters });
});
// =====================
// Объекты (assets)
// =====================
@@ -267,7 +285,9 @@ export function registerGpsRoutes(app: Express) {
lng: a.lastLng,
speed: a.lastSpeed,
course: a.lastCourse,
recordedAt: a.lastSeenAt,
recordedAt: a.lastSeenAt, // время приёма последней точки сервером
lastSeenAt: a.lastSeenAt,
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
})),
});
});

View File

@@ -8,12 +8,14 @@ import {
gpsGroups,
gpsGroupAssets,
gpsAssetSubscribers,
gpsSettings,
type GpsAsset,
type GpsPosition,
type GpsGeozone,
type GpsGeozoneEvent,
type GpsGroup,
type GpsAssetSubscriber,
type GpsSettings,
type InsertGpsAsset,
type InsertGpsGeozone,
type InsertGpsPosition,
@@ -140,7 +142,11 @@ export class GpsStorage {
return deleted.length > 0;
}
/** Обновление последней позиции объекта (и перевод в online) */
/**
* Обновление последней позиции объекта (и перевод в online).
* last_seen_at = время ПРИЁМА сервером (new Date() здесь),
* last_recorded_at = время точки по устройству (recordedAt).
*/
async updateAssetLastPosition(
assetId: number,
data: { lat: number; lng: number; speed?: number | null; course?: number | null; recordedAt: Date }
@@ -152,12 +158,24 @@ export class GpsStorage {
lastLng: data.lng,
lastSpeed: data.speed ?? null,
lastCourse: data.course ?? null,
lastSeenAt: data.recordedAt,
lastSeenAt: new Date(),
lastRecordedAt: data.recordedAt,
isOnline: true,
})
.where(eq(gpsAssets.id, assetId));
}
/**
* Отметка «устройство живо» без обновления координат
* (точка отброшена фильтром джиттера, но приём был).
*/
async touchAssetSeen(assetId: number): Promise<void> {
await db
.update(gpsAssets)
.set({ lastSeenAt: new Date(), isOnline: true })
.where(eq(gpsAssets.id, assetId));
}
async setAssetOnline(assetId: number, isOnline: boolean): Promise<void> {
await db.update(gpsAssets).set({ isOnline }).where(eq(gpsAssets.id, assetId));
}
@@ -438,6 +456,31 @@ export class GpsStorage {
)
.returning();
}
// =====================
// Настройки организации
// =====================
/** Настройки GPS организации; если строки нет — дефолт */
async getSettings(organizationId: number): Promise<{ jitterMeters: number }> {
const [row] = await db
.select()
.from(gpsSettings)
.where(eq(gpsSettings.organizationId, organizationId));
return { jitterMeters: row?.jitterMeters ?? 25 };
}
async upsertSettings(organizationId: number, jitterMeters: number): Promise<GpsSettings> {
const [row] = await db
.insert(gpsSettings)
.values({ organizationId, jitterMeters, updatedAt: new Date() })
.onConflictDoUpdate({
target: gpsSettings.organizationId,
set: { jitterMeters, updatedAt: new Date() },
})
.returning();
return row;
}
}
export const gpsStorage = new GpsStorage();