круглосуточно
+7 (999) 901-21-61с 9:00 до 19:00 Мск

Как устроен Parallax-GPS: архитектура системы мониторинга

Платформа управления автопарком на базе GPS-трекинга, телеметрии CAN-шины и ML-аналитики.

СтекNestJS, TypeScript, PostgreSQL, React, MUI, Leaflet, Python (ML)
Архитектура5 микросервисов в Docker-стеке под Traefik
Масштаб35 backend-модулей, ~20 фронтенд-страниц

О проекте

Система мониторинга транспорта - платформа управления автопарком для компаний, которые эксплуатируют коммерческую технику и хотят понимать, что с ней происходит. Система собирает данные с GPS-трекеров и CAN-шины машин, в реальном времени показывает положение и состояние парка на карте, ведёт историю перемещений, анализирует телеметрию двигателя и расход топлива, регистрирует события и отправляет уведомления в telegram, ведёт учёт ремонтов и технического обслуживания, списывает со счёта компании ежедневную оплату.

Что внутри

На карте - все машины компании в моменте. Для каждой видны актуальные параметры: скорость, направление, обороты, температура охлаждающей жидкости, напряжение бортовой сети, уровень топлива, ошибки двигателя.

История маршрутов - с очисткой треков от спуфинга, дрейфа на парковках и помех от РЭБ. Графики телеметрии - с детекцией заправок, сливов и аномалий расхода. Журнал событий - с алертами о критических состояниях машины и накопленной статистикой. Учёт техники - с привязкой ремонтов, проблем, документов и истории технического обслуживания. Биллинг - со списанием абонентской платой по тарифам, контролем остатков, защитой от двойных списаний.

Параллельно работает Python ML-сервис, который на индивидуальных моделях для каждой машины ищет аномалии - текущий контур включает потерю тяги и деградацию генератора, архитектура будет расширяться добавлением новых детекторов.

Кому нужна такая система

Компании с автопарком от 5-10 машин, которым важно не просто знать "где машина", а понимать состояние парка: кто из водителей как водит, сколько потрачено топлива, где происходили остановки, определение продолжительных простоев на холостом ходу. Решение подойдёт транспортным предприятиям, строительным компаниям с парком спецтехники, сельхозпредприятиям, коммунальным службам, корпоративным автопаркам.

Чем отличается от Wialon и подобных систем

Wialon, GPSWox и другие готовые решения - это закрытые системы со стандартным набором функций. Они хорошо подходят, если задача стандартная: показать машины на карте, посчитать пробег, выгрузить отчёт. Но как только бизнесу нужны нестандартные сценарии - индивидуальные алерты под специфику парка, собственные ML-детекторы, интеграция с внутренней CRM или 1С, специфический биллинг, - готовая система может не подойти.

Данная система с самого начала спроектирована как расширяемая платформа: открытая архитектура, документированный API, возможность писать собственные детекторы аномалий, гибкая модель тарификации, мультитенантность для обслуживания нескольких компаний с одной инсталляции. Это не альтернатива Wialon "по функциям", это альтернатива "по подходу" - для тех, кому нужна система под себя.

Онлайн-карта парка со всеми машинами в реальном времени
Онлайн-карта — положение и состояние всего парка в реальном времени

Стек и масштаб

Backend

Основное приложение - NestJS 11 + TypeScript 5.7, TypeORM 0.3 + PostgreSQL 15, Socket.io 4 для real-time, JWT через passport-jwt + bcrypt, PDFKit для генерации документов, dayjs/luxon для работы со временем, class-validator + class-transformer для DTO, @nestjs/schedule для cron-задач, multer для загрузок, Swagger для документации API.

TCP-серверы для трекеров - реализации парсеров протоколов поверх Node.js TCP: Sinotrack, Galileosky, Vega/Wialon-IPS, плюс отдельный приёмник автономных маяков. Развёртываются как часть основного приложения, каждый на своём порту.

ML-сервис - отдельный микросервис на Python 3 + apscheduler + pandas. Хранит обученные модели в Docker-volume.

Tile-proxy - отдельный микросервис на Node.js + sharp для хранения кеша карт и конвертации в WebP.

Frontend

React 19 + TypeScript, Redux Toolkit 2 (типизированные слайсы и селекторы), React Router 7, MUI 7 + MUI X DataGrid 8 + MUI X DatePickers 8, Leaflet 1.9 + React Leaflet 5 для карт, Recharts 3 для графиков, Socket.io-client 4.8 для real-time, axios для авторизации, react-window для виртуализации, react-dnd 16, react-pdf 10 для просмотра документов, mammoth для DOCX, xlsx для экспорта в Excel.

Архитектура - Feature-Sliced Design, разделение по бизнес-фичам на уровне директорий.

Инфраструктура и развёртывание

Docker Compose, Traefik как обратный прокси с автоматическим Let's Encrypt, разделение TCP-роутеров по протоколам трекеров, healthcheck'и для критичных сервисов, persistent volumes для БД, кеша карт и ML-моделей.

Внешние интеграции

Telegram Bot API (алерты и уведомления), Google Geolocation API (LBS-геолокация по сотовым вышкам), Perplexity AI (анализ проблем техники), OpenCellID (импорт базы вышек), OpenStreetMap + OpenTopoMap (тайлы карт).

История маршрутов: очищенный трек машины с остановками на карте
История маршрутов с очищенным треком и списком остановок

Цифры

Backend-модулей в NestJS35
Фронтенд-страниц20
Поддерживаемых протоколов трекеров3 (Sinotrack, Galileosky, Vega)
Сервисов в Docker-стеке5 (приложение, БД, ML-воркер, tile-proxy, Traefik)
Активных детекторов в ML-воркере2 (потеря тяги, исправность генератора)
Типов алертов реактивного мониторинга8
Шагов в pipeline постфильтрации треков10
Источников телеметрии в работеGPS + CAN-шина

Архитектура системы

Система построена как набор взаимосвязанных микросервисов, развёрнутых через Docker Compose под обратным прокси Traefik. Каждый сервис решает свою чётко очерченную задачу и общается с остальными через документированные интерфейсы - REST API, WebSocket, прямые TCP-подключения от трекеров, общая база данных для критичных сценариев.

Пять сервисов в стеке

Основное приложение (NestJS). Ядро системы. Отвечает за бизнес-логику, обработку телеметрии, биллинг, авторизацию, REST API для фронта, WebSocket для real-time обновлений, парсинг трёх протоколов трекеров через TCP-серверы на отдельных портах. Внутри - около 35 модулей, организованных по доменным областям: техника, трекеры, телеметрия, поездки, алерты, биллинг, ремонты, ТО, документы, пользователи, компании.

PostgreSQL. Основное хранилище. Все операционные данные - техника, точки треков, телеметрия, события, биллинг, документы - находятся здесь. Изоляция между компаниями обеспечена на уровне схемы через company_id во всех ключевых сущностях.

ML-воркер (Python). Отдельный микросервис для аналитики. Раз в неделю переобучает индивидуальные модели на каждую машину по накопленным данным телеметрии. Каждые 15 минут запускает детекцию аномалий по свежим данным и пишет результаты в общую БД. Сейчас работают два детектора (потеря тяги, исправность генератора), архитектура расширяется добавлением новых классов в массив активных детекторов.

Tile-proxy (Node.js). Отдельный микросервис кеширования и проксирования картографических тайлов. Принимает запросы от фронта, проверяет локальный кеш, при отсутствии обращается во внешние источники (OpenStreetMap, OpenTopoMap) с two-tier ротацией хостов и автоматическим переключением источников при ошибках. Полученные тайлы конвертируются в WebP и кешируются. Раз в сутки - очистка кеша по TTL и общему лимиту 10 ГБ.

Traefik. Обратный прокси на входе в стек. Терминирует HTTPS с автоматическим получением и обновлением сертификатов через Let's Encrypt. Маршрутизирует HTTP-запросы по правилам в основное приложение и в tile-proxy. Отдельно - пять TCP-роутеров на портах 9090-9094 для прямого подключения устройств по их собственным протоколам: основной Galileosky (9090), Вега MT по Wialon IPS (9091), Galileosky грузовые с J1939 (9092), Galileosky легковые (9093) и автономные маяки Вега MX (9094); у каждого свой entrypoint Traefik и свой слушатель в приложении.

Принципы взаимодействия

Основное приложение и БД - основной контур. Сюда стекается вся операционная нагрузка.

ML-воркер не интегрирован в основное приложение специально. Это архитектурное решение: ML-задачи имеют другие требования (Python-экосистема для pandas/scikit-learn, периодический режим работы, изоляция нагрузки от пользовательских запросов), и их разумнее держать отдельно. Воркер читает данные из общей БД и пишет туда же результаты - связь "по данным", не "по API". Это минимизирует точки сопряжения и позволяет переписать или расширить ML-часть без вмешательства в основное приложение.

Tile-proxy - изолированная вспомогательная служба. У неё нет связи ни с основным приложением, ни с БД. Её задача - только обработка картографических тайлов, и она независима от всего остального стека. Это даёт устойчивость: если у tile-proxy случается проблема (закончилось место на диске, недоступны внешние источники тайлов) - основная функциональность системы (приём данных от трекеров, реактивные алерты, биллинг, отчёты) продолжает работать без перебоев.

Трекеры подключаются напрямую, минуя HTTPS и REST. У GPS-трекеров есть собственные бинарные протоколы поверх обычного TCP, и они не понимают современную веб-обвязку. Поэтому Traefik разводит TCP-подключения по портам, и парсеры протоколов в основном приложении принимают их как есть.

Диаграммы

Схема архитектуры основного приложения
Архитектура приложения — внутреннее устройство основного приложения, связи между модулями
Схема развёртывания: пять сервисов в Docker-стеке
Архитектура развёртывания — пять сервисов в Docker-стеке и потоки данных

Ключевые технические решения

Шесть главных историй ниже - это не "обзор технологий", это конкретные инженерные задачи и решения. У каждой - своя проблема, свой подход, свои технические детали. Внутри каждой истории толстая часть свёрнута в toggle-блок "Технические детали" - раскрывать стоит тем, кто хочет глубже разобраться в реализации.

Каскадная LBS-геолокация: координаты, когда нет GPS сигнала

Многоуровневая система определения местоположения по сотовым вышкам с самообучающимся локальным кешем, fallback'ом на Google API и подстраховкой через соседние CellID - для случаев, когда GPS-приёмник не может получить координаты.

Проблема

GPS не работает всегда. Он не работает в плотной городской застройке, в подземных паркингах, в гаражах, в туннелях. Он не работает первые несколько секунд после старта приёмника - пока спутники ещё не "пойманы". Так же на него влияет работа систем РЭБ.

Современные трекеры передают помимо GPS данные о ближайших сотовых вышках - параметры LBS-фиксации (Mobile Country Code, Mobile Network Code, Local Area Code, Cell ID). По этим параметрам можно определить координаты вышки и приблизительно - координаты машины. Но координаты вышек не приходят с трекера в готовом виде. Их нужно резолвить через базы данных или внешние API, и каждый из доступных способов имеет свои ограничения по точности, стоимости, лимитам запросов.

Стандартные подходы здесь либо одноуровневые (работает только локальная база, и если в ней нет вышки - нет координат), либо завязаны на платные API с ограничениями по числу запросов. У данной системы - каскад из трёх уровней с самообучением.

Подход и решение

При получении пакета телеметрии с LBS-параметрами, запускается каскадная попытка определить местоположение. Каскад идёт от самого дешёвого и быстрого источника к самому дорогому, останавливаясь на первом, который дал результат.

Уровень 1 - локальная база OpenCellID. Это публичная база данных сотовых вышек мира (более 2 миллионов записей), которая импортирована в локальный PostgreSQL. Поиск по ней - обычный SQL-запрос с индексом, занимает миллисекунды. Если вышка есть в базе - координаты получены, готово.

Уровень 2 - Google Geolocation API. Если в локальной базе вышки нет, идём в Google API. У него есть лимит - 90 запросов в день на проект (free tier), и каждый "промах" локальной базы поедает квоту. Здесь два дополнения. Во-первых, кеширование результатов: если Google вернул координаты для какой-то вышки - мы их сохраняем в локальную базу с пометкой "получено через Google". В следующий раз эта же вышка зарезолвится локально, без обращения к API. Самообучение через selfCollect: со временем "горячие" вышки - те, мимо которых проезжают машины компании - оседают в локальной базе, и Google API дёргается только для редких, новых для нас вышек.

Уровень 3 - fallback на соседние CellID. Если Google не нашёл вышку (исчерпан лимит, ошибка сети, неизвестная вышка) - есть последний шанс. У трекеров часто приходит информация не только о текущей вышке, но и о соседних. Если хотя бы одна из них есть в нашей базе - мы возвращаем её координаты с пометкой coord_approximate=true. Это не точное местоположение машины, но соседние вышки одной соты - это километры друг от друга, не сотни километров. На карте такая точка отображается особым маркером с расходящимися волнами - пользователь видит "координаты приблизительные, GPS не было".

Каскад закрывает 95%+ случаев, когда GPS данных не хватает. Машина, которая въехала в подземный паркинг, не пропадает с карты - она перетечёт с GPS-точки на LBS-точку и продолжит быть видимой.

Дашборд телеметрии машины с графиками напряжения, топлива и работы двигателя
Дашборд телеметрии: напряжение, топливо и работа двигателя за период

Двухслойная фильтрация уровня топлива: медиана плюс Калман с подтверждением событий

Двухступенчатая обработка показаний топливного датчика: медианный фильтр на 9 точек убирает мгновенные выбросы, фильтр Калмана с механизмом подтверждения событий разделяет реальные изменения от шума - без задержки реакции на настоящие заправки и сливы.

Проблема

Топливный датчик в баке - простое аналоговое устройство, и его показания постоянно прыгают. Машина проехала по кочке - топливо плеснулось, датчик показал на 5 литров больше или меньше. Машина наклонилась на повороте - то же самое. Машина завелась, поехала, остановилась - каждое такое событие вносит шум в показания.

При этом среди этого шума есть полезный сигнал: реальное изменение объёма топлива. Заправка - резкое увеличение на 30-50 литров за минуту-две. Слив (вандализм или хищение) - резкое уменьшение на десятки литров за пару минут. Постепенный расход - медленное снижение по мере движения машины.

Задача - отделить реальные изменения от мгновенных всплесков, не теряя при этом самих событий. Если просто сгладить агрессивным средним значением - настоящие заправки и сливы тоже размажутся в "плавный рост/падение", и факт события мы пропустим. Если не сглаживать вообще - каждая яма на дороге будет выглядеть как мини-слив топлива.

Подход и решение

Применена двухслойная фильтрация, в которой каждый слой решает свою часть задачи.

Слой 1 - медианный фильтр на скользящем окне в 9 точек. На вход - поток сырых показаний датчика. На выходе - медиана последних 9 значений вместо текущего "сырого". Медиана здесь работает лучше, чем среднее арифметическое: одиночный выброс на 30 литров вверх или вниз не сдвигает медиану ни на литр. Это дёшево (одно сравнение и сортировка короткого массива), снимает 90% мгновенного шума и при этом не маскирует реальные тренды - медленный плавный расход и резкие события одинаково проходят фильтр в нетронутом виде.

Слой 2 - фильтр Калмана с подтверждением событий. На вход - выход медианного фильтра. На выходе - "оценочный" уровень топлива и факт регистрации события (заправка/слив). Здесь центральная задача - различить шум от события, и решение нестандартное.

Стандартный фильтр Калмана для такой задачи имеет проблему: он "размазывает" реакцию на резкое изменение по 5-10 шагам. Реальная заправка из 40 литров будет регистрироваться как 8 литров, потом 16, потом 24, потом 32 - и только к концу выйдет на правильный уровень. Это значит, что в момент самой заправки система не знает, что она происходит - она увидит только финальный результат через несколько минут.

Поэтому был добавлен механизм подтверждения событий: фильтр следит за расхождением между предсказанным состоянием и наблюдаемым. Если расхождение превышает порог (5 литров) и сохраняется в течение 5 последовательных точек - это подтверждённое событие. Фильтр не пытается его сгладить, он его признаёт: ставит новый базовый уровень, фиксирует факт события, передаёт его в FuelEventDetector для дальнейшей классификации (заправка или слив, в зависимости от знака изменения и темпа).

Если расхождение не подтверждается через 5 точек (вернулось в норму) - это был выброс, и фильтр продолжает работать в обычном режиме сглаживания.

Это даёт чистый результат: на графике топлива у пользователя - плавные кривые расхода между событиями и резкие, чёткие "ступеньки" в местах реальных заправок и сливов. Без задержки, без размазывания, без ложных срабатываний.

График расхода топлива по дням с отмеченными заправками и сливами
Расход топлива по дням и журнал заправок и сливов

Детекция заправок и сливов: разные критерии для разных событий

Стейт-машина детекции событий с топливом, где заправки и сливы определяются разными критериями (объём и устойчивость для одних, темп и скорость машины для других), с защитой от ложных срабатываний и объединение близких по времени событий - потому что эти явления физически различны.

Проблема

Когда фильтр Калмана из предыдущей главы зафиксировал событие - резкое изменение уровня топлива - это ещё не значит, что нужно сразу записать "заправка" или "слив" в журнал. Это значит "что-то произошло", и теперь надо понять что именно.

Заправка и слив - это не "одно и то же явление с разным знаком". Это физически разные процессы с разной природой и разными признаками:

  • Заправка - машина стоит на АЗС, бак заливают через горловину, объём растёт за 1-3 минуты на 20-60 литров (типично) и потом стабилизируется. Машина почти всегда не движется (зажигание выключено или двигатель работает на холостых).
  • Слив - это либо хищение, либо повреждение бака. Темп сильно зависит от способа: хищение - 5-10 литров в минуту, повреждение бака - может быть исчисляться считанными литрами в час. Машина в этот момент может стоять (классический случай вандализма) или ехать (повреждение топливопровода).

Если применять к ним одинаковые пороги ("любое изменение больше 5 литров") - будут постоянные ложные срабатывания. Резкие манёвры на полупустом баке - всплески показаний. Нагрев бака на солнце - медленные изменения объёма из-за теплового расширения.

Задача - выработать отдельные критерии для каждого типа события, при этом не пропустить настоящие случаи, но и не создавать ложных алертов.

Подход и решение

Реализован FuelEventDetector как стейт-машина с раздельной логикой для заправок и сливов.

Критерий заправки: прирост уровня топлива не менее 10 литров, сохраняющийся устойчиво в течение нескольких следующих точек телеметрии. Устойчивость - ключевое требование: настоящая заправка после завершения остаётся на новом уровне, и через 5-10 минут после события объём не должен внезапно "упасть обратно". Если уровень сначала вырос на 15 литров, а через 3 минуты вернулся к исходному - это не заправка, это или ошибочное показание датчика, или какой-то технический выброс. Реальная заправка фиксируется только после подтверждения устойчивости в течение 3-5 минут.

Критерий слива: уменьшение уровня не менее 3 литров за период не более 2 минут, с учётом скорости машины и состояния двигателя. Если двигатель работает и машина едет - это не слив, это нормальный расход (пусть даже большой). Если же двигатель выключен или машина стоит - резкое падение уровня уже подозрительно. Здесь критерий мягче по объёму (3 литра вместо 10).

Спайк-фильтр. Перед самой логикой детекции стоит фильтр единичных всплесков - если соседние точки нормальные, а одна резко выпадает - она игнорируется. Это снимает остаточный шум, который не поймали медианный фильтр и Калман в предыдущей проверке.

Коалесцинг сливов. Слив может прерываться, потом возобновляется через минуту-другую . Если зафиксировать каждый эпизод как отдельный - журнал будет заполнен множеством записей вместо одной. Коалесцинг объединяет последовательные сливы в течение 5 минут в одно событие с суммарным объёмом и временным диапазоном.

Двухрежимный API. Есть два сценария вызова детектора: батч - на исторических данных при импорте или пересчёте (передаётся весь массив точек, на выходе - список событий), и стрим - на live-данных, точка за точкой (детектор хранит состояние внутри и выдаёт событие сразу при срабатывании). Это позволяет использовать одну и ту же логику и для разбора прошлого, и для real-time мониторинга.

Результат - низкий процент ложных срабатываний при сохранении высокой чувствительности к настоящим случаям.

10-шаговый pipeline постфильтрации треков: GPS, спуфинг, дрейф, РЭБ

Многоступенчатая система очистки трека от физически невозможных явлений: дедупликация, проверка по одометру, маркировка спуфинга, выбраковка выбросов с rollback'ом, распознавание РЭБ-кругов, обработка дрейфа на парковках. Каждый шаг решает свою часть задачи, и эти шаги обмениваются метаданными.

Проблема

Сырой трек, который приходит с GPS-трекера - это не та траектория, по которой реально ехала машина. Это поток координат, в котором кроме реального движения присутствует:

  • Дребезг GPS - машина стоит, а координаты прыгают в радиусе 50-70 метров. На карте это выглядит как ёж из коротких чёрточек вокруг точки парковки.
  • Скачки в неправильную точку - иногда GPS-приёмник на одну точку даёт координаты, отличающиеся от соседних на сотни метров или километры. Физически машина не могла туда попасть и обратно за 10 секунд.
  • Спуфинг - намеренная подмена сигнала GPS. Бывает злонамеренная (попытка скрыть реальный маршрут) и техническая (рядом с источниками помех координаты "уезжают" на большие расстояния).
  • РЭБ-круги - характерный артефакт работы средств радиоэлектронной борьбы. Координаты начинают рисовать окружности или восьмёрки - машина якобы ездит по кругу с центром далёким от реальных координат.
  • Реальное движение, неотличимое от шума - если у машина медленно движется по парковке, один и тот же эпизод может выглядеть и как "дрейф на стоянке", и как "медленное движение". Простой алгоритм путает одно с другим.

Если показывать всё это пользователю как есть - история поездок может превратиться в кашу. Все последующие аналитические выводы окажутся неточными, потому что строятся на загрязнённых данных.

Стандартное решение - простые пороги ("отбросить точки со скоростью > 200 км/ч") - не справляется. Спуфинг часто даёт правдоподобные скорости. РЭБ - тоже. Дребезг на парковке - это вообще не про скорость, это про дисперсию координат.

Подход и решение

В проекте реализован pipeline из 10 последовательных шагов, каждый из которых решает свою часть задачи. Шаги выполняются в строгом порядке, и между шагами передаются метаданные - например, шаг 4 может пометить точку как подозрительную, шаг 7 это учтёт при принятии решения. Это даёт совокупный эффект, который не достигается ни одним шагом по отдельности.

  1. Дедупликация. Точки с одинаковым timestamp'ом (бывает при дублировании пакетов из-за переподключения трекера) отбрасываются.
  2. Anti-spoof по одометру. Сравнение пройденного по GPS расстояния с показаниями одометра (если они приходят с CAN-шины). При расхождении выше порога - точка маркируется как подозрительная.
  3. Resolve coordinates. Точки с LBS-координатами (см. историю про каскадную геолокацию) обрабатываются и помечаются coord_approximate.
  4. LBS-маркировка спуфинга. Если расстояние между GPS и LBS координатами точки выше определённого порога - это подозрительно. Маркируется флагом.
  5. Filter outliers с rollback. Точки, физически невозможные относительно соседних, отбрасываются - но с откатом хвоста: если выброс был принят как валидный, и из-за этого следующие 2-3 точки тоже попали в трек, они отзываются обратно.
  6. Filter teleport return. Случай, когда произошёл “выброс” трека и последующее возвращение в общую канву. Такая точка-выброс удаляется (и метаданные шага 5 учитываются здесь же).
  7. OBD speed mismatch. Сравнение скорости из GPS с показаниями скорости с CAN-шины. Если они расходятся в разы - точка подозрительная.
  8. Median distance filter. Проверка медианного расстояния до соседних точек. Точка, отстоящая от медианы соседних на множитель (например, 5x) - отбрасывается.
  9. Movement without odometer. Самое тонкое место. Если по GPS машина проехала 5 километров, а одометр не сдвинулся ни на километр - это не реальное движение. Это либо РЭБ либо спуфинг с подвижной точкой. Сегменты таких точек выбрасываются. Но важная задача - не выбросить возвращения к реальным точкам если история началась с ложных - поэтому привязываться к одометру жёстко - нельзя.
  10. Parking drift с snap-to-known. Дребезг GPS на парковке. Если скорость близка к нулю и точки прыгают вокруг известной точки парковки - все они схлопываются в эту известную точку. Машина не "вибрирует" на карте, она стоит там, где стоит.

Каждый шаг в отдельности - относительно простая логика. Сила - в их последовательности и взаимодействии. На выходе - очищенный трек.

Экран ML-мониторинга со статусом обученных моделей по каждой машине
ML-мониторинг: статус индивидуальных моделей по каждой машине

ML-воркер: индивидуальные модели на каждую машину

Отдельный Python-микросервис с plug-in архитектурой детекторов. Каждая машина получает свои собственные обученные модели, а добавление нового детектора аномалий сводится к написанию одного класса и регистрации в массиве. Сейчас активны два детектора, архитектура расширяется без перекомпиляции основного приложения.

Проблема

Современная машина с CAN-шиной выдаёт десятки параметров: обороты двигателя, температура охлаждающей жидкости, нагрузка, расход воздуха, напряжение бортовой сети, давление в системе, скорость, передача и так далее. Из этих данных можно делать гораздо больше, чем простые алерты такие как "перегрев". Можно ловить деградацию узлов до того, как они откажут - генератор начал хуже заряжать аккумулятор, привод буксует чаще обычного, расход стабильно вырос на 15% при том же характере поездок.

Но "норма" для каждой машины своя. У КамАЗа другие нормальные значения нагрузки, чем у Газели. У старой машины с пробегом 300 тысяч и у новой - разные базовые показатели. У машины, которая возит щебень, и у машины, которая ездит по городу с равномерной нагрузкой - разные паттерны телеметрии. Универсальные пороги "если расход >X, то аномалия" не работают.

Решение - индивидуальные модели на каждую машину, обученные на её собственных данных. Каждая машина "знает" свои нормальные паттерны, и аномалия определяется относительно её собственной нормы, а не абстрактного эталона.

Вторая часть проблемы - расширяемость. Виды деградации, которые можно детектировать, разные: потеря тяги, состояние генератора, расход топлива, поведение системы охлаждения, паттерны переключения передач. Каждое - своя задача со своими признаками. Если каждый новый детектор требует переписывать весь сервис - добавление одной фичи занимает недели. Нужна архитектура, в которой детектор - это plug-in, который пишется отдельным модулем и регистрируется одной строкой.

Подход и решение

ML-функционал вынесен в отдельный микросервис на Python 3 - он работает рядом с основным приложением, но независимо. Это не каприз архитектуры, а практическое решение: ML-задачи имеют другие требования к окружению (pandas, scikit-learn, numpy - всё это Python-экосистема), другой режим работы (периодический, не реактивный), и держать их в основном NestJS-приложении было бы плохо для всех - для основного приложения, для ML-кода, для развёртывания.

Plug-in архитектура детекторов. Каждый детектор - это класс с двумя обязательными методами: train(machineryId, historicalData) - обучает модель на исторических данных конкретной машины и сохраняет её, и detect(machineryId, recentData) - применяет модель к свежим данным и возвращает оценку отклонений плюс пояснение.

Чтобы добавить новый детектор - пишется новый класс по интерфейсу, добавляется в массив, перезапускается воркер. Никаких изменений в основном приложении, в БД, в API. Сейчас в массиве два класса: TractionLossDetector (потеря тяги - сравнение текущей нагрузки на двигатель с типичной для машины при тех же условиях) и AlternatorHealthDetector (исправность генератора - паттерны изменения напряжения бортовой сети относительно оборотов).

Расписание. Воркер работает по двум регулярным процессам через apscheduler. Раз в неделю по воскресеньям: проходит по всем машинам всех компаний, для каждой машины и каждого активного детектора обучает модель на накопленных данных. Каждые 15 минут - detect_all: для каждой машины и каждого детектора применяет модель к свежей телеметрии за прошедший интервал. Если какой-то детектор выдал anomaly_score > 0.6 - генерируется алерт, который записывается в общую БД.

Изоляция нагрузки. apscheduler настроен с такими параметрами: нельзя одновременно запустить два процесса детекции - даже если предыдущий не успел завершиться, новый ждёт, если воркер был выключен и пропустил несколько интервалов запуска - будет выполнен только один раз, не вся очередь из “опоздавших”.

Хранение моделей. Каждая модель - это файл в Docker-volume, привязанный к machineryId и имени детектора. Volume переживает перезапуски и пересборки контейнера. Если модель отсутствует или устарела - train_all создаёт её заново на следующем еженедельном цикле.

Связь с основным приложением - только через БД. Воркер читает свежую телеметрию из общей PostgreSQL и пишет результаты (статусы моделей, обнаруженные аномалии) туда же. Никаких HTTP-эндпоинтов между воркером и основным приложением. Это даёт устойчивость: воркер можно остановить, пересобрать, переписать целиком - основная функциональность системы при этом не страдает.

Онлайн Карта под real-time нагрузкой: оптимизация маркеров

Императивная Leaflet-карта с десятками машин, каждая со своим состоянием и анимацией, обновляющаяся в реальном времени по WebSocket - без подвисаний, моргания и утечек памяти.

Проблема

Онлайн карта - главный экран системы. На ней “живут” все машины компании в реальном времени: едут, стоят, стоят на холостом ходу, превышают скорость, отображают наличие алертов, теряют связь. Каждое изменение должно отражаться на карте сразу - иначе диспетчер видит устаревшую картину, и вся ценность мониторинга в моменте теряется.

Технически у такой карты несколько одновременных требований, которые друг другу противоречат.

Маркер должен визуально кодировать состояние машины - не просто точка, а живая иконка: цвет показывает режим (едет / стоит на холостых / припаркована / оффлайн), форма показывает движется ли машина и в какую сторону, бейдж показывает критические тревоги (перегрев, низкий заряд, Check Engine), волны вокруг - что координаты приблизительные, госномер под маркером - для опознания. Это много визуальной информации.

Машин много, события идут постоянно. На парке в 30-50 машин WebSocket выдаёт обновления десятки раз в секунду - каждое движение, изменение данных телеметрии. Если на каждом обновлении пересоздавать SVG-маркеры - DOM захлебнётся, карта начнёт моргать, браузер съест всю память.

Стандартное решение через React-обёртки (react-leaflet с <Marker> компонентами) для такой задачи не подходит - на каждом ре-рендере он пересоздаёт DOM-узлы маркеров, что и приводит к проблемам с производительностью. Нужна работа напрямую с императивным Leaflet-API, но без привычных в React паттернов "сбросить и пересоздать".

Подход и решение

Было принято решение отказаться от обёрток react-leaflet и работать с Leaflet напрямую через useRef и эффекты, синхронизируя состояние карты с Redux-стейтом. Это позволяет точечно управлять жизненным циклом каждого маркера- добавлять, обновлять, удалять без массовых пересозданий. Поверх этого работает несколько техник.

Fingerprint-сравнение для отсечки лишних обновлений. Для каждой машины на каждом ре-рендере собирается короткая строка-отпечаток - конкатенация всех визуально значимых полей (скорость, обороты, направление, напряжение, температура, ошибки, статус, госномер). Если отпечаток не изменился - иконка маркера не пересоздаётся, дёргаются только координаты (это дёшево). Сравнение строк гораздо быстрее, чем создание нового SVG и присвоение его маркеру через DOM.

Кеш SVG-иконок для статичных состояний. Для маркеров режимов "стоит на холостых", "припаркована", "оффлайн" комбинации (режим × тревога × госномер × в фокусе) образуют конечный набор - и каждая такая комбинация повторяется между разными машинами и моментами времени. Эти иконки кешируются по составному ключу и переиспользуются. Маркеры в режиме "движется" в кеш не идут - у них уникальный direction (угол поворота стрелки), и попадания в кеш были бы единичными.

Глобальные стили в <head> один раз. Анимации маркеров (пульсирующие столбики у режима "холостые обороты", расходящиеся волны у приблизительных координат, мигающий красный бейдж у критической тревоги) реализованы через CSS keyframes. Они добавляются в <head> страницы один раз при первом рендере карты - не дублируются внутри каждого SVG-маркера. На парке в 50 машин это разница между 50 копиями <style> в DOM и одной.

Diff-based синхронизация маркеров. Когда из стора приходит новый список машин, не происходит полная перерисовка. Вместо этого: существующие маркеры обновляются (если изменились по fingerprint), новые добавляются, исчезнувшие удаляются - индивидуально, по id. Это даёт устойчивую работу карты при потоке real-time событий без моргания.

Стабилизация контейнера перед fitBounds. Отдельная проблема: при первом рендере карты вызвать fitBounds() нельзя, пока контейнер не получил окончательную высоту - иначе карта отцентрирует машины неправильно, и при следующем layout-проходе они "уедут" за границы экрана. Решение: после монтирования карты ждать через requestAnimationFrame, пока высота контейнера не стабилизируется на трёх кадрах подряд, и только тогда вызывать invalidateSize() + fitBounds(). Это устойчивое решение классической проблемы моргания карты при загрузке.

Module-level canvas для измерения текста. Для определения ширины подложки под госномер нужен canvas.measureText. Чтобы не создавать canvas на каждом маркере, он вынесен в module-scope и переиспользуется. Мелочь, но на парке в сотни машин эта мелочь экономит сотни DOM-аллокаций.

Все эти техники работают в комбинации. По отдельности каждая даёт небольшой прирост, вместе - дают плавную работу карты на парке любого реального размера, без моргания, утечек памяти и тормозов при WebSocket-обновлениях.

Дополнительные технические решения

Помимо шести главных разделов выше, в системе работает ряд более коротких, но содержательных решений. Каждое из них самостоятельно - это не "вспомогательный код", а полноценные модули с собственной логикой.

Страница телеметрии с панелью уведомлений о событиях парка
Лента событий и уведомления парка

Реактивный мониторинг и алерты

Параллельно с ML-аналитикой работает контур реактивного мониторинга - детекция критических состояний машины в моменте по простым правилам, без обучения моделей. Восемь типов алертов: перегрев двигателя, низкое напряжение бортовой сети, обрыв питания, длительная работа на холостых, превышение скорости, ошибка двигателя (Check Engine), отсутствие связи с трекером, низкий уровень топлива.

Каждый алерт реализован как стейт-машина с подтверждением, чтобы избежать срабатываний на короткие артефакты сигнала. Например, CheckEngineDetector срабатывает не на одном пакете с флагом ошибки, а только когда флаг присутствует 3 точки подряд - короткий сбой передачи или единичный шум фильтруется. LowVoltageDetector имеет отдельную логику игнорирования первых 20 секунд после запуска двигателя - в момент когда работает стартёр - напряжение проседает, и без исключения он бы регулярно выдавал ложные срабатывания.

Все детекторы используют cooldown с прогревом из БД (warmupCache): при перезапуске приложения в кеш загружается состояние последних срабатываний за последние сутки, чтобы не дублировать алерты, уже отправленные пользователю. Защита от race conditions - синхронный захват флагов до await, чтобы два одновременных пакета телеметрии не запустили одновременно две копии одного алерта.

Пороги всех этих детекторов задаются по каждой машине отдельно. В таблице Machinery под это заведён набор nullable-колонок threshold_*: границы напряжения, давления масла, температуры охлаждающей жидкости, оборотов, минимального уровня топлива, объёма слива и заряда батареи маяка. NULL означает «использовать общий дефолт» — схему можно было выкатить отдельно от логики, не меняя поведение ни одной существующей машины. Разбор «индивидуальное значение или дефолт» вынесен в чистую функцию-резолвер без зависимостей от ORM, поэтому им пользуется и контур алертов, и бортжурнал.

У части порогов дефолта нет вовсе, и это осознанно: для оборотов, давления масла и заряда маяка NULL значит «контроль выключен» — фолбэк на журнальную константу включил бы новый алерт сразу всему парку. Рядом с каждым порогом лежит колонка интервала повтора *_repeat_h: 0 или NULL — оповестить один раз и молчать, пока показатель не вернётся в норму, после возврата отсчёт начинается заново; положительное значение — повторять с этим интервалом, даже если в норму показатель не приходил. Отрицательные и нечисловые значения нормализуются в ноль: молчание безопаснее спама. Чтобы карточка машины могла показать, от чего именно человек отступает, есть отдельный эндпоинт общих дефолтов — он обнуляет ручные колонки явным списком и прогоняет тот же резолвер.

Управление выходами терминала

У терминала Galileosky есть четыре управляющих выхода (OUT0–OUT3) — транзисторные ключи с открытым коллектором. Весь протокол управления ими — одна текстовая команда Out v,s, где v — номер выхода, а s — состояние: 0 замыкает выход на массу, 1 размыкает. По умолчанию все выходы разомкнуты, и управление одним выходом не трогает остальные. Значения лежат в энергонезависимой памяти терминала, поэтому после перезагрузки он восстанавливает последнее заданное состояние сам.

Команды импульса в протоколе нет. Поэтому в системе два типа сигнала: LEVEL — одна команда, выход остаётся в заданном состоянии; PULSE — серверный импульс, то есть пара команд с паузой между ними, причём выключающая уходит в блоке finally и доходит даже если первая завершилась таймаутом. Импульс требует, чтобы трекер был на связи прямо сейчас, иначе команда отклоняется с ошибкой. Уровень же можно заказать спящему трекеру: заказ сохранится и будет добит фоновым доставщиком.

Действий, которые можно повесить на выход, два: ENGINE_LOCK — блокировка запуска двигателя, и HORN — тестовый сигнал. Действие хранится строкой, а не pg-enum, чтобы добавление нового не требовало миграции; частичный уникальный индекс не даёт повесить одно действие на два включённых выхода одной машины. Включение блокировки защищено step-up-паролем: команда на ON для ENGINE_LOCK требует повторного ввода пароля пользователя и сверяется через bcrypt, выключение пароля не требует. Право на команду проверяется в сервисе, а не guard-ом — его получают администратор платформы и руководитель компании.

Состояние выхода описывается парой полей: desired_state — что заказали, actual_state — что подтвердил терминал. Подтверждений два. Первое — немедленное, по ответу терминала вида OUT(3..0)=NNNN: правый символ соответствует OUT0, левый — OUT3, маска разбирается регулярным выражением и раскладывается по строкам выходов одним UPDATE. Второе — фоновое, по телеметрии: Galileosky передаёт состояние выходов двухбайтовым тегом 0x45, сырая маска пишется в колонку output_mask витрины текущего положения, и каждый пакет от машины с включёнными выходами заново сверяет факт с заказом. Именно этот путь и догоняет состояние после перезагрузки терминала — отдельного кода восстановления нет, он не нужен. Расхождение заказа и факта подбирает интервальный доставщик: он находит строки, где команда уходила давно, и повторяет её, с защитой от дублей по времени последней отправки. Каждая отправка пишется в append-only журнал команд с исполнителем, текстом команды, ответом терминала и результатом (confirmed, timeout, not_connected); для фонового доставщика исполнитель записывается как системный.

Автономный маяк как второй трекер машины

Кроме основного трекера машине можно привязать автономный маяк — отдельное устройство со своим питанием. В сущности машины под него заведено отдельное поле backup_tracker_imei, а привязка проверяется тремя условиями: трекер есть в справочнике, у него тип «маяк», и он не занят другой машиной. Штатный трекер маяком привязать нельзя.

Маяки принимает отдельный TCP-сервер, а не общий приёмник Веги, хотя протокол тот же — Wialon IPS. Разделение даёт login-gate и защиту буфера: пока не пришёл кадр логина, сервер не отвечает подтверждением, а после нескольких кадров без логина рвёт соединение. Пакеты маяка идут по собственному пути обработки, отдельному от основного, — это важно для режима заморозки (см. ниже). Команды маякам не отправляются: сервер маяков не входит в роутер команд, у него только диагностический интерфейс списка подключений.

Положение маяка живёт в собственной таблице machinery_beacon_location, одна строка на машину. Помимо координат там хранятся показатели, которых нет у основного трекера: заряд батареи — напряжение и процент, и оценка качества координат — число спутников, флаг «координаты приблизительные» (получены по сотовым вышкам, а не по GPS) и радиус достоверности в метрах, где NULL означает честный GPS-фикс. Отдельная таблица выбрана намеренно: точка маяка обязана попадать в витрину всегда, минуя санитайзер основного пути, и не должна влиять на сторожевой таймер молчания, который считает тишину по времени обновления строки основного положения. Свежесть проверяется на стороне SQL — обновление применяется, только если входящая метка времени новее сохранённой, а гонка вставки ловится по коду нарушения уникальности.

Решение, показывать ли маяк вместо основного трекера, принимает карта. Оно требует совпадения двух условий: основной трекер молчит — либо машина заморожена, либо от него не было точек дольше двух часов, — и при этом точка маяка свежее последней точки основного трекера. Только тогда маркер переезжает на координату маяка, получает отдельный бейдж и, если координата приблизительная, рисуется с радиусом достоверности.

Режим заморозки

Машину, которая простаивает, можно заморозить. Технически это статус FROZEN у её трекера — отдельного поля на машине нет. Переключение идёт через два эндпоинта, и оба работают в транзакции с пессимистичной блокировкой строки трекера: заморозить можно только активную машину, разморозить — только замороженную, иначе запрос отклоняется с понятным текстом ошибки.

Проверка статуса стоит в самом начале обработки пакета — сразу после того, как по IMEI найдена машина, и до гейта ложного времени, записи точки трека, текущего положения и телеметрии. Пакет замороженного трекера просто отбрасывается с записью в лог. Связь трекера подгружается вместе с машиной одним запросом, поэтому на горячем пути не появляется второго обращения к базе. Путь маяка проверки заморозки не содержит вовсе — замороженная машина продолжает отвечать по маяку. Сторожевой таймер молчания тоже смотрит только на активные трекеры, так что заморозка не порождает ложных алертов «трекер пропал».

Тарификация замороженной машины идёт по отдельной колонке тарифа price_per_frozen_tracker_per_day. Суточное списание — cron в полночь по московскому времени, дата операции формируется по московскому календарю. Долей внутри суток в расчёте нет: статус берётся на момент запуска задачи, поэтому льготная ставка начинает действовать со следующих суток, а сутки, в которые машину заморозили, оплачиваются по прежнему статусу. Скидка компании применяется только к активной части: сначала считается сумма по активным трекерам, из неё вычитается скидка, и лишь затем прибавляется сумма по замороженным. В метаданные списания кладутся число замороженных трекеров, ставка, начисленная за них сумма и их идентификаторы — чтобы любое списание можно было разобрать постфактум.

Пока машина заморожена, сменить или отвязать её трекер нельзя: попытка прислать другой IMEI отклоняется с ошибкой 400 и просьбой сначала разморозить технику. Повторная отправка того же IMEI не блокируется — сравнение идёт на неравенство.

Группы техники, гостевые ссылки и отчёт по группе

Парк можно разбить на группы. Инвариант «машина состоит максимум в одной группе» обеспечен не проверкой в сервисе, а схемой: у машины обычная внешняя колонка group_id, а не связь многие-ко-многим. Удаление группы технику не трогает — внешний ключ объявлен с ON DELETE SET NULL. Уникальность имени группы внутри компании тоже держит база: сервис не читает перед записью, а ловит код нарушения уникальности и превращает его в понятную ошибку.

Группе можно выдать гостевую ссылку. Токен — 24 случайных байта в base64url, хранится в колонке группы; срока жизни у него нет, но он отзывается в один запрос, и при отзыве или перевыпуске уже открытые гостевые сокеты принудительно разрываются. Гостевой контроллер — единственное место в проекте, где аутентификация отсутствует намеренно. Гость получает урезанную проекцию: поля перечислены поимённо, без спреда, — координаты, телеметрия, госномер, но ни IMEI, ни идентификатора компании, ни наличия маяка, ни статуса блокировки. Живая карта работает по тем же WebSocket, но в отдельной комнате группы, и гостю намеренно не проставляется идентификатор компании — иначе он попал бы в общие рассылки по парку. Неизвестный или отозванный токен даёт 404 без подробностей.

Отчёт по группе строится как таблица день × машина: пробег, моточасы, расход, заправки и сливы, с итогами по каждой машине и по всей области. Данные собираются ровно тремя запросами к дневным агрегатам — пробега, моточасов и топлива — и сшиваются в памяти по ключу «машина + день»; это то же анти-N+1-решение, что и в сводке по автопарку. Область отчёта — либо группа, либо псевдогруппа «Без группы» для машин, не попавших ни в одну. Тот же отчёт с теми же цифрами доступен гостю по ссылке.

Длина периода ограничена 92 днями — кварталом с запасом. Ограничение стоит в сервисе, а не в DTO: «не длиннее 92 дней» — правило про календарь, а не про формат строки. Причина в устройстве отчёта: он строится тремя запросами без агрегации на стороне базы, и на годовом диапазоне парк из полусотни машин дал бы под двадцать тысяч строк в JSON.

У стационарной техники пробег остаётся null, а не нулём — это намеренно: одометр дизельной электростанции не растёт, и любое число в этой ячейке читалось бы как переезд станции. При этом в итогах по группе пробег всегда число: у смешанной группы null скрыл бы пробег остальных машин. Выгрузка в Excel собирается на клиенте через exceljs, подключённый динамическим import() — библиотека тяжёлая и в основной бандл не попадает; рядом живёт отдельная печатная форма того же отчёта.

Экран биллинга: баланс компании, тариф и история операций
Биллинг: баланс, тариф и история операций списания

Биллинг с гарантиями денежных операций

Система биллинга - учёт балансов компаний и списания по тарифам - построена с нуля как полноценный денежный контур.

Idempotency через idempotency_key - любая операция списания или пополнения требует уникальный ключ, и повторное обращение с тем же ключом не приводит к двойному списанию. Это критично, потому что cron-задачи списания могут запуститься в перекрывающихся окнах при сбое или перезапуске, и без идемпотентности компания теряла бы деньги по два раза за день.

Pessimistic_write блокировки - при изменении баланса транзакция захватывает строку компании в режиме SELECT ... FOR UPDATE, исключая параллельные изменения. Это защищает от классической race condition, когда два одновременных списания читают один и тот же баланс и оба пишут на основе устаревшего значения.

BigInt-арифметика на уровне приложения и БД - все суммы хранятся и считаются как целочисленные копейки, не как float. Это исключает накопление ошибок округления (классическая проблема "0.1 + 0.2 = 0.30000000000000004"), которые на длительной горизонтали приводят к расхождениям балансов.

Ledger-модель учёта - каждое изменение баланса записывается отдельной строкой в журнал транзакций с указанием суммы, типа операции, ссылки на исходную сущность (например, на машину, за которую списывается тариф), идемпотентного ключа. Текущий баланс компании - это не "поле в таблице", это сумма ledger'а. Это даёт полную аудируемость: для любого момента времени можно восстановить, как этот баланс получился.

Адаптеры протоколов трекеров

Современные GPS-трекеры используют разные бинарные протоколы поверх TCP: Sinotrack, Galileosky, Vega/Wialon-IPS - у каждого свои поля, свои форматы кадров, свои особенности (контрольные суммы, экранирование, режимы передачи).

В системе реализованы три адаптера, каждый на своём TCP-порту. Адаптеры решают одну задачу - приведение разнородных пакетов к унифицированному внутреннему формату (TrackerDataDto), который дальше обрабатывается всем стеком одинаково. Логика обработки телеметрии, треков, событий - не знает, с какого протокола пришли данные. Это даёт простоту развития: чтобы добавить поддержку нового протокола, достаточно написать новый адаптер с теми же выходными методами, никакие другие модули не меняются.

Адаптеры разворачиваются в составе основного приложения, но Traefik разводит TCP-подключения по портам - на каждом порту слушает свой парсер; позже к ним добавился ещё один слушатель, приёмник автономных маяков, на собственном порту (см. раздел про маяк ниже). Это позволяет одной машине одновременно обслуживать трекеры разных вендоров без пересборки и переконфигурации.

Real-time через WebSocket

Live-обновления карты, телеметрии и событий построены на Socket.io. Сервер использует @nestjs/websockets с @nestjs/platform-socket.io, аутентификация - через токен в handshake.auth при подключении.

Ключевое архитектурное решение - изоляция событий по компаниям. Каждый клиент при подключении ассоциируется со своей компанией через client.data.companyId, и broadcast'ы отправляются только клиентам из той же компании. Это значит: пользователь компании А никогда не увидит данные о машине компании Б, даже если бы захотел "подсмотреть". Изоляция работает на уровне кода диспетчеризации, не на уровне фронта.

Real-time каналы организованы через комнаты: map (актуальные позиции машин), отдельные комнаты на детальные экраны (telemetry конкретной машины, история треков). Клиент подписывается на нужные комнаты, отписывается при уходе со страницы.

Мультитенантность и общесистемные справочники

Платформа спроектирована как мультитенантная - на одной инсталляции могут работать несколько компаний, и их данные строго изолированы друг от друга. Каждая операционная сущность (машины, трекеры, точки треков, телеметрия, поездки, события, документы, ремонты, биллинг) содержит поле company_id, и все запросы к этим данным фильтруются по нему на уровне сервисов.

Параллельно с этим в системе есть слой общесистемных справочников - данные, расшаренные между всеми компаниями: коды ошибок двигателей с поиском, библиотека технической литературы (мануалы, схемы, инструкции). Любая компания может пользоваться ими, никакая не может их редактировать "под себя".

Связь между справочниками и операционными данными работает автоматически: для конкретной машины подбирается релевантная литература по марке и модели, а CAN-коды ошибок расшифровываются из справочника прямо в карточке проблемы или алерта. Это снимает с механика ручную работу по подбору документации.

Операционные данные изолированы по компаниям, эталонные знания общие. Многие системы делают эту границу размытой - либо дублируют справочники по компаниям (избыточность данных, расхождения между копиями), либо случайно "сливают" приватные данные через общие таблицы. Здесь граница чёткая и закреплена на уровне схемы БД.

Селективное сохранение телеметрии

Поток телеметрии с трекеров - это очень много данных. Машина в движении передаёт пакет каждые 10-20 секунд, парк из 30 машин - это несколько пакетов в секунду. Если сохранять всё - БД будет расти на сотни тысяч записей в сутки, и большая часть этих данных не несёт никакой полезной нагрузки (машина едет с постоянной скоростью по прямой или вообще стоит).

TelemetryFilterService решает задачу селективного сохранения: каждый пришедший пакет проходит через многокритериальный фильтр и сохраняется только при наличии веских причин. Критерии включают: переходы зажигания (включилось/выключилось), переходы двигателя (завёлся/заглушился), появление или исчезновение Check Engine, попадание в критические состояния (перегрев, низкое напряжение), значимое изменение координат относительно последней сохранённой точки, истечение адаптивного heartbeat'а (для движущейся машины - раз в минуту, для стоящей - раз в 15 минут).

Отдельный контур обработки - для архивных точек: иногда трекер задерживает передачу и присылает пакеты пачкой задним числом. Эти пакеты попадают на отдельный путь обработки, чтобы не сбивать стейт-машины реактивных детекторов (которые работают на актуальном "сейчас", а не на восстановлении прошлого).

Селективное сохранение даёт примерно 5-10-кратное снижение объёма данных в БД при сохранении полной картины всех значимых событий.

Downsampling графиков с сохранением экстремумов

На графиках телеметрии (топливо, напряжение, обороты двигателя, скорость) отображаются временные ряды за выбранный период - день, неделю, месяц. Если за месяц у машины накопилось 300 тысяч точек, попытка нарисовать их на графике с шириной 800 пикселей бессмысленна и тяжела для браузера: точки сольются в сплошную линию, а рендеринг займёт секунды.

Решение - downsampling на стороне бэкенда перед отдачей данных на фронт. Период разбивается на N интервалов (типично 200-500), и для каждого интервала возвращается набор репрезентативных точек: первая и последняя точки интервала, минимум и максимум за интервал. Это сохраняет визуальную форму графика — все экстремумы (пики и провалы), все переходы и плавные тренды видны, как если бы рисовалась полная кривая.

Без downsampling'а при простом усреднении, график "потеряет" короткие резкие события (минутный скачок напряжения, секундный провал давления). С простым LIMIT N без выбора экстремумов - не отобразятся пики между сэмплами. Текущий подход даёт визуально точную картинку при объёме данных, который браузер легко рендерит.

Инкрементальные дневные агрегаты телеметрии

Многие отчёты по парку строятся на дневных агрегатах: суточный пробег, суточный расход топлива, суммарное время движения за день, количество событий за день. Каждый раз пересчитывать эти агрегаты по всему массиву телеметрии - медленно и расточительно: за месяц по машине набирается 30 одинаковых вычислений на одних и тех же данных.

Реализован контур инкрементального обновления дневных агрегатов. По мере поступления новых точек телеметрии в БД отдельный механизм обновляет соответствующие записи в таблице daily_aggregates (по машине, по дате, по типу метрики). Старые дни закрыты и не пересчитываются. Текущий день - обновляется при поступлении новых данных.

Отдельная сложность - архивные точки, которые приходят задним числом (трекер задержал передачу из-за потери связи). Для таких случаев механизм агрегирования умеет открывать "закрытый" день для пересчёта, добавлять в него точку, обновлять агрегат и снова его закрывать. Это исключает накопление неточностей в исторических отчётах.

Получаемая из этого выгода - отчёты по парку отрисовываются мгновенно: запросы идут не к десяткам тысяч точек, а к десяткам строк в daily_aggregates.

Модуль ТО, ремонтов и проблем

Помимо мониторинга в моменте, система ведёт полную историю обслуживания техники. Это отдельная подсистема со своей моделью данных, связанная с машинами, но логически независимая.

Регулярное ТО - для каждой машины настраиваются интервалы технического обслуживания по трём осям: пробегу (например, "замена масла каждые 10 тыс. км"), наработки и времени ("замена фильтра раз в 6 месяцев"). Система отслеживает текущий пробег и дату последнего обслуживания, рассчитывает остаток до следующего ТО и подсвечивает приближающиеся сроки в таблице.

Журнал проблем - записи о неисправностях с описанием, фото, моточасами, пробегом, типом и приоритетом. К каждой проблеме привязываются задачи (диагностика, ремонт), история комментариев, документы.

Журнал ремонтов - что было сделано, когда, кем, какие запчасти потрачены. Фиксация фактически выполненных работ по проблеме.

Журнал диагностики - задача по диагностики, может быть создана на основе зафиксированной проблемы и будет хранить в себе статус и результат, если она завершена.

Все контуры (ТО, проблемы, ремонты, диагностика) объединены в общую модель истории обслуживания машины - открывая карточку конкретной машины, владелец парка или механик видит всё в хронологическом порядке: что делали, когда сломалось, что заменили, когда следующее ТО.

ИИ-анализ проблем техники через структурированные запросы

Когда механик заносит в журнал новую проблему по машине (с описанием, фото, моточасами, пробегом) - система отправляет запрос в Perplexity API для получения структурированного анализа. В запросе передаются параметры техники (марка, модель, год, тип двигателя, тяги, трансмиссии) и описание проблемы; в ответ возвращается JSON с гипотезами причин (с оценкой вероятности), пошаговой диагностикой и шагами ремонта с инструментами и запчастями. Результат сохраняется в карточке проблемы.

Ключевая деталь - строгая JSON-схема в запросе через параметр response_format: { type: 'json_schema', json_schema: ... }. Модель не возвращает свободный текст, который потом приходится парсить регулярками; она обязана выдать JSON ровно по заданной схеме, с гарантированными типами полей и обязательностью ключей. Это современный подход к работе с AI API, который снимает целый класс багов .

К одной проблеме может быть привязано несколько задач - диагностики и ремонтов. В карточке проблемы видна вся история "от обнаружения до устранения". Сейчас модуль временно выключен, чтобы не отвлекать от работы над основной частью системы; будет включён в следующем этапе развития проекта.

Самописный прокси-сервер карт с кешированием

Карты в системе - на базе Leaflet с тайлами от открытых источников (OpenStreetMap, OpenTopoMap). Прямое обращение фронта к этим источникам имеет два недостатка: трафик уходит на сторонние домены (медленно, ограничения по rate-limit от OSM, риск блокировок), а с учётом масштаба парка - каждое открытие карты пользователем тянет десятки тайлов. Чтобы решить эту проблему, в стек добавлен отдельный микросервис tile-proxy, написанный на Node.js.

Он перехватывает запросы вида /{z}/{x}/{y}.png, проверяет локальный кеш на диске (TTL 6 месяцев, общий лимит 10 ГБ), при попадании отдаёт сразу. При промахе обращается во внешний источник с two-tier ротацией: сначала между несколькими хостами одного источника (a/b/c.tile.openstreetmap.org), при множественных ошибках 429/403 — переключение на следующий источник (OpenTopoMap). Полученный PNG-тайл конвертируется в WebP (экономия трафика на отдачу - 30-40%) и кешируется в обоих форматах. Раз в сутки запускается очистка кеша: сначала по TTL, потом, если всё ещё больше 10 ГБ - удаление самых старых до 9 ГБ.

Сервис разворачивается через Traefik по пути /tiles с TLS - пользователь видит карту со своего домена, а не от OSM. Это и приватнее, и быстрее, и не зависит от доступности внешних источников для просмотра уже посещённых районов карты.

Frontend-архитектура

Фронтенд приложения построен на React 19 + TypeScript + Redux Toolkit 2 + MUI 7, организован по принципу Feature-Sliced Design: код разделён по бизнес-фичам (машины, треки, телеметрия, биллинг, проблемы, ТО, алерты), каждая фича - самодостаточный модуль со своими slice, селекторами, типами и UI-компонентами. Это даёт ту же возможность, что и модули NestJS на бэкенде: добавлять новые фичи без переплетения с существующими. Всего около 20 страниц + общие компоненты, около 25 Redux-слайсов.

Помимо описанной выше шестой главной истории про оптимизацию маркеров на карте, во фронтовой части системы есть несколько решений, заслуживающих отдельного упоминания.

Корректная и отзывчивая работа фильтров: AbortController + debounce + markStale

Страница телеметрии - типовой пример сложной формы фильтрации: пользователь выбирает машину, период (от-до), и по этим параметрам подгружается дашборд с десятком графиков и сводок. На реальных датасетах запрос за месяц может тянуться несколько секунд. Если пользователь меняет даты - летит несколько запросов подряд, и без специальной обработки возникают сразу три проблемы: лишний трафик к серверу, "пустые" UI-моменты пока новый запрос летит, и race condition (поздний ответ дешёвого запроса перетирает свежий тяжёлый, на экране оказываются неверные данные).

Решение работает на трёх уровнях. Debounce на 400 мс задерживает запуск загрузки - пока пользователь крутит, фронт молчит, ждёт окончания изменений. AbortController через .abort() на промисе RTK-thunk'а отменяет предыдущий запрос на уровне сети - не "просто игнорируем ответ", а реально прерываем fetch. И отдельно - синхронный markStale() сразу при изменении любого фильтра помечает текущие данные в сторе как устаревшие, и UI-карточки моментально становятся тусклыми. Пользователь видит, что данные обновляются, и не сидит в недоумении пока летит запрос.

Это решает сразу три проблемы - производительность, корректность, UX - без подключения внешних библиотек, на штатных средствах RTK и React.

Code splitting: каждая страница - отдельный чанк, Leaflet - изолирован

Все страницы приложения подгружаются через React.lazy. Компонент карты дополнительно изолирован в свой чанк - Leaflet - тяжёлая библиотека, и её не имеет смысла загружать пользователю, который пришёл смотреть отчёты по ТО или биллинг. Это даёт два эффекта: первоначальная загрузка приложения быстрее (минимальный bundle на старте), и пользователи, не пользующиеся картой, никогда её и не подгружают.

Мобильная адаптация

Интерфейс работает не только в десктопе. Карта парка, карточки машин с актуальной телеметрией, выдвижная нижняя панель с деталями - всё адаптировано под мобильный экран. Это критично для целевой аудитории: диспетчер, механик или владелец компании часто находятся не за рабочим столом, а на месте - в гараже, на стройплощадке, в поле. Возможность открыть приложение со смартфона и увидеть текущее состояние парка в моменте - необходимость, а не опция.

Мобильный интерфейс: карта парка и карточки машин с телеметрией на смартфоне
Мобильный интерфейс — карта парка и карточки машин на смартфоне

Прочие штрихи

Несколько мелких, но важных решений работают в фоне на качество приложения. Кастомные stateSanitizer и actionSanitizer для Redux DevTools - тяжёлые массивы (история точек, история телеметрии) скрываются в инспекторе как [N points — hidden], иначе DevTools становится неюзабельным на реальных данных. React.memo для Tooltip'ов в Recharts - чарты пересоздают tooltip-компонент при каждом ховере, мемоизация снимает заметную часть нагрузки. Условный рендеринг серий и осей графиков - если у машины нет CAN-данных, оси температуры/нагрузки на графике двигателя не отображаются вообще, и это работает чисто без фолбэков. WebSocket-хук с явной обработкой переподключения - Socket.io по умолчанию не переподключается при разрыве со стороны сервера, и без ручного connect() карта молча теряет live-апдейты. URL-синхронизация фильтров на странице телеметрии - выбранная машина и период живут в search params, ссылку на конкретный отчёт можно скопировать, обновить страницу или поделиться.

Дорожная карта

Система находится в активной разработке. Ниже - направления развития, разделённые по горизонту планирования.

Ближайшие шаги

Включение модуля журнала проблем с ИИ-анализом. Модуль сейчас отключён, чтобы не отвлекать от работы над ядром. Включение - возврат к работающей интеграции с Perplexity API.

Подключение модулей складского учёта запчастей. В системе реализованы (но пока не активированы) модули заявок на запчасти, счетов, отслеживания связи "запчасть - счёт - ремонт". Их включение замкнёт цикл "неисправность→ диагностика → заявка на запчасти → счёт → ремонт" в едином документообороте.

Среднесрочное развитие

Расширение и доводка ML-детекторов. Архитектура ML-воркера готова. На очереди - анализ стиля вождения и детектор аномального расхода топлива (отклонение от индивидуальной "нормы" машины на типовых режимах). Параллельно - отладка уже работающих детекторов на реальных кейсах: накопление подтверждённых случаев и обратной связи по ложным срабатываниям детектора, уточнение порогов, доработка моделей.

Расшифровка и анализ кодов ошибок двигателя. Сейчас из CAN-шины извлекается только факт наличия ошибки (лампа Check Engine), и сейчас она работает как простой триггер для алерта. Следующий шаг - извлечение конкретного DTC-кода из протокола, его расшифровка по справочнику кодов ошибок (он уже есть в системе как общесистемный справочник), и совместный анализ с данными телеметрии на момент возникновения ошибки. Это превратит реактивный алерт "загорелась лампа" в диагностическое событие "ошибка такая-то, в момент её возникновения двигатель работал в режиме X, температура была Y, обороты Z - типичная причина такая, проверить вот это".

Передача фото и видео с регистраторов для разрешения спорных моментов. Часть современных трекеров умеет передавать снимки и короткие видеофрагменты - для разрешения спорных ситуаций (ДТП, претензии клиентов, разногласия с водителем). Расширение системы под этот канал данных, хранение медиа-материалов с привязкой к точкам маршрута и событиям.

Интеграция с 1С и сторонними CRM. Архитектура мультитенантности и API готовы к подключению внешних систем - для проектов с автопарком в производственной структуре это типовой запрос.

Мобильное приложение для слесарей. Сотрудник видит назначенные задачи (ТО, ремонты, диагностики), отмечает выполнение работы и прикладывает фото результата.

Долгосрочное направление

Платформа развивается как расширяемая система управления автопарком с упором на предотвращение поломок. Главный вектор - углубление диагностического слоя: совместный анализ телеметрии и кодов ошибок, индивидуальные ML-модели на каждую машину, постепенный переход от реактивного контроля ("сломалось - узнали") к проактивному ("идёт деградация - предупредили"). Это альтернатива готовым закрытым системам типа Wialon, в которых нет места собственным детекторам и адаптации под индивидуальные процессы клиента.