diff --git a/migrations/0075_gps_lost.sql b/migrations/0075_gps_lost.sql new file mode 100644 index 0000000..c49eadb --- /dev/null +++ b/migrations/0075_gps_lost.sql @@ -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; diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts index c432c34..b67da73 100644 --- a/server/gps/geozone.service.ts +++ b/server/gps/geozone.service.ts @@ -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); diff --git a/server/gps/routes.ts b/server/gps/routes.ts index a25aba0..ac6b06f 100644 --- a/server/gps/routes.ts +++ b/server/gps/routes.ts @@ -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-фикса (для индикатора потери спутников) })), }); }); diff --git a/server/gps/storage.ts b/server/gps/storage.ts index 0872f4a..e462d4c 100644 --- a/server/gps/storage.ts +++ b/server/gps/storage.ts @@ -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 { + 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 { 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; diff --git a/shared/schema.ts b/shared/schema.ts index b2e5e3d..2918e58 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -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"),