diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx
index 12d9f31..a294620 100644
--- a/client/src/pages/gps/RoutesTab.tsx
+++ b/client/src/pages/gps/RoutesTab.tsx
@@ -332,6 +332,12 @@ export function GpsRoutesTab() {
{trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`}
{pointsThinned && `, маркеров на карте: ${displayedPoints.length}`}
+ {(trackData?.filteredCount ?? 0) > 0 && (
+
+ Отфильтровано мусорных точек:{' '}
+ {trackData!.filteredCount}
+
+ )}
Пробег: {stats.distance.toFixed(1)} км
diff --git a/client/src/pages/gps/SettingsTab.tsx b/client/src/pages/gps/SettingsTab.tsx
index e25f02a..e7da7ef 100644
--- a/client/src/pages/gps/SettingsTab.tsx
+++ b/client/src/pages/gps/SettingsTab.tsx
@@ -11,12 +11,13 @@ import { useToast } from '@/hooks/use-toast';
interface GpsSettings {
success: boolean;
jitterMeters: number;
+ maxJumpSpeedKmh: number;
}
/**
* Таб «Настройки» GPS-модуля.
- * Секция «Трекинг»: фильтр дрожания позиции (джиттер) — сервер не записывает
- * точки ближе N метров при скорости < 5 км/ч. Сюда же позже добавятся
+ * Секция «Трекинг»: фильтры входящих точек — дрожание позиции (джиттер)
+ * и максимальный скачок скорости (LBS-прыжки). Сюда же позже добавятся
* другие настройки модуля — структура секций-Card под это готова.
*/
export function GpsSettingsTab() {
@@ -26,15 +27,22 @@ export function GpsSettingsTab() {
});
const [jitterMeters, setJitterMeters] = useState('0');
+ const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0');
useEffect(() => {
- if (data) setJitterMeters(String(data.jitterMeters));
+ if (data) {
+ setJitterMeters(String(data.jitterMeters));
+ setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh));
+ }
}, [data]);
const saveMutation = useMutation({
mutationFn: async () => {
- const value = Number(jitterMeters);
- const res = await apiRequest('PUT', '/api/gps/settings', { jitterMeters: value });
+ // Оба поля сохраняются одним PUT (непереданные на сервере сохраняются)
+ const res = await apiRequest('PUT', '/api/gps/settings', {
+ jitterMeters: Number(jitterMeters),
+ maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
+ });
return res.json();
},
onSuccess: () => {
@@ -49,7 +57,9 @@ export function GpsSettingsTab() {
},
});
- const jitterValid = /^\d+$/.test(jitterMeters.trim()) && Number(jitterMeters) >= 0 && Number(jitterMeters) <= 1000;
+ const isIntInRange = (v: string, max: number) => /^\d+$/.test(v.trim()) && Number(v) >= 0 && Number(v) <= max;
+ const jitterValid = isIntInRange(jitterMeters, 1000);
+ const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000);
return (
@@ -84,10 +94,29 @@ export function GpsSettingsTab() {
Целое число от 0 до 1000
)}
+
+
+
Максимальный скачок, км/ч
+
setMaxJumpSpeedKmh(e.target.value)}
+ />
+
+ 0 = выключен. Точки, требующие перемещения быстрее N км/ч от предыдущей,
+ отбрасываются — отсекает LBS-прыжки на вышки при потере GPS. Действует на новые точки.
+
+ {!jumpValid && (
+
Целое число от 0 до 5000
+ )}
+
+
saveMutation.mutate()}
>
{saveMutation.isPending ? (
diff --git a/client/src/pages/gps/types.ts b/client/src/pages/gps/types.ts
index 8ef7edb..a5ea316 100644
--- a/client/src/pages/gps/types.ts
+++ b/client/src/pages/gps/types.ts
@@ -64,6 +64,8 @@ export interface GpsTrackResponse {
success: boolean;
points: GpsTrackPoint[];
total: number;
+ /** Число отброшенных сервером устаревших/телепорт-точек */
+ filteredCount?: number;
sampled: boolean;
}
diff --git a/migrations/0071_gps_settings_jump.sql b/migrations/0071_gps_settings_jump.sql
new file mode 100644
index 0000000..cd2370c
--- /dev/null
+++ b/migrations/0071_gps_settings_jump.sql
@@ -0,0 +1,4 @@
+-- GPS: настройка анти-скачка (фильтр LBS-позиций с нереалистичной скоростью).
+-- max_jump_speed_kmh: максимальная допустимая подразумеваемая скорость (км/ч)
+-- между последовательными принятыми точками; 0 = фильтр выключен.
+ALTER TABLE gps_settings ADD COLUMN IF NOT EXISTS max_jump_speed_kmh INTEGER NOT NULL DEFAULT 250;
diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts
index bef56b2..5efd5e5 100644
--- a/server/gps/geozone.service.ts
+++ b/server/gps/geozone.service.ts
@@ -52,19 +52,71 @@ export interface ProcessPositionInput {
recordedAt?: Date;
}
+// Вердикт фильтрации точки относительно предыдущей принятой
+export type PointVerdict = "accept" | "stale" | "jump";
+
+export interface GpsFilterSettings {
+ jitterMeters: number;
+ maxJumpSpeedKmh: number;
+}
+
+interface PrevPoint {
+ lat: number;
+ lng: number;
+ recordedAt: Date;
+}
+
+/**
+ * Классификация новой точки относительно предыдущей ПРИНЯТОЙ.
+ * Используется и в processPosition (при приёме), и при отдаче трека.
+ *
+ * - stale: recordedAt <= prev.recordedAt — устаревшая копия/дубликат
+ * (трекеры без GPS-фикса пересылают старый фикс со старым fixTime).
+ * - jump: impliedKmh = dist/dt > maxJumpSpeedKmh — нереалистичный скачок
+ * (LBS-позиции в десятках км от реальных); 0 = фильтр выключен.
+ * Защита от dt <= 0 (после stale-проверки недостижимо, но на всякий случай):
+ * считаем jump'ом, если dist > jitterMeters.
+ */
+export function classifyPoint(
+ prev: PrevPoint | null,
+ point: PrevPoint,
+ settings: GpsFilterSettings
+): PointVerdict {
+ if (!prev) return "accept";
+
+ if (point.recordedAt.getTime() <= prev.recordedAt.getTime()) {
+ return "stale";
+ }
+
+ if (settings.maxJumpSpeedKmh > 0) {
+ const dtSec = (point.recordedAt.getTime() - prev.recordedAt.getTime()) / 1000;
+ const dist = haversineMeters(prev.lat, prev.lng, point.lat, point.lng);
+ if (dtSec <= 0) {
+ if (dist > settings.jitterMeters) return "jump";
+ } else {
+ const impliedKmh = dist / 1000 / (dtSec / 3600);
+ if (impliedKmh > settings.maxJumpSpeedKmh) return "jump";
+ }
+ }
+
+ return "accept";
+}
+
/**
* Основная точка входа: сохраняет позицию, обновляет last_* объекта,
* проверяет геозоны и рассылает SSE/уведомления.
*
* Семантика времени:
* - last_seen_at = ВСЕГДА время ПРИЁМА сервером (онлайн-индикатор на карте);
- * - last_recorded_at = время точки по устройству (fixTime трекера);
+ * - last_recorded_at = время последней ПРИНЯТОЙ точки по устройству (fixTime);
* - gps_positions.recorded_at = время устройства (для треков).
*
- * Фильтр джиттера: если в настройках jitterMeters > 0, у объекта есть
- * предыдущая точка, расстояние до неё < jitterMeters и скорость мала
- * (null или < 5 км/ч) — точка отбрасывается: не пишется в gps_positions,
- * координаты и геозоны не трогаются, но last_seen_at/is_online обновляются.
+ * Цепочка фильтров (сравнение всегда с последней принятой точкой):
+ * 1. stale — устаревшая копия/дубликат по времени устройства;
+ * 2. jump — нереалистичный скачок скорости (LBS-позиции);
+ * 3. jitter — мелкое перемещение с малой скоростью.
+ * Отброшенная точка не пишется в gps_positions, координаты и геозоны не
+ * трогаются, но last_seen_at/is_online обновляются (устройство живо).
*/
export async function processPosition(
assetId: number,
@@ -76,17 +128,39 @@ export async function processPosition(
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 prev: PrevPoint | null =
+ asset.lastLat !== null && asset.lastLng !== null && asset.lastRecordedAt !== null
+ ? { lat: asset.lastLat, lng: asset.lastLng, recordedAt: asset.lastRecordedAt }
+ : null;
+
+ // Фильтры stale/jump (устаревшие копии и LBS-скачки)
+ const verdict = classifyPoint(prev, { lat: input.lat, lng: input.lng, recordedAt }, settings);
+ if (verdict !== "accept") {
+ if (verdict === "stale") {
+ console.log(
+ `[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
+ );
+ } else {
+ const dist = prev ? haversineMeters(prev.lat, prev.lng, input.lat, input.lng) : 0;
+ const dtSec = prev ? (recordedAt.getTime() - prev.recordedAt.getTime()) / 1000 : 0;
+ const impliedKmh = dtSec > 0 ? dist / 1000 / (dtSec / 3600) : Infinity;
+ console.log(
+ `[GPS] skipped jump: asset=${assetId} dist=${Math.round(dist)}m dt=${Math.round(dtSec)}s implied=${Math.round(impliedKmh)}km/h > max=${settings.maxJumpSpeedKmh}km/h`
+ );
+ }
+ // Устройство живо — фиксируем факт приёма, точку не сохраняем
+ await gpsStorage.touchAssetSeen(assetId);
+ return;
+ }
+
+ // Фильтр джиттера (округление мелких перемещений)
+ if (settings.jitterMeters > 0 && prev) {
+ const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);
const speed = input.speed ?? null;
if (distance < settings.jitterMeters && (speed === null || speed < 5)) {
- // Устройство живо — фиксируем факт приёма, точку не сохраняем
await gpsStorage.touchAssetSeen(assetId);
return;
}
diff --git a/server/gps/routes.ts b/server/gps/routes.ts
index 299d4dd..594019e 100644
--- a/server/gps/routes.ts
+++ b/server/gps/routes.ts
@@ -2,7 +2,7 @@ import { Router } from "express";
import type { Express, Response } from "express";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { gpsStorage } from "./storage";
-import { processPosition } from "./geozone.service";
+import { processPosition, classifyPoint } from "./geozone.service";
import { ensureDevice, deleteDevice } from "./traccar.client";
import { storage } from "../storage";
@@ -173,16 +173,28 @@ export function registerGpsRoutes(app: Express) {
router.get("/settings", async (req: AuthenticatedRequest, res: Response) => {
const settings = await gpsStorage.getSettings(req.user!.organizationId);
- res.json({ success: true, jitterMeters: settings.jitterMeters });
+ res.json({ success: true, ...settings });
});
router.put("/settings", async (req: AuthenticatedRequest, res: Response) => {
- const jitterMeters = Number(req.body?.jitterMeters);
+ // Обновляем только переданные поля (обратная совместимость с клиентом,
+ // который шлёт только jitterMeters)
+ const current = await gpsStorage.getSettings(req.user!.organizationId);
+
+ const jitterMeters =
+ req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
+ const maxJumpSpeedKmh =
+ req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh;
+
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 });
+ if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
+ return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)");
+ }
+
+ const row = await gpsStorage.upsertSettings(req.user!.organizationId, { jitterMeters, maxJumpSpeedKmh });
+ res.json({ success: true, jitterMeters: row.jitterMeters, maxJumpSpeedKmh: row.maxJumpSpeedKmh });
});
// =====================
@@ -306,19 +318,36 @@ export function registerGpsRoutes(app: Express) {
const all = await gpsStorage.getTrack(id, orgId, from ?? undefined, to ?? undefined);
const total = all.length;
- let points = all;
+ // Фильтрация сохранённых данных при выдаче (без удаления из БД):
+ // пропускаем stale (устаревшие копии) и jump (LBS-скачки) — те же правила,
+ // что при приёме, сравнение с предыдущей принятой точкой
+ const settings = await gpsStorage.getSettings(orgId);
+ const filtered: typeof all = [];
+ let filteredCount = 0;
+ let prevAccepted: { lat: number; lng: number; recordedAt: Date } | null = null;
+ for (const point of all) {
+ const current = { lat: point.lat, lng: point.lng, recordedAt: point.recordedAt };
+ if (classifyPoint(prevAccepted, current, settings) === "accept") {
+ filtered.push(point);
+ prevAccepted = current;
+ } else {
+ filteredCount++;
+ }
+ }
+
+ let points = filtered;
let sampled = false;
- if (total > MAX_TRACK_POINTS) {
- const step = Math.ceil(total / MAX_TRACK_POINTS);
- points = all.filter((_, idx) => idx % step === 0);
+ if (points.length > MAX_TRACK_POINTS) {
+ const step = Math.ceil(points.length / MAX_TRACK_POINTS);
+ points = points.filter((_, idx) => idx % step === 0);
// Всегда включаем последнюю точку трека
- if (points[points.length - 1] !== all[total - 1]) {
- points.push(all[total - 1]);
+ if (points[points.length - 1] !== filtered[filtered.length - 1]) {
+ points.push(filtered[filtered.length - 1]);
}
sampled = true;
}
- res.json({ success: true, points, total, sampled });
+ res.json({ success: true, points, total, filteredCount, sampled });
});
// =====================
diff --git a/server/gps/storage.ts b/server/gps/storage.ts
index faf2b3e..fad4f03 100644
--- a/server/gps/storage.ts
+++ b/server/gps/storage.ts
@@ -461,22 +461,28 @@ export class GpsStorage {
// Настройки организации
// =====================
- /** Настройки GPS организации; если строки нет — дефолт */
- async getSettings(organizationId: number): Promise<{ jitterMeters: number }> {
+ /** Настройки GPS организации; если строки нет — дефолты */
+ async getSettings(organizationId: number): Promise<{ jitterMeters: number; maxJumpSpeedKmh: number }> {
const [row] = await db
.select()
.from(gpsSettings)
.where(eq(gpsSettings.organizationId, organizationId));
- return { jitterMeters: row?.jitterMeters ?? 25 };
+ return {
+ jitterMeters: row?.jitterMeters ?? 25,
+ maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
+ };
}
- async upsertSettings(organizationId: number, jitterMeters: number): Promise {
+ async upsertSettings(
+ organizationId: number,
+ settings: { jitterMeters: number; maxJumpSpeedKmh: number }
+ ): Promise {
const [row] = await db
.insert(gpsSettings)
- .values({ organizationId, jitterMeters, updatedAt: new Date() })
+ .values({ organizationId, ...settings, updatedAt: new Date() })
.onConflictDoUpdate({
target: gpsSettings.organizationId,
- set: { jitterMeters, updatedAt: new Date() },
+ set: { ...settings, updatedAt: new Date() },
})
.returning();
return row;
diff --git a/shared/schema.ts b/shared/schema.ts
index 7bfe34b..0c94bb6 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -4113,6 +4113,7 @@ export type InsertGpsAssetSubscriber = typeof gpsAssetSubscribers.$inferInsert;
export const gpsSettings = pgTable("gps_settings", {
organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }),
jitterMeters: integer("jitter_meters").notNull().default(25),
+ maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});