feat(gps): индикатор потери GPS у объектов (last_gps_fix_at из stale re-send/принятых точек), настройка порога в часах

This commit is contained in:
2026-08-28 10:48:32 +03:00
parent 96e088c890
commit 1af4126f7c
5 changed files with 47 additions and 1 deletions

View File

@@ -0,0 +1,3 @@
-- Индикатор потери GPS: время последнего фикса по устройству и порог в часах
ALTER TABLE gps_assets ADD COLUMN IF NOT EXISTS last_gps_fix_at timestamp;
ALTER TABLE gps_settings ADD COLUMN IF NOT EXISTS gps_lost_hours integer NOT NULL DEFAULT 5;

View File

@@ -346,6 +346,9 @@ export async function processPosition(
const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt, speed: input.speed });
if (verdict !== "accept") {
if (verdict === "stale") {
// Stale re-send = трекер сам сообщает, когда в последний раз видел спутники
// (замороженный fixTime). Фиксируем как «время последнего GPS-фикса».
await gpsStorage.updateAssetGpsFix(assetId, recordedAt);
console.log(
`[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
);
@@ -366,6 +369,9 @@ export async function processPosition(
return;
}
// Точка прошла фильтры — GPS-фикс жив, двигаем время последнего фикса вперёд
await gpsStorage.updateAssetGpsFix(assetId, recordedAt, true);
// Фильтр джиттера (округление мелких перемещений)
if (settings.jitterMeters > 0 && prev) {
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);

View File

@@ -306,9 +306,14 @@ export function registerGpsRoutes(app: Express) {
if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)");
}
const gpsLostHours =
req.body?.gpsLostHours !== undefined ? Number(req.body.gpsLostHours) : current.gpsLostHours;
if (!Number.isInteger(tripStopMinutes) || tripStopMinutes < 0 || tripStopMinutes > 120) {
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
}
if (!Number.isInteger(gpsLostHours) || gpsLostHours < 1 || gpsLostHours > 72) {
return badRequest(res, "gpsLostHours: целое число от 1 до 72");
}
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
let defaultLat = current.defaultLat;
@@ -347,6 +352,7 @@ export function registerGpsRoutes(app: Express) {
jitterMeters,
maxJumpSpeedKmh,
tripStopMinutes,
gpsLostHours,
defaultLat,
defaultLng,
defaultZoom,
@@ -356,6 +362,7 @@ export function registerGpsRoutes(app: Express) {
jitterMeters: row.jitterMeters,
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
gpsLostHours: row.gpsLostHours,
defaultLat: row.defaultLat,
defaultLng: row.defaultLng,
defaultZoom: row.defaultZoom,
@@ -465,6 +472,7 @@ export function registerGpsRoutes(app: Express) {
recordedAt: a.lastSeenAt, // время приёма последней точки сервером
lastSeenAt: a.lastSeenAt,
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
lastGpsFixAt: a.lastGpsFixAt, // время последнего GPS-фикса (для индикатора потери спутников)
})),
});
});

View File

@@ -176,6 +176,29 @@ export class GpsStorage {
.where(eq(gpsAssets.id, assetId));
}
/**
* Обновить время последнего GPS-фикса по устройству.
* При stale re-send — замороженный fixTime (трекер сам сообщает,
* когда в последний раз видел спутники); при принятой свежей точке —
* её recordedAt (двигаем только вперёд).
*/
async updateAssetGpsFix(assetId: number, recordedAt: Date, onlyForward = false): Promise<void> {
if (onlyForward) {
await db
.update(gpsAssets)
.set({ lastGpsFixAt: recordedAt })
.where(and(
eq(gpsAssets.id, assetId),
sql`(last_gps_fix_at IS NULL OR last_gps_fix_at < ${recordedAt})`
));
} else {
await db
.update(gpsAssets)
.set({ lastGpsFixAt: recordedAt })
.where(eq(gpsAssets.id, assetId));
}
}
async setAssetOnline(assetId: number, isOnline: boolean): Promise<void> {
await db.update(gpsAssets).set({ isOnline }).where(eq(gpsAssets.id, assetId));
}
@@ -466,6 +489,7 @@ export class GpsStorage {
jitterMeters: number;
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;
@@ -478,6 +502,7 @@ export class GpsStorage {
jitterMeters: row?.jitterMeters ?? 25,
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
tripStopMinutes: row?.tripStopMinutes ?? 10,
gpsLostHours: row?.gpsLostHours ?? 5,
defaultLat: row?.defaultLat ?? null,
defaultLng: row?.defaultLng ?? null,
defaultZoom: row?.defaultZoom ?? null,
@@ -490,6 +515,7 @@ export class GpsStorage {
jitterMeters: number;
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;

View File

@@ -3994,7 +3994,9 @@ export const gpsAssets = pgTable("gps_assets", {
lastLng: doublePrecision("last_lng"),
lastSpeed: doublePrecision("last_speed"),
lastCourse: doublePrecision("last_course"),
lastSeenAt: timestamp("last_seen_at"), // время ПРИЁМА последней точки сервером (онлайн-индикатор)
lastSeenAt: timestamp("last_seen_at"),
// Время последнего GPS-фикса по устройству (из stale re-send = замороженный fix, либо свежая принятая точка)
lastGpsFixAt: timestamp("last_gps_fix_at"), // время ПРИЁМА последней точки сервером (онлайн-индикатор)
lastRecordedAt: timestamp("last_recorded_at"), // время последней точки по устройству (fixTime)
createdAt: timestamp("created_at").defaultNow(),
}, (table) => ({
@@ -4121,6 +4123,7 @@ export const gpsSettings = pgTable("gps_settings", {
jitterMeters: integer("jitter_meters").notNull().default(25),
maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250),
tripStopMinutes: integer("trip_stop_minutes").notNull().default(10),
gpsLostHours: integer("gps_lost_hours").notNull().default(5),
defaultLat: doublePrecision("default_lat"),
defaultLng: doublePrecision("default_lng"),
defaultZoom: integer("default_zoom"),