From 60bd52b75ae7e23318982960a342beceeabb8b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Fri, 28 Aug 2026 17:22:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20?= =?UTF-8?q?=C2=AB=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8F=D0=BD=20GPS=C2=BB=20?= =?UTF-8?q?=E2=80=94=20LBS-=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D1=87=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20(is=5Flbs),?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B5,=20=D0=BF=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=20=D0=B2=20=D1=82=D1=80=D0=B5=D0=BA=D0=B5;?= =?UTF-8?q?=20OSRM=20map=20matching=20=D1=82=D1=80=D0=B5=D0=BA=D0=B0=20(ma?= =?UTF-8?q?tched=20endpoint=20+=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=C2=AB=D0=9F=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=80=D0=BE=D0=B3=D0=B0=D0=BC=C2=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/GpsMap.tsx | 4 + client/src/components/gps/OsmMap.tsx | 6 +- client/src/components/gps/Yandex21Map.tsx | 6 +- client/src/components/gps/marker-html.ts | 13 +- client/src/hooks/useGps.ts | 8 +- client/src/pages/gps/AssetsTab.tsx | 6 + client/src/pages/gps/MapTab.tsx | 10 ++ client/src/pages/gps/RoutesTab.tsx | 122 ++++++++++++++----- client/src/pages/gps/types.ts | 12 ++ docker-compose.yml | 10 ++ migrations/0076_gps_lbs.sql | 6 + server/gps/geozone.service.ts | 52 +++++++- server/gps/routes.ts | 138 +++++++++++++++++++++- server/gps/storage.ts | 16 ++- shared/schema.ts | 7 ++ 15 files changed, 374 insertions(+), 42 deletions(-) create mode 100644 migrations/0076_gps_lbs.sql 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) => ({