feat(gps): индикатор потери GPS у объектов (last_gps_fix_at из stale re-send/принятых точек), настройка порога в часах
This commit is contained in:
3
migrations/0075_gps_lost.sql
Normal file
3
migrations/0075_gps_lost.sql
Normal 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;
|
||||||
@@ -346,6 +346,9 @@ export async function processPosition(
|
|||||||
const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt, speed: input.speed });
|
const verdict = walker.check({ lat: input.lat, lng: input.lng, recordedAt, speed: input.speed });
|
||||||
if (verdict !== "accept") {
|
if (verdict !== "accept") {
|
||||||
if (verdict === "stale") {
|
if (verdict === "stale") {
|
||||||
|
// Stale re-send = трекер сам сообщает, когда в последний раз видел спутники
|
||||||
|
// (замороженный fixTime). Фиксируем как «время последнего GPS-фикса».
|
||||||
|
await gpsStorage.updateAssetGpsFix(assetId, recordedAt);
|
||||||
console.log(
|
console.log(
|
||||||
`[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
|
`[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
|
||||||
);
|
);
|
||||||
@@ -366,6 +369,9 @@ export async function processPosition(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Точка прошла фильтры — GPS-фикс жив, двигаем время последнего фикса вперёд
|
||||||
|
await gpsStorage.updateAssetGpsFix(assetId, recordedAt, true);
|
||||||
|
|
||||||
// Фильтр джиттера (округление мелких перемещений)
|
// Фильтр джиттера (округление мелких перемещений)
|
||||||
if (settings.jitterMeters > 0 && prev) {
|
if (settings.jitterMeters > 0 && prev) {
|
||||||
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);
|
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);
|
||||||
|
|||||||
@@ -306,9 +306,14 @@ export function registerGpsRoutes(app: Express) {
|
|||||||
if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
|
if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
|
||||||
return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)");
|
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) {
|
if (!Number.isInteger(tripStopMinutes) || tripStopMinutes < 0 || tripStopMinutes > 120) {
|
||||||
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
|
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
|
||||||
}
|
}
|
||||||
|
if (!Number.isInteger(gpsLostHours) || gpsLostHours < 1 || gpsLostHours > 72) {
|
||||||
|
return badRequest(res, "gpsLostHours: целое число от 1 до 72");
|
||||||
|
}
|
||||||
|
|
||||||
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
|
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
|
||||||
let defaultLat = current.defaultLat;
|
let defaultLat = current.defaultLat;
|
||||||
@@ -347,6 +352,7 @@ export function registerGpsRoutes(app: Express) {
|
|||||||
jitterMeters,
|
jitterMeters,
|
||||||
maxJumpSpeedKmh,
|
maxJumpSpeedKmh,
|
||||||
tripStopMinutes,
|
tripStopMinutes,
|
||||||
|
gpsLostHours,
|
||||||
defaultLat,
|
defaultLat,
|
||||||
defaultLng,
|
defaultLng,
|
||||||
defaultZoom,
|
defaultZoom,
|
||||||
@@ -356,6 +362,7 @@ export function registerGpsRoutes(app: Express) {
|
|||||||
jitterMeters: row.jitterMeters,
|
jitterMeters: row.jitterMeters,
|
||||||
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
|
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
|
||||||
tripStopMinutes: row.tripStopMinutes,
|
tripStopMinutes: row.tripStopMinutes,
|
||||||
|
gpsLostHours: row.gpsLostHours,
|
||||||
defaultLat: row.defaultLat,
|
defaultLat: row.defaultLat,
|
||||||
defaultLng: row.defaultLng,
|
defaultLng: row.defaultLng,
|
||||||
defaultZoom: row.defaultZoom,
|
defaultZoom: row.defaultZoom,
|
||||||
@@ -465,6 +472,7 @@ export function registerGpsRoutes(app: Express) {
|
|||||||
recordedAt: a.lastSeenAt, // время приёма последней точки сервером
|
recordedAt: a.lastSeenAt, // время приёма последней точки сервером
|
||||||
lastSeenAt: a.lastSeenAt,
|
lastSeenAt: a.lastSeenAt,
|
||||||
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
|
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
|
||||||
|
lastGpsFixAt: a.lastGpsFixAt, // время последнего GPS-фикса (для индикатора потери спутников)
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -176,6 +176,29 @@ export class GpsStorage {
|
|||||||
.where(eq(gpsAssets.id, assetId));
|
.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> {
|
async setAssetOnline(assetId: number, isOnline: boolean): Promise<void> {
|
||||||
await db.update(gpsAssets).set({ isOnline }).where(eq(gpsAssets.id, assetId));
|
await db.update(gpsAssets).set({ isOnline }).where(eq(gpsAssets.id, assetId));
|
||||||
}
|
}
|
||||||
@@ -466,6 +489,7 @@ export class GpsStorage {
|
|||||||
jitterMeters: number;
|
jitterMeters: number;
|
||||||
maxJumpSpeedKmh: number;
|
maxJumpSpeedKmh: number;
|
||||||
tripStopMinutes: number;
|
tripStopMinutes: number;
|
||||||
|
gpsLostHours: number;
|
||||||
defaultLat: number | null;
|
defaultLat: number | null;
|
||||||
defaultLng: number | null;
|
defaultLng: number | null;
|
||||||
defaultZoom: number | null;
|
defaultZoom: number | null;
|
||||||
@@ -478,6 +502,7 @@ export class GpsStorage {
|
|||||||
jitterMeters: row?.jitterMeters ?? 25,
|
jitterMeters: row?.jitterMeters ?? 25,
|
||||||
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
|
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
|
||||||
tripStopMinutes: row?.tripStopMinutes ?? 10,
|
tripStopMinutes: row?.tripStopMinutes ?? 10,
|
||||||
|
gpsLostHours: row?.gpsLostHours ?? 5,
|
||||||
defaultLat: row?.defaultLat ?? null,
|
defaultLat: row?.defaultLat ?? null,
|
||||||
defaultLng: row?.defaultLng ?? null,
|
defaultLng: row?.defaultLng ?? null,
|
||||||
defaultZoom: row?.defaultZoom ?? null,
|
defaultZoom: row?.defaultZoom ?? null,
|
||||||
@@ -490,6 +515,7 @@ export class GpsStorage {
|
|||||||
jitterMeters: number;
|
jitterMeters: number;
|
||||||
maxJumpSpeedKmh: number;
|
maxJumpSpeedKmh: number;
|
||||||
tripStopMinutes: number;
|
tripStopMinutes: number;
|
||||||
|
gpsLostHours: number;
|
||||||
defaultLat: number | null;
|
defaultLat: number | null;
|
||||||
defaultLng: number | null;
|
defaultLng: number | null;
|
||||||
defaultZoom: number | null;
|
defaultZoom: number | null;
|
||||||
|
|||||||
@@ -3994,7 +3994,9 @@ export const gpsAssets = pgTable("gps_assets", {
|
|||||||
lastLng: doublePrecision("last_lng"),
|
lastLng: doublePrecision("last_lng"),
|
||||||
lastSpeed: doublePrecision("last_speed"),
|
lastSpeed: doublePrecision("last_speed"),
|
||||||
lastCourse: doublePrecision("last_course"),
|
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)
|
lastRecordedAt: timestamp("last_recorded_at"), // время последней точки по устройству (fixTime)
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
@@ -4121,6 +4123,7 @@ export const gpsSettings = pgTable("gps_settings", {
|
|||||||
jitterMeters: integer("jitter_meters").notNull().default(25),
|
jitterMeters: integer("jitter_meters").notNull().default(25),
|
||||||
maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250),
|
maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250),
|
||||||
tripStopMinutes: integer("trip_stop_minutes").notNull().default(10),
|
tripStopMinutes: integer("trip_stop_minutes").notNull().default(10),
|
||||||
|
gpsLostHours: integer("gps_lost_hours").notNull().default(5),
|
||||||
defaultLat: doublePrecision("default_lat"),
|
defaultLat: doublePrecision("default_lat"),
|
||||||
defaultLng: doublePrecision("default_lng"),
|
defaultLng: doublePrecision("default_lng"),
|
||||||
defaultZoom: integer("default_zoom"),
|
defaultZoom: integer("default_zoom"),
|
||||||
|
|||||||
Reference in New Issue
Block a user