diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx
index 2ccc0af..4df42b0 100644
--- a/client/src/components/gps/GpsMap.tsx
+++ b/client/src/components/gps/GpsMap.tsx
@@ -26,6 +26,8 @@ export interface GpsMapMarker {
label?: string;
/** Приглушённый маркер для offline-объектов */
offline?: boolean;
+ /** Примерная позиция (LBS по вышкам, нет GPS): полупрозрачный маркер с пунктирной обводкой */
+ approximate?: boolean;
onClick?: () => void;
zIndex?: number;
/** HTML/text попапа по клику (balloon на Яндексе, popup на Leaflet) */
@@ -52,6 +54,8 @@ export interface GpsMapPolyline {
points: Array<[number, number]>;
color?: string | null;
dashed?: boolean;
+ /** Толщина линии в px (по умолчанию 4) */
+ width?: number;
}
interface GpsMapProps {
diff --git a/client/src/components/gps/OsmMap.tsx b/client/src/components/gps/OsmMap.tsx
index de5db84..ed9bdd0 100644
--- a/client/src/components/gps/OsmMap.tsx
+++ b/client/src/components/gps/OsmMap.tsx
@@ -141,7 +141,7 @@ export function OsmMap({
// Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в ref)
const markersKey = JSON.stringify(
- markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course])
+ markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course])
);
useEffect(() => {
const layer = markersLayerRef.current;
@@ -186,7 +186,7 @@ export function OsmMap({
// Полигоны и полилинии
const featuresKey = JSON.stringify([
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
- polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
+ polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]),
]);
useEffect(() => {
const layer = featuresLayerRef.current;
@@ -206,7 +206,7 @@ export function OsmMap({
if (l.points.length < 2) continue;
L.polyline(l.points, {
color: l.color || DEFAULT_COLOR,
- weight: 4,
+ weight: l.width ?? 4,
dashArray: l.dashed ? '8 6' : undefined,
}).addTo(layer);
}
diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx
index 93e311a..297953b 100644
--- a/client/src/components/gps/Yandex21Map.tsx
+++ b/client/src/components/gps/Yandex21Map.tsx
@@ -194,9 +194,9 @@ export function Yandex21Map({
// (иначе открытый balloon точки трека уничтожается ребилдом при seek)
markers.map((m) => m.id === 'playhead'
? [m.id, m.kind, m.color, m.zIndex]
- : [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]),
+ : [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]),
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
- polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
+ polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]),
]);
useEffect(() => {
const collection = collectionRef.current;
@@ -224,7 +224,7 @@ export function Yandex21Map({
collection.add(
new ymaps.Polyline(l.points, {}, {
strokeColor: l.color || DEFAULT_COLOR,
- strokeWidth: 4,
+ strokeWidth: l.width ?? 4,
strokeStyle: l.dashed ? 'dash' : 'solid',
})
);
diff --git a/client/src/components/gps/marker-html.ts b/client/src/components/gps/marker-html.ts
index 668e320..3dab554 100644
--- a/client/src/components/gps/marker-html.ts
+++ b/client/src/components/gps/marker-html.ts
@@ -55,7 +55,8 @@ export function gpsMarkerLayout(marker: GpsMapMarker): GpsMarkerLayout {
*/
export function gpsMarkerHtml(marker: GpsMapMarker): string {
const color = marker.color || DEFAULT_COLOR;
- const opacity = marker.offline ? 'opacity:0.5;' : '';
+ // approximate (позиция по вышкам) — полупрозрачный маркер; offline — приглушённый
+ const opacity = marker.offline ? 'opacity:0.5;' : marker.approximate ? 'opacity:0.55;' : '';
const layout = gpsMarkerLayout(marker);
const title = escapeHtml(marker.label || '');
const label = marker.label
@@ -70,7 +71,15 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string {
: `
`;
} else {
const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
- body = `${icon}
`;
+ if (marker.approximate) {
+ // Примерная позиция: пунктирная обводка + бейдж «~» в углу кружка
+ body = `` +
+ `
${icon}
` +
+ `
~
` +
+ `
`;
+ } else {
+ body = `${icon}
`;
+ }
}
return `${body}${label}
`;
diff --git a/client/src/hooks/useGps.ts b/client/src/hooks/useGps.ts
index 6301ff0..8063aca 100644
--- a/client/src/hooks/useGps.ts
+++ b/client/src/hooks/useGps.ts
@@ -91,12 +91,14 @@ export function useGpsGeozoneEvents(filters: { assetId?: number; geozoneId?: num
});
}
-/** Трек объекта за период; загружается только при enabled (по кнопке «Показать») */
-export function useGpsTrack(assetId: number | null, from: string, to: string, enabled: boolean) {
+/** Трек объекта за период; загружается только при enabled (по кнопке «Показать»).
+ * matched=true — дополнительно запрашивает линию по дорогам (OSRM) */
+export function useGpsTrack(assetId: number | null, from: string, to: string, enabled: boolean, matched = false) {
return useQuery({
- queryKey: ['/api/gps/assets', assetId, 'track', from, to],
+ queryKey: ['/api/gps/assets', assetId, 'track', from, to, matched],
queryFn: async () => {
const params = new URLSearchParams({ from, to });
+ if (matched) params.set('matched', '1');
const res = await apiRequest('GET', `/api/gps/assets/${assetId}/track?${params.toString()}`);
return res.json();
},
diff --git a/client/src/pages/gps/AssetsTab.tsx b/client/src/pages/gps/AssetsTab.tsx
index 25a822c..8b3b20f 100644
--- a/client/src/pages/gps/AssetsTab.tsx
+++ b/client/src/pages/gps/AssetsTab.tsx
@@ -200,6 +200,12 @@ export function GpsAssetsTab() {
{asset.externalId || '—'}
{formatDateTime(asset.lastSeenAt)}
+ {/* Примерная позиция: последняя точка — координаты вышки (LBS) */}
+ {asset.lastIsLbs && (
+
+ (примерно, по вышкам)
+
+ )}
{/* Время точки по устройству, если сильно отстаёт от приёма сервером */}
{asset.lastSeenAt &&
asset.lastRecordedAt &&
diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx
index 250b023..d0c8787 100644
--- a/client/src/pages/gps/MapTab.tsx
+++ b/client/src/pages/gps/MapTab.tsx
@@ -150,6 +150,8 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
kind: p.type,
label: p.name,
offline: !p.isOnline,
+ // Позиция по вышкам (LBS) — полупрозрачный маркер с пунктирной обводкой
+ approximate: p.lastIsLbs === true,
onClick: rulerMode ? undefined : () => setSelected(p),
})),
...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({
@@ -255,6 +257,14 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
Скорость:{' '}
{selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'}
+ {selectedLive.lastIsLbs && (
+
+ Позиция примерная (по вышкам, нет GPS)
+
+ )}
+ {selectedLive.gpsLost && (
+ Нет GPS-сигнала
+ )}
Обновлено: {formatDateTime(seenAt)}
{recordedStale && Точка от: {formatDateTime(recordedAt)}
}
{selectedAsset?.externalId && Трекер: {selectedAsset.externalId}
}
diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx
index 51593a2..cff6f37 100644
--- a/client/src/pages/gps/RoutesTab.tsx
+++ b/client/src/pages/gps/RoutesTab.tsx
@@ -10,6 +10,7 @@ import { Loader2, Pause, Play } from 'lucide-react';
import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap';
import { AssetTreePanel } from '@/components/gps/AssetTreePanel';
import { useGpsAssets, useGpsGroups, useGpsTrack } from '@/hooks/useGps';
+import { useToast } from '@/hooks/use-toast';
import type { GpsTrackPoint } from './types';
/** Расстояние между двумя точками в км (формула гаверсинуса) */
@@ -76,6 +77,8 @@ function splitIntoTrips(points: GpsTrackPoint[], tripStopMinutes: number): Trip[
const finish = (pts: GpsTrackPoint[]) => {
let distanceKm = 0;
for (let i = 1; i < pts.length; i++) {
+ // LBS-участки (точки по вышкам) в пробег не считаем
+ if (pts[i - 1].isLbs || pts[i].isLbs) continue;
distanceKm += haversineKm(pts[i - 1].lat, pts[i - 1].lng, pts[i].lat, pts[i].lng);
}
trips.push({
@@ -110,6 +113,7 @@ interface GpsSettingsResponse {
* по скорости), деление дня на поездки, статистика и проигрывание с плейхедом.
*/
export function GpsRoutesTab() {
+ const { toast } = useToast();
const { data: assetsData } = useGpsAssets();
const { data: groupsData } = useGpsGroups();
const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false);
@@ -135,6 +139,8 @@ export function GpsRoutesTab() {
const [to, setTo] = useState(initialRange.to);
// Применённые параметры запроса — трек грузится только по кнопке «Показать»
const [applied, setApplied] = useState<{ assetId: number; from: string; to: string } | null>(null);
+ // Линия трека по дорогам (OSRM map matching); переключатель дёргает refetch
+ const [matchRoads, setMatchRoads] = useState(true);
// Пришли по ссылке с карты — сразу показываем трек за сегодня
useEffect(() => {
@@ -152,10 +158,23 @@ export function GpsRoutesTab() {
applied?.assetId ?? null,
applied ? new Date(applied.from).toISOString() : '',
applied ? new Date(applied.to).toISOString() : '',
- !!applied
+ !!applied,
+ matchRoads
);
const points: GpsTrackPoint[] = trackData?.points || [];
+ // matched === null — OSRM недоступен: тихо показываем обычный трек,
+ // один раз предупреждаем toast'ом (на каждый новый запрос трека)
+ const osrmToastRef = useRef(false);
+ useEffect(() => {
+ osrmToastRef.current = false;
+ }, [applied, matchRoads]);
+ useEffect(() => {
+ if (!matchRoads || !trackData || trackData.matched !== null || osrmToastRef.current) return;
+ osrmToastRef.current = true;
+ toast({ title: 'OSRM недоступен', description: 'Показан трек по точкам' });
+ }, [trackData, matchRoads, toast]);
+
const selectedAsset = assets.find((a) => String(a.id) === (applied ? String(applied.assetId) : assetId));
// === Поездки ===
@@ -176,9 +195,13 @@ export function GpsRoutesTab() {
let distance = 0;
let maxSpeed = 0;
for (let i = 1; i < activePoints.length; i++) {
+ // LBS-участки (точки по вышкам) в пробег не считаем
+ if (activePoints[i - 1].isLbs || activePoints[i].isLbs) continue;
distance += haversineKm(activePoints[i - 1].lat, activePoints[i - 1].lng, activePoints[i].lat, activePoints[i].lng);
}
for (const p of activePoints) {
+ // Примерные точки (LBS) не учитываем в макс. скорости
+ if (p.isLbs) continue;
if (p.speed != null && p.speed > maxSpeed) maxSpeed = p.speed;
}
const startTs = new Date(activePoints[0].recordedAt).getTime();
@@ -274,32 +297,50 @@ export function GpsRoutesTab() {
// Выбранная кликом точка трека — инфо-панель поверх карты (вместо balloon карты)
const [selectedPoint, setSelectedPoint] = useState(null);
- // Сегменты трека, окрашенные по скорости в начальной точке сегмента.
- // При > 800 точек прореживаем до ~800 для раскраски (базовая полилиния
- // из всех точек остаётся под низом). 800 сегментов оба провайдера
- // тянут нормально; если Яндекс просядет — порог снизить до 400.
+ // Сегменты трека: LBS-участки (хотя бы одна точка сегмента — по вышкам)
+ // рисуем серым пунктиром и тоньше, остальные — по скорости начальной точки.
+ // При > 800 точек прореживаем до ~800 (базовая полилиния из всех точек
+ // остаётся под низом). 800 сегментов оба провайдера тянут нормально;
+ // если Яндекс просядет — порог снизить до 400.
const COLORIZE_POINTS_LIMIT = 800;
- const speedSegments: GpsMapPolyline[] = useMemo(() => {
- if (activePoints.length < 2) return [];
+ const { speedSegments, lbsSegments } = useMemo(() => {
+ const speed: GpsMapPolyline[] = [];
+ const lbs: GpsMapPolyline[] = [];
+ if (activePoints.length < 2) return { speedSegments: speed, lbsSegments: lbs };
const step = Math.max(1, Math.ceil(activePoints.length / COLORIZE_POINTS_LIMIT));
const pts = step === 1 ? activePoints : activePoints.filter((_, idx) => idx % step === 0);
- const segments: GpsMapPolyline[] = [];
for (let i = 1; i < pts.length; i++) {
- segments.push({
- id: `seg-${i}`,
- points: [
- [pts[i - 1].lat, pts[i - 1].lng],
- [pts[i].lat, pts[i].lng],
- ],
- color: speedColor(pts[i - 1].speed),
- });
+ const pair: Array<[number, number]> = [
+ [pts[i - 1].lat, pts[i - 1].lng],
+ [pts[i].lat, pts[i].lng],
+ ];
+ if (pts[i - 1].isLbs || pts[i].isLbs) {
+ lbs.push({ id: `lbs-${i}`, points: pair, color: SPEED_UNKNOWN_COLOR, dashed: true, width: 3 });
+ } else {
+ speed.push({ id: `seg-${i}`, points: pair, color: speedColor(pts[i - 1].speed) });
+ }
}
- return segments;
+ return { speedSegments: speed, lbsSegments: lbs };
}, [activePoints]);
+ // Линия по дорогам (OSRM) поверх раскраски: сплошная, цвет объекта, толще
+ const matchedLine: GpsMapPolyline[] =
+ matchRoads && trackData?.matched && trackData.matched.points.length >= 2
+ ? [
+ {
+ id: 'matched',
+ points: trackData.matched.points,
+ color: selectedAsset?.color || '#3b82f6',
+ width: 5,
+ },
+ ]
+ : [];
+
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи.
// При включённой раскраске: базовая полилиния из всех точек под низом,
- // сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек)
+ // сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек);
+ // LBS-участки (по вышкам) — всегда серым пунктиром поверх;
+ // matched-линия по дорогам — верхним слоем
const colorizeEnabled = colorize && activePoints.length >= 2;
const mapPolylines: GpsMapPolyline[] =
trackCoords.length >= 2
@@ -310,8 +351,10 @@ export function GpsRoutesTab() {
color: colorizeEnabled ? '#94a3b8' : selectedAsset?.color || '#3b82f6',
},
...(colorizeEnabled ? speedSegments : []),
+ ...lbsSegments,
+ ...matchedLine,
]
- : [];
+ : matchedLine;
const mapMarkers: GpsMapMarker[] = [
// Точки записи координат (стрелки курса с попапом; в кластер не включаем;
// клик — попап + установка плейхеда проигрывания)
@@ -366,6 +409,10 @@ export function GpsRoutesTab() {
setColorize(!!v)} />
Раскрасить по скорости
+
@@ -418,6 +465,9 @@ export function GpsRoutesTab() {
Скорость: {selectedPoint.speed != null ? `${Math.round(selectedPoint.speed)} км/ч` : '—'}
+ {selectedPoint.isLbs && (
+ Примерная позиция (по вышкам)
+ )}
{selectedPoint.course != null && Направление: {Math.round(selectedPoint.course)}°
}
{selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}
Плейхед установлен на эту точку
@@ -425,16 +475,26 @@ export function GpsRoutesTab() {
)}
- {/* Легенда раскраски трека по скорости */}
- {colorizeEnabled && (
+ {/* Легенда раскраски трека по скорости + LBS-участки (пунктир) */}
+ {(colorizeEnabled || lbsSegments.length > 0) && (
- {SPEED_STOPS.map((s) => (
-
-
- {s.label}
+ {colorizeEnabled && (
+ <>
+ {SPEED_STOPS.map((s) => (
+
+
+ {s.label}
+
+ ))}
+ км/ч
+ >
+ )}
+ {lbsSegments.length > 0 && (
+
+
+ пунктир — нет GPS (по вышкам)
- ))}
- км/ч
+ )}
)}
@@ -463,6 +523,14 @@ export function GpsRoutesTab() {
Пробег: {stats.distance.toFixed(1)} км
+ {trackData?.matched && (
+
+ Пробег по дорогам:{' '}
+
+ {(trackData.matched.distance / 1000).toFixed(1)} км
+
+
+ )}
Время в пути:{' '}
diff --git a/client/src/pages/gps/types.ts b/client/src/pages/gps/types.ts
index d7c979b..a81275e 100644
--- a/client/src/pages/gps/types.ts
+++ b/client/src/pages/gps/types.ts
@@ -23,6 +23,10 @@ export interface GpsAsset {
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
lastRecordedAt?: string | null;
lastGpsFixAt?: string | null;
+ /** Режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS (по вышкам) */
+ gpsLost?: boolean;
+ /** Последняя точка — координаты вышки (примерная позиция) */
+ lastIsLbs?: boolean;
createdAt: string | null;
/** Группы объекта (добавляет GET /api/gps/assets) */
groups?: Array<{ id: number; name: string }>;
@@ -48,6 +52,10 @@ export interface GpsPosition {
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
lastRecordedAt?: string | null;
lastGpsFixAt?: string | null;
+ /** Режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS (по вышкам) */
+ gpsLost?: boolean;
+ /** Последняя точка — координаты вышки (примерная позиция) */
+ lastIsLbs?: boolean;
}
/** Точка трека (GET /api/gps/assets/:id/track) */
@@ -59,6 +67,8 @@ export interface GpsTrackPoint {
speed: number | null;
course: number | null;
accuracy: number | null;
+ /** Точка — координаты вышки (LBS), а не GPS-фикс */
+ isLbs?: boolean;
recordedAt: string;
}
@@ -69,6 +79,8 @@ export interface GpsTrackResponse {
/** Число отброшенных сервером устаревших/телепорт-точек */
filteredCount?: number;
sampled: boolean;
+ /** Линия трека по дорогам от OSRM (при ?matched=1); null — OSRM недоступен */
+ matched?: { points: Array<[number, number]>; distance: number; duration: number } | null;
}
/** Геозона: polygon — массив пар [lat, lng] */
diff --git a/docker-compose.yml b/docker-compose.yml
index f220518..d211e63 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -161,6 +161,16 @@ services:
- traccar_data:/opt/traccar/data
- ./traccar/traccar.xml:/opt/traccar/conf/traccar.xml:ro
+ # Map matching (привязка GPS-треков к дорогам) — OSRM, карта Приволжского округа.
+ # Только внутренняя Docker-сеть (приложение ходит по http://osrm:5000).
+ osrm:
+ image: osrm/osrm-backend:latest
+ container_name: osrm
+ restart: unless-stopped
+ command: osrm-routed --algorithm mld /data/volga.osrm
+ volumes:
+ - ./osrm-data:/data:ro
+
volumes:
crm_data:
postgres_data:
diff --git a/migrations/0076_gps_lbs.sql b/migrations/0076_gps_lbs.sql
new file mode 100644
index 0000000..5502495
--- /dev/null
+++ b/migrations/0076_gps_lbs.sql
@@ -0,0 +1,6 @@
+-- Режим «потерян GPS»: пометка LBS-точек (координаты сотовых вышек) и флаг
+-- потери GPS у объекта. LBS-точки сохраняются и показываются (пунктир на карте),
+-- но не участвуют в логике геозон.
+ALTER TABLE gps_positions ADD COLUMN IF NOT EXISTS is_lbs boolean NOT NULL DEFAULT false;
+ALTER TABLE gps_assets ADD COLUMN IF NOT EXISTS gps_lost boolean NOT NULL DEFAULT false;
+ALTER TABLE gps_assets ADD COLUMN IF NOT EXISTS last_is_lbs boolean NOT NULL DEFAULT false;
diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts
index b67da73..a2b1361 100644
--- a/server/gps/geozone.service.ts
+++ b/server/gps/geozone.service.ts
@@ -305,12 +305,27 @@ export function filterTrackPoints();
+// Время (server time, ms) последнего stale-verdict'а по объекту.
+// Идущие stale re-send'ы — сигнал потери GPS: свежие точки в течение
+// STALE_LBS_WINDOW_MS после последнего stale считаются LBS (координаты
+// сотовой вышки) — сохраняются и показываются, но не участвуют в геозонах.
+const assetLastStaleAt = new Map();
+
+// Окно после последнего stale re-send'а, в котором свежие точки считаются LBS
+const STALE_LBS_WINDOW_MS = 10 * 60 * 1000;
+
function getAssetWalker(assetId: number, prev: PrevPoint | null, settings: GpsFilterSettings): JumpFilterWalker {
let walker = assetWalkers.get(assetId);
if (!walker) {
@@ -332,6 +347,13 @@ export async function processPosition(
const asset = await gpsStorage.getAsset(assetId, organizationId);
if (!asset) return;
+ // Гидратация in-memory состояния после рестарта: если в БД объект помечен
+ // «потерян GPS», а lastStaleAt пуст — считаем, что stale был только что
+ // (осторожный режим до свежих событий)
+ if (asset.gpsLost && !assetLastStaleAt.has(assetId)) {
+ assetLastStaleAt.set(assetId, Date.now());
+ }
+
const settings = await gpsStorage.getSettings(organizationId);
// Предыдущая принятая точка объекта (если есть) — для инициализации walker'а
@@ -349,6 +371,13 @@ export async function processPosition(
// Stale re-send = трекер сам сообщает, когда в последний раз видел спутники
// (замороженный fixTime). Фиксируем как «время последнего GPS-фикса».
await gpsStorage.updateAssetGpsFix(assetId, recordedAt);
+ // Идущие stale re-send'ы — сигнал потери GPS: запоминаем время последнего
+ // stale и включаем режим «потерян GPS» у объекта
+ assetLastStaleAt.set(assetId, Date.now());
+ if (!asset.gpsLost) {
+ await gpsStorage.setGpsLost(assetId, true);
+ console.log(`[GPS] gps_lost ON: asset=${assetId} (stale re-send)`);
+ }
console.log(
`[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
);
@@ -372,6 +401,13 @@ export async function processPosition(
// Точка прошла фильтры — GPS-фикс жив, двигаем время последнего фикса вперёд
await gpsStorage.updateAssetGpsFix(assetId, recordedAt, true);
+ // Режим «потерян GPS»: если последний stale re-send был свежее 10 минут,
+ // свежая точка — это координаты сотовой вышки (LBS), а не GPS-фикс.
+ // Такие точки сохраняем и показываем (пунктир на карте), но в логику
+ // геозон не пускаем. jump/echo/джиттер-отбросы режим не переключают.
+ const lastStaleAt = assetLastStaleAt.get(assetId);
+ const isLbs = lastStaleAt !== undefined && Date.now() - lastStaleAt < STALE_LBS_WINDOW_MS;
+
// Фильтр джиттера (округление мелких перемещений)
if (settings.jitterMeters > 0 && prev) {
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);
@@ -391,6 +427,7 @@ export async function processPosition(
speed: input.speed ?? null,
course: input.course ?? null,
accuracy: input.accuracy ?? null,
+ isLbs,
recordedAt,
});
@@ -401,8 +438,15 @@ export async function processPosition(
speed: input.speed,
course: input.course,
recordedAt,
+ isLbs,
});
+ // Настоящий GPS-фикс после режима «потерян GPS» — выключаем режим
+ if (!isLbs && asset.gpsLost) {
+ await gpsStorage.setGpsLost(assetId, false);
+ console.log(`[GPS] gps_lost OFF: asset=${assetId} (свежий GPS-фикс)`);
+ }
+
// 3. SSE-событие о перемещении — broadcast по организации,
// чтобы карта у всех открытых клиентов обновилась в реальном времени
eventBus.publishEvent({
@@ -414,12 +458,16 @@ export async function processPosition(
speed: input.speed ?? null,
course: input.course ?? null,
recordedAt: recordedAt.toISOString(),
+ isLbs,
},
organizationId,
});
- // 4. Проверка геозон
- await checkGeozones(asset, input, recordedAt);
+ // 4. Проверка геозон — только для настоящих GPS-точек;
+ // LBS-координаты вышек в логику геозон не пускаем
+ if (!isLbs) {
+ await checkGeozones(asset, input, recordedAt);
+ }
}
async function checkGeozones(
diff --git a/server/gps/routes.ts b/server/gps/routes.ts
index ac6b06f..97a6f4a 100644
--- a/server/gps/routes.ts
+++ b/server/gps/routes.ts
@@ -15,6 +15,124 @@ import { storage } from "../storage";
const MAX_TRACK_POINTS = 2000;
+// =====================
+// OSRM map matching (привязка трека к дорогам)
+// =====================
+
+// URL OSRM-сервиса (контейнер во внутренней Docker-сети)
+const OSRM_URL = process.env.OSRM_URL || "http://osrm:5000";
+// Лимит OSRM Match API — 100 координат на запрос; берём с запасом
+const OSRM_CHUNK_SIZE = 95;
+// Таймаут запроса к OSRM
+const OSRM_TIMEOUT_MS = 5000;
+// TTL in-memory кэша привязанных треков (точки меняются по мере поступления новых)
+const MATCHED_CACHE_TTL_MS = 10 * 60 * 1000;
+
+interface MatchedTrack {
+ /** Геометрия привязанного трека в [lat, lng] (для единообразия с остальными точками) */
+ points: Array<[number, number]>;
+ /** Суммарная длина по дорогам, метры */
+ distance: number;
+ /** Суммарная длительность по дорогам, секунды */
+ duration: number;
+}
+
+// In-memory кэш: ключ `${assetId}:${from}:${to}` → результат (или null при недоступности OSRM)
+const matchedTrackCache = new Map();
+// Warn про недоступность OSRM пишем один раз до следующего успешного ответа
+let osrmUnavailableWarned = false;
+
+interface OsrmMatchResponse {
+ code?: string;
+ matchings?: Array<{
+ geometry?: { coordinates?: Array<[number, number]> };
+ distance?: number;
+ duration?: number;
+ }>;
+}
+
+/**
+ * Привязка трека к дорогам через OSRM Match API.
+ * Возвращает null, если OSRM недоступен (endpoint при этом не падает).
+ * LBS-точки сюда не передаём — привязываем только точные GPS-данные.
+ */
+async function matchTrack(points: Array<{ lat: number; lng: number }>): Promise {
+ const allCoords: Array<[number, number]> = [];
+ let distance = 0;
+ let duration = 0;
+
+ for (let i = 0; i < points.length; i += OSRM_CHUNK_SIZE) {
+ const chunk = points.slice(i, i + OSRM_CHUNK_SIZE);
+ if (chunk.length < 2) continue;
+ // ВНИМАНИЕ: OSRM ждёт координаты в порядке lng,lat
+ const coordsParam = chunk.map((p) => `${p.lng},${p.lat}`).join(";");
+ const url = `${OSRM_URL}/match/v1/driving/${coordsParam}?overview=full&geometries=geojson&gaps=split&tidy=true`;
+
+ let data: OsrmMatchResponse;
+ try {
+ const res = await fetch(url, { signal: AbortSignal.timeout(OSRM_TIMEOUT_MS) });
+ data = (await res.json()) as OsrmMatchResponse;
+ if (osrmUnavailableWarned) {
+ console.log("[GPS] OSRM снова доступен");
+ osrmUnavailableWarned = false;
+ }
+ } catch (err) {
+ // OSRM недоступен (ECONNREFUSED/таймаут) — трек без привязки, endpoint не падает
+ if (!osrmUnavailableWarned) {
+ console.warn(`[GPS] OSRM недоступен (${OSRM_URL}):`, err instanceof Error ? err.message : err);
+ osrmUnavailableWarned = true;
+ }
+ return null;
+ }
+
+ // Чанк не смэтчился (code != 'Ok') — пропускаем его и продолжаем
+ if (data.code !== "Ok" || !Array.isArray(data.matchings)) continue;
+
+ for (const matching of data.matchings) {
+ const coords = matching.geometry?.coordinates;
+ if (Array.isArray(coords)) {
+ for (const [lng, lat] of coords) {
+ // Дедупликация стыковочной точки между чанками/matching'ами
+ const prev = allCoords[allCoords.length - 1];
+ if (prev && prev[0] === lat && prev[1] === lng) continue;
+ // Храним в [lat, lng] для единообразия с точками трека
+ allCoords.push([lat, lng]);
+ }
+ }
+ distance += matching.distance ?? 0;
+ duration += matching.duration ?? 0;
+ }
+ }
+
+ if (allCoords.length < 2) return null;
+ return { points: allCoords, distance: Math.round(distance), duration: Math.round(duration) };
+}
+
+/** Привязка трека с in-memory кэшем (TTL 10 минут) */
+async function matchTrackCached(
+ assetId: number,
+ from: Date | null,
+ to: Date | null,
+ points: Array<{ lat: number; lng: number }>
+): Promise {
+ const key = `${assetId}:${from?.toISOString() ?? ""}:${to?.toISOString() ?? ""}`;
+ const cached = matchedTrackCache.get(key);
+ if (cached && cached.expiresAt > Date.now()) return cached.data;
+
+ const data = await matchTrack(points);
+ matchedTrackCache.set(key, { data, expiresAt: Date.now() + MATCHED_CACHE_TTL_MS });
+
+ // Простая уборка протухших записей, чтобы Map не рос бесконечно
+ if (matchedTrackCache.size > 100) {
+ const now = Date.now();
+ for (const [k, v] of matchedTrackCache) {
+ if (v.expiresAt <= now) matchedTrackCache.delete(k);
+ }
+ }
+ return data;
+}
+
+
function parseDateParam(value: unknown): Date | null {
if (typeof value !== "string" || !value) return null;
const d = new Date(value);
@@ -473,6 +591,8 @@ export function registerGpsRoutes(app: Express) {
lastSeenAt: a.lastSeenAt,
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
lastGpsFixAt: a.lastGpsFixAt, // время последнего GPS-фикса (для индикатора потери спутников)
+ gpsLost: a.gpsLost, // режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS
+ lastIsLbs: a.lastIsLbs, // последняя точка — координаты вышки (примерная позиция)
})),
});
});
@@ -509,7 +629,23 @@ export function registerGpsRoutes(app: Express) {
sampled = true;
}
- res.json({ success: true, points, total, filteredCount, sampled });
+ // Привязка трека к дорогам через OSRM (?matched=1): только точные
+ // GPS-точки (isLbs исключаем). При недоступности OSRM — matched: null,
+ // endpoint не падает.
+ let matched: MatchedTrack | null | undefined;
+ if (req.query.matched === "1") {
+ const gpsOnly = points.filter((p) => !p.isLbs);
+ matched = gpsOnly.length >= 2 ? await matchTrackCached(id, from, to, gpsOnly) : null;
+ }
+
+ res.json({
+ success: true,
+ points,
+ total,
+ filteredCount,
+ sampled,
+ ...(matched !== undefined ? { matched } : {}),
+ });
});
// =====================
diff --git a/server/gps/storage.ts b/server/gps/storage.ts
index e462d4c..4103994 100644
--- a/server/gps/storage.ts
+++ b/server/gps/storage.ts
@@ -146,10 +146,11 @@ export class GpsStorage {
* Обновление последней позиции объекта (и перевод в online).
* last_seen_at = время ПРИЁМА сервером (new Date() здесь),
* last_recorded_at = время точки по устройству (recordedAt).
+ * isLbs = последняя точка — LBS (координаты вышки), см. режим «потерян GPS».
*/
async updateAssetLastPosition(
assetId: number,
- data: { lat: number; lng: number; speed?: number | null; course?: number | null; recordedAt: Date }
+ data: { lat: number; lng: number; speed?: number | null; course?: number | null; recordedAt: Date; isLbs?: boolean }
): Promise {
await db
.update(gpsAssets)
@@ -160,11 +161,24 @@ export class GpsStorage {
lastCourse: data.course ?? null,
lastSeenAt: new Date(),
lastRecordedAt: data.recordedAt,
+ lastIsLbs: data.isLbs ?? false,
isOnline: true,
})
.where(eq(gpsAssets.id, assetId));
}
+ /**
+ * Переключение режима «потерян GPS» у объекта.
+ * true — трекер присылает stale re-send'ы (не видит спутники), свежие точки
+ * помечаются как LBS; false — нормальный режим.
+ */
+ async setGpsLost(assetId: number, lost: boolean): Promise {
+ await db
+ .update(gpsAssets)
+ .set({ gpsLost: lost })
+ .where(eq(gpsAssets.id, assetId));
+ }
+
/**
* Отметка «устройство живо» без обновления координат
* (точка отброшена фильтром джиттера, но приём был).
diff --git a/shared/schema.ts b/shared/schema.ts
index 2918e58..37ee644 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -3998,6 +3998,10 @@ export const gpsAssets = pgTable("gps_assets", {
// Время последнего GPS-фикса по устройству (из stale re-send = замороженный fix, либо свежая принятая точка)
lastGpsFixAt: timestamp("last_gps_fix_at"), // время ПРИЁМА последней точки сервером (онлайн-индикатор)
lastRecordedAt: timestamp("last_recorded_at"), // время последней точки по устройству (fixTime)
+ // Режим «потерян GPS»: трекер присылает stale re-send'ы, свежие точки считаются LBS
+ gpsLost: boolean("gps_lost").notNull().default(false),
+ // Последняя принятая точка — LBS (координаты сотовой вышки, не GPS)
+ lastIsLbs: boolean("last_is_lbs").notNull().default(false),
createdAt: timestamp("created_at").defaultNow(),
}, (table) => ({
orgIdx: index("gps_assets_org_idx").on(table.organizationId),
@@ -4018,6 +4022,9 @@ export const gpsPositions = pgTable("gps_positions", {
speed: doublePrecision("speed"),
course: doublePrecision("course"),
accuracy: doublePrecision("accuracy"),
+ // Точка — координаты сотовой вышки (LBS), а не GPS-фикс; сохраняется и
+ // отображается (пунктир), но не участвует в логике геозон
+ isLbs: boolean("is_lbs").notNull().default(false),
recordedAt: timestamp("recorded_at").notNull(),
createdAt: timestamp("created_at").defaultNow(),
}, (table) => ({