feat(gps): last_seen по времени приёма, фильтр джиттера (настройки), линейка, трек по скорости; fix: карта не перекрывает диалоги (isolation)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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, // время последней точки по устройству
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user