golang

От монолита и чатов к FMS и FMS App: новый уровень управления каршерингом с автопарком 19 000+ авто

  • суббота, 22 марта 2025 г. в 00:00:11
https://habr.com/ru/companies/citydrive/articles/893194/

Всем привет! Меня зовут Сухарев Даня, я руководитель FMS-продукта (Fleet Management System) в каршеринге Ситидрайв. Представьте 19 000+ автомобилей, которые нужно обслуживать, заправлять, мыть, чинить и следить, чтобы всё работало как часы. А теперь добавьте к этому старую Админку, разрозненные системы, бесконечные чаты и онлайн-таблицы, в которых исполнители координируют работу. Мы поняли, что так дальше нельзя, и за 3 квартала переписали всё, создав новую систему управления автопарком и мобильное приложение FMS App.

В этой статье расскажу, как мы заменили хаос на чётко работающую систему: построили архитектуру с 11 микросервисами, выбрали стек, уложились в сжатые сроки, пересобрали концепцию на ходу и в итоге создали продукт, который сильно упростил жизнь отдела операций и исполнителей. Разработчикам — про технические решения, продактам — про продуктовые вызовы, всем — про боль, хаос и победы. Поехали! 🚀

ЧАСТЬ 1. КАК ВСЁ БЫЛО УСТРОЕНО ДО

Каршеринг — это не только машины, которыми пользуются клиенты, но и огромная операционная работа: авто нужно регулярно мыть, заправлять, чинить, проверять техническое состояние и так далее. Сложные технические работы проводятся на СТО, а всё остальное прямо в городе  — в день возникает более 2 000 задач от мойки и заправки до замены СТС и техосмотра. Раньше для управления всем этим процессом использовались две основные системы:
1. Админка — система управления автомобилями. В ней хранилась вся информация о машинах, их статусах, технических данных и командах (например, открыть или закрыть двери).
2. Диспетчерская — система, через которую исполнители получали задачи на авто и фиксировали их выполнение.

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

  • Постановка задачи на автомобиль идёт через Админку, а выполнение — через Диспетчерскую. Это порождало путаницу и задержки.

  • Отслеживание задач ведётся в сторонних онлайн-таблицах. Исторические данные о том кто, как и с каким результатом выполнил задачу, были разрозненными.

  • Замерить ход выполнения задач сложно: у нас есть только факт старта и закрытия. 

  • Доработки существующих систем крайне сложные: 2 монолита, которые тянули за собой технический долг и неожиданное поведение при изменениях.

Как это работало на примере задачи «мойка авто»

Процесс мойки машины кажется элементарным: взял машину → доехал до мойки → помыл → вернул. Но на деле всё выглядело иначе

  • Исполнитель заходит в Диспетчерскую и выбирает задачу.

  • Проверяет в чате распределение — на какую именно мойку нужно везти машину.

  • Затем пишет !в другой! чат, чтобы получить машину с линии и доехать до задачи (ДДЗ — «Доехать До Задачи»), так как авто, которое нужно помыть не всегда находится рядом, и до него нужно добраться.

  • Сотрудник колл-центра (КЦ) ищет ближайшее свободное авто, закрепляет за исполнителем, скидывает координаты в чат. Открытие дверей также происходит через сотрудника колл-центра и чат. Исполнитель забирает машину и едет к задаче. 

Всё это — лишь подготовка к выполнению задачи. Теперь сам процесс мойки:

  • Исполнитель добирается до машины, которую нужно помыть.

  • Открывает её через Диспетчерскую.

  • Отвозит в мойку, оставляет в боксе.

  • Берёт следующую задачу и снова проходит весь процесс с ДДЗ.

Этот цикл я называю "каруселью": исполнитель курсирует между задачами и мойкой, каждый раз снова проходя через выдачу ДДЗ. На каждом этапе общение идёт в 3 разных каналах: в первом получает задачу, во втором получает авто для ДДЗ, в третьем решает возникающие проблемы.

Представьте количество сообщений и коммуникаций. На скрине — я 2 часа не читал сообщения в чате.

При этом потоке сотруднику КЦ нужно “выцепить” важные сообщения и обработать их. Например, если исполнитель сообщает о пробитом колесе, оператор должен: увидеть проблему, снять машину с линии, создать новую задачу на шиномонтаж.
По итогу у нас сохранялось минимум данных о процессе. Мы знали только два факта: когда исполнитель начал задачу и когда он её закончил. Всё остальное — в разрозненных чатах.

Текущее положение дел можно визуализировать вот таким скопом задач.

ЧАСТЬ 2. РЕШЕНИЕ — FMS, КОТОРОЕ АВТОМАТИЗИРУЕТ РАБОТУ

Настало время изменений, мы решаем построить FMS — единую систему управления задачами и автомобилями, которая заменит все чаты, монолиты и хаос. Кратко наше решение выглядело следующим образом:

Мы решили не просто разработать новую систему управления с нуля, но и создать отдельное мобильное приложение для выполнения всех задач, чтобы FMS заменяло старую Админку, а весь процесс работы исполнителей был сосредоточен в FMS App — без лишних чатов, переключений между системами и хаоса.

И тут встал любимый продуктовый вопрос: «А какую проблему эта система решает?». В нашем случае, несколько проблем:
1. FMS — это центр знаний об автомобиле. Здесь хранится вся информация о машине: что с ней происходит сейчас, что происходило раньше и что запланировано в будущем.

2. FMS — это единая точка входа для всех систем Ситидрайва. Машина появляется во всей экосистеме именно через FMS.

3. FMS объединяет в себе несколько бизнес-процессов и заменяет старые системы. Мы больше не разделяем работу между Диспетчерской и Админкой — все задачи ставятся, выполняются и отслеживаются централизованно в FMS и FMS App.

Этап 1. Замена админки — fms карточка авто 

FMS начинался с простого UI, который просто реплицировал данные из старой Админки. Но уже через квартал карточка автомобиля стала read-only, а все ключевые операции переехали в FMS.

За этот же квартал мы переписали с нуля:

  • Массовую систему обновления данных об авто.

  • Блок команд на автомобили.

  • Автоматическую подгрузку фото машин.

  • Массовую загрузку документов (ОСАГО, СТС, ПТС и так далее).

  • Управление и обновление координат автомобилей.

  • Историю задач на автомобиль.

  • Историю осмотров.

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

Этап 2 — Замена диспетчерской

Диспетчерская выполняла две ключевые функции:

  • Офисные сотрудники ставят задачи, распределяют их между исполнителями и контролируют выполнение.

  • Исполнители берут задания, выполняют их и фиксируют результат.

Первым делом мы начали проксировать задачи, чтобы помочь офисным сотрудникам, которые этими задачами управляли. Так появился блок задач в FMS, который дал нам:

  • Возможность следить за всеми задачами в одном месте.

  • Массовую постановку задач. Раньше можно было назначить только одну задачу на машину. Теперь — сразу на весь автопарк. Простая, но критически важная фича.

  • Возможность массового управления задачами: назначение, отмена, деактивация машин.

  • Новые этапы и разрезы для мониторинга задач.

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

Спустя два квартала работы небольшой командой у нас появились: новая карточка авто, карточка задачи, список автомобилей и блок задач — ключевые элементы обновлённой системы. За этим стояли:

  • Я — Даниил Сухарев — продакт-менеджер FMS, который держал весь процесс под контролем.

  • Евгений Кислов — тимлид разработки, превративший концепцию в работающий код.

  • Георгий Гулуа — фронтенд-разработчик, благодаря которому интерфейс стал удобным и отзывчивым.

  • Данил Севрюков — дизайнер, задавший новый стандарт UX для FMS.

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

Это был только старт, но уже тогда стало ясно — мы движемся в правильном направлении. 🚀

Этап 3 — FMS APP

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

Мы провели количественное и качественное исследование среди наших исполнителей. Вот некоторые графики, которыми я могу поделиться: 

Наши исполнители сами выбирают, где работать удобнее, , а инструмент для работы — один из ключевых факторов. Вывод один — нужно делать удобное и качественное приложение. 

В идеале всё начинается с описания AS IS — текущего состояния процессов. Но у нас не было времени на долгие исследования, поэтому с командой операций мы ежедневно проводили по 3-4 встречи в день продолжительностью 2-3 часа в течение двух недель. В результате мы не просто зафиксировали AS IS, а сразу перешли к TO BE — новому процессу, который и стал основой для проектирования FMS App.

ЧАСТЬ 3. ЧТО ПОД КАПОТОМ?

Бэкенд. Здесь вопросов не было: все микросервисы FMS были написаны на Go

Типичный стек для русскоязычной компании, где основной язык разработки — Go:

  • Код храним в GitLab, там же крутится CI/CD.

  • Синхронное общение между микросервисами реализовано через gRPC.

  • В качестве основной базы данных используем PostgreSQL.

  • Для асинхронного взаимодействия — Kafka.

  • Деплой в Kubernetes.

  • Мониторинг — стандартный набор: Prometheus, Grafana, Loki, Jaeger.

  • ClickHouse — для аналитики.


Мобильное приложение. Здесь пришлось подумать. У конкурентов есть только Android, а по итогам опроса наших исполнителей 60% пользователи IOS, а 40% — Android, значит, выбираем Kotlin. Во-первых, он позволяет разрабатывать под обе платформы, во-вторых, у нас не было мобильных разработчиков.

Здесь хочу отдельно остановиться на команде. Ранее я упоминал Пашу Сухотерина — хэда клиентской разработки. Я пошёл к нему просить мобильного разработчика, заведомо зная, что свободных рук сейчас нет. Признаюсь, у меня была цель привлечь к разработке именно Пашу — донести важность проблемы, которую мы решаем, и мотивировать его помочь нам. Это сработало — Паша вызвался на помощь, потому что ему было интересно пописать на Kotlin. Вот так мы и закрыли сразу две проблемы: выбрали стек, подходящий под обе платформы, и нашли мобильного разработчика, который действительно горел проектом. А могли бы просто уйти в долгий найм и затянуть проект.

Дизайн. За 1.5 недели работы мы собрали почти 300 экранов приложения со сложной логикой, отрисованных с нуля. В процессе что-то переделывали, но саму концепцию сформировали именно за этот срок. 

Архитектура системы. За System Design отвечал наш тимлид Женя Кислов — мой главный герой проекта. Общий уровень архитектуры строился так:

  • Скрипты автопарка — автоматизированный постановщик задач, формирующий задачи на основе триггеров (пробег, дата последней мойки и т. д.).

  • FMS UI — веб-интерфейс для управления задачами и авто.

  • FMS App — мобильное приложение для исполнителей.

  • CS (Control System) — старая админка, где хранятся ID пользователей, пермишены и т. д.

Как велась разработка

Когда мы только стартовали, в команде было всего три backend-разработчика: один лид и два мидла. Перед нами стояла довольно амбициозная цель — за три месяца выкатить рабочее MVP.

Но был один нюанс: в Ситидрайве уже существовала система, которую мы должны были заменить. Причём не просто заменить, а сразу сделать лучше: более отказоустойчивую и производительную. То есть с самого начала нужно было проектировать систему с учётом высокой нагрузки.

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

С нуля было разработано: 11 микросервисов и 15 000 строк кода. Ключевой микросервис — Task Service. Мы заложили в него все будущие задачи, которые со временем перенесём из старой Диспетчерской.

Как ускорили разработку 

Каждый разработчик писал 2–3 микросервиса: он отвечал за их разработку, поддержку и развитие. Такой подход позволил нам работать независимо друг от друга, разделяя доменные области, а потом просто соединить всё воедино. В итоге у нас не возникало ситуаций, когда один разработчик блокировал работу другого.

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

Одно из нестандартных решений — полное покрытие тестами слоя работы с базой данных (слоя репозиториев) в трёх ключевых микросервисах. Это было необходимо из-за сложной структуры данных: много JOIN-ов, JSONB-поля, а схема могла меняться по нескольку раз в неделю. Например, в одном из обновлений мы разделили таблицу на две, что затронуло почти 50% SQL-запросов. Без тестов это могло бы обрушить весь API и занять неделю на поиск багов, но благодаря тестированию репозиториев мы справились за день.

При этом мы не забывали и про классическое unit-тестирование бизнес-логики. Самые сложные функции в сервисном слое тоже покрывали тестами.

Кроме того, мы покрыли интеграционными тестами практически все API-эндпоинты и Kafka-консьюмеры с динамической структурой. Такие тесты мы запускали на поднятых микросервисах, чтобы максимально приблизить условия к рабочим и минимизировать риски неожиданных багов при каждом релизе.

Таким образом, за 3 месяца рабочий MVP был готов.

Как удалось решить проблему так быстро

Мой подход к продуктовой работе немного отличается от классического. Да, мы провели все необходимые ритуалы: изучили конкурентов, собрали статистику, провели опросы самозанятых (за что отдельное спасибо команде операций).

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

Чтобы достичь результата, мы сформировали ключевые правила: 

  • Минимум встреч, максимум фокуса. Мы отказались от классических оценок, груммингов и сложных планирований. Как я люблю говорить: «Мы пришли не задачки в спринте оценивать, а работу делать». Цель была одна — приложение. Конечно, мы фиксировали задачи в JIRA и балансировали нагрузку, но чтобы сохранять темп отменили все лишние встречи. Остался только короткий дейлик на 15 минут.

  • Команду никто не отвлекает. Вся коммуникация шла через меня — никто не мог писать разработчикам напрямую. Минимум звонков, максимум работы. Когда команда небольшая, но задача амбициозная, важно правильно расставить приоритеты.

Был ли это стартап внутри компании? Можно сказать и так. Мы двигались хаотично, но при этом чётко знали, куда идём. У проекта были DoD, описанное ТЗ на 25 страниц и дизайн на 300 экранов приложения. Но всё это не мешало нам пересобирать приложение на ходу, если это было нужно. Концепция несколько раз менялась, и мы были к этому готовы.

Иногда нам требовались доработки в других системах. Любой, кто работал в разработке, знает, как это бывает: «У нас бэклог, приходите в Q5». Мы с этим не согласились. Наша позиция была простой: либо команда дорабатывает систему у себя, либо мы придём и сделаем это сами (но вам это не понравится).

Было ли это Agile, адаптированным Agile или нарушением всех правил проектного управления? Пусть это решают ценители PMBOK. Важнее другое: приложение было готово в срок, команда не просто не развалилась, а наоборот — выросла. 🚀

ЧАСТЬ 4. ИНТЕРЕСНЫЕ АРХИТЕКТУРНЫЕ РЕШЕНИЯ

Архитектура с микросервисами-стратегиями

Мы разделили систему на два типа микросервисов:

  • Доменные микросервисы — отвечают за конкретные домены, общие для всей системы (например, домен автомобилей или домен задач).

  • Микросервисы-стратегии — имеют одинаковый gRPC API, но разную логику работы. Под капотом могут управлять доменными микросервисами.

Например, при создании задачи сначала нужно понять, нужна ли она вообще. Для заправки автомобиля это просто проверка уровня топлива. А для мойки уже всё сложнее: анализ пробега, времени с последней мойки и даже оценка загрязнённости по фото с использованием ML. У нас более 100 типов задач, поэтому проверок перед созданием много.

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

func createTask(params CreateTaskParams) {
  strategies := map[string]GrpcClient {
    “Wash”: CarWashGrpcClient,
    “Fuel”: FuelGrpcClient,
    // и так далее
  }

  client := strategies[params.TaskType]

  canCreateTask := client.canCreateTask(params.CarId, params.City, …)

  if canCreateTask {
    // логика создания задачи
  }
}

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

JSON-описание флоу выполнения задач

Одна из главных целей — создать систему, которая поддерживает любые типы задач, любые флоу и любые действия над задачами без изменения кода бэкенда/мобильного приложения. Поэтому сам процесс выполнения задачи формируется на сервере и передаётся в мобильное приложение в виде JSON-объекта.

 "stages": [
       {
           "displayName": "Прибыть и осмотреть авто для задачи \"Стационарная мойка\"",
           "status": "completed",
           "taskId": "123a4b48-27dc-4364-974a-c8a1659286a2",
           "taskStatus": "inspection",
           "data": [
               {
                   "key": "ТС",
                   "value": "Geely Atlas Pro, т406нн797",
                   "type": "string"
               },
               {
                   "key": "Координаты автомобиля",
                   "value": "37.527164459228516, 55.65485382080078",
                   "type": "coordinates"
               }
           ],
           "subActions": []
       },
       {
           "displayName": "Выбрать мойку",
           "status": "completed",
           "taskId": "123a4b48-27dc-4364-974a-c8a1659286a2",
           "taskStageCode": "CHOOSE_CAR_WASH",
           "data": [
               {
                   "key": "Адрес",
                   "value": "Академика Волгина 33А",
                   "type": "address"
               },
               {
                   "key": "Часы работы",
                   "value": "22:00 - 07:00",
                   "type": "string"
               }
           ],
           "subActions": []
       },
       {
           "displayName": "Доехать до мойки и отдать в работу",
           "status": "completed",
           "taskId": "123a4b48-27dc-4364-974a-c8a1659286a2",
           "taskStageCode": "GET_TO_CAR_WASH",
           "data": [],
           "subActions": []
       },
       {
           "displayName": "Осмотреть ТС и принять работу для задачи \"Стационарная мойка\"",
           "status": "inProgress",
           "taskId": "123a4b48-27dc-4364-974a-c8a1659286a2",
           "taskStatus": "acceptanceInspection",
           "data": [],
           "action": {
               "displayName": "Осмотреть ТС",
               "deepLink": "acceptance-inspection/scheme"
           },
           "subActions": []
       }
   ]
}

Здесь каждый объект описывает этап задачи и доступные действия (actions, subactions), которые может выполнить самозанятый.

Особенность в том, что второй этап специфичен только для задач типа "мойка" и автоматически подставляется кодом strategies[task.Type].getTaskStages(task.CarId, task.City, …). Этот же микросервис-стратегия имеет свою базу данных автомоек и автоматически подставляет подходящую мойку для конкретного самозанятого.

Также обратите внимание на action в активном этапе. В JSON указывается deeplink, что позволяет реализовать любую кастомную логику: открытие карт, информации об авто и так далее. Если в будущем потребуется изменить поведение — достаточно будет просто обновить ссылку в базе данных, не изменяя код мобильного приложения.

То же самое и с другими этапами. Если, например, у самозанятого в нашей админке поменяют мойку на другую, эта информация автоматически обновится в мобильном приложении.

Подход с опросами

Во флоу выполнения задач важную роль играют осмотры. Как правило, их три:

  • Осмотр авто перед началом работы.

  • Осмотр после выполнения целевых действий (например, проверки повреждений после мойки).

  • Осмотр после возврата авто в доступную для клиентов зону.

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

 "code": "CAR_WASH_INSPECTION",
   "steps": [
       {
           "code": "DIAGONALS_PHOTO",
           "name": "Фотографируем авто по диагонали",
           "description": "Сделайте 4 фото авто. 2 фото — спереди, а 2 фото — сзади. Для каждого встаньте к машине по диагонали.",
           "questions": [
               {
                   "code": "DIAGONALS_PHOTO",
                   "name": "Прикрепите фотографии",
                   "description": "",
                   "type": "files",
                   "required": true,
                   "filesOptions": {
                       "minFiles": 4,
                       "maxFiles": 4,
                       "fileExtensions": [
                           "png",
                           "jpg",
                           "jpeg"
                       ]
                   }
               }
           ]
       },
       {
           "code": "BODY_DEFECTS",
           "name": "Проверяем кузов",
           "description": "Есть ли на нем повреждения?",
           "questions": [
               {
                   "code": "BODY_DEFECTS",
                   "name": "Повреждения",
                   "description": "",
                   "type": "singleChoice",
                   "required": true,
                   "singleChoiceOptions": {
                       "options": [
                           "Повреждений нет",
                           "Есть, но незначительные",
                           "Есть заметные повреждения"
                       ]
                   }
               },
               {
                   "code": "BODY_DEFECTS_PHOTO",
                   "name": "Фото повреждений",
                   "description": "",
                   "type": "files",
                   "required": false,
                   "requiredIfCode": "BODY_DEFECTS",
                   "requiredIfNotValue": "Повреждений нет",
                   "filesOptions": {
                       "minFiles": 1,
                       "maxFiles": 10,
                       "fileExtensions": [
                           "png",
                           "jpg",
                           "jpeg"
                       ]
                   }
               }
           ]
       },
       {
           "code": "WHEELS",
           "name": "Проверяем колеса",
           "description": "В каком они состоянии?",
           "questions": [
               {
                   "code": "WHEELS",
                   "name": "Выберите подходящее",
                   "description": "",
                   "type": "multipleChoice",
                   "required": true,
                   "multipleChoiceOptions": {
                       "options": [
                           {
                               "text": "Все в порядке",
                               "group": 1
                           },
                           {
                               "text": "Стерт протектор",
                               "group": 2
                           },
                           {
                               "text": "Не по сезону",
                               "group": 2
                           },
                           {
                               "text": "Нет болта",
                               "group": 2
                           },
                           {
                               "text": "Низкое давление",
                               "group": 2
                           },
                           {
                               "text": "Другое",
                               "group": 2
                           }
                       ]
                   }
               }
           ]
       },
       {
           "code": "FUEL_TANK",
           "name": "Проверяем бензобак",
           "description": "Лючок и пробка есть и работают исправно?",
           "questions": [
               {
                   "code": "FUEL_TANK",
                   "name": "Выберите подходящее",
                   "description": "",
                   "type": "multipleChoice",
                   "required": true,
                   "multipleChoiceOptions": {
                       "options": [
                           {
                               "text": "Все в порядке",
                               "group": 1
                           },
                           {
                               "text": "Нет лючка",
                               "group": 2
                           },
                           {
                               "text": "Нет пробки",
                               "group": 2
                           }
                       ]
                   }
               },
               {
                   "code": "FUEL_TANK_PHOTO",
                   "name": "Фото бензобака с открытым лючком",
                   "description": "",
                   "type": "files",
                   "required": true,
                   "filesOptions": {
                       "minFiles": 1,
                       "maxFiles": 1,
                       "fileExtensions": [
                           "png",
                           "jpg",
                           "jpeg"
                       ]
                   }
               }
           ]
       },
       {
           "code": "WIND_SHIELD",
           "name": "Проверяем лобовое стекло",
           "description": "Есть ли на нем повреждения?",
           "questions": [
               {
                   "code": "WIND_SHIELD",
                   "name": "Выберите подходящее",
                   "description": "",
                   "type": "singleChoice",
                   "required": true,
                   "singleChoiceOptions": {
                       "options": [
                           "Все в порядке",
                           "Есть одно или несколько повреждений, мешающих водителю",
                           "Есть некритические повреждения"
                       ]
                   }
               },
               {
                   "code": "WIND_SHIELD_PHOTO",
                   "name": "Фото повреждений",
                   "description": "",
                   "type": "files",
                   "required": false,
                   "requiredIfCode": "WIND_SHIELD",
                   "requiredIfNotValue": "Все в порядке",
                   "filesOptions": {
                       "minFiles": 1,
                       "maxFiles": 10,
                       "fileExtensions": [
                           "png",
                           "jpg",
                           "jpeg"
                       ]
                   }
               }
           ]
       },
       {
           "code": "STS",
           "name": "Проверяем наличие СТС",
           "description": "Лежит ли документ в бордачке?",
           "questions": [
               {
                   "code": "STS",
                   "name": "Выберите подходящее",
                   "description": "",
                   "type": "singleChoice",
                   "required": true,
                   "singleChoiceOptions": {
                       "options": [
                           "Да, на месте",
                           "Нет, отсутствует"
                       ]
                   }
               },
               {
                   "code": "EMPTY_GLOVE_BOX_PHOTO",
                   "name": "Фото пустого бордачка",
                   "description": "",
                   "type": "files",
                   "required": false,
                   "requiredIfCode": "STS",
                   "requiredIfValue": "Нет, отсутствует",
                   "filesOptions": {
                       "minFiles": 1,
                       "maxFiles": 1,
                       "fileExtensions": [
                           "png",
                           "jpg",
                           "jpeg"
                       ]
                   }
               }
           ]
       },
       {
           "code": "DASHBOARD",
           "name": "Проверяем приборную панель",
           "description": "Показывает ли она ошибку?",
           "questions": [
               {
                   "code": "DASHBOARD",
                   "name": "Выбрите подходящее",
                   "description": "",
                   "type": "multipleChoice",
                   "required": true,
                   "multipleChoiceOptions": {
                       "options": [
                           {
                               "text": "Все в порядке",
                               "group": 1
                           },
                           {
                               "text": "Чек двигателя",
                               "group": 2
                           },
                           {
                               "text": "Датчик шин",
                               "group": 2
                           },
                           {
                               "text": "Ключ-карта",
                               "group": 2
                           },
                           {
                               "text": "ТО",
                               "group": 2
                           },
                           {
                               "text": "Ошибка подушки",
                               "group": 2
                           },
                           {
                               "text": "ESP / ABS",
                               "group": 2
                           },
                           {
                               "text": "Масленка (уровень / давление масла)",
                               "group": 2
                           },
                           {
                               "text": "Другое",
                               "group": 2
                           }
                       ]
                   }
               },
               {
                   "code": "DASHBOARD_PHOTO",
                   "name": "Прикрепите фото",
                   "description": "",
                   "type": "files",
                   "required": true,
                   "filesOptions": {
                       "minFiles": 1,
                       "maxFiles": 10,
                       "fileExtensions": [
                           "png",
                           "jpg",
                           "jpeg"
                       ]
                   }
               }
           ]
       },
       {
           "code": "INTERIOR_DEFECTS",
           "name": "Салон",
           "description": "Есть ли в салоне повреждения?",
           "questions": [
               {
                   "code": "INTERIOR_DEFECTS",
                   "name": "Повреждения",
                   "description": "",
                   "type": "singleChoice",
                   "required": true,
                   "singleChoiceOptions": {
                       "options": [
                           "Все в порядке",
                           "Есть повреждения"
                       ]
                   }
               }
           ]
       }
   ]

Ключевые моменты:

  1. У каждого вопроса свой тип (загрузка файлов, одиночный/множественный выбор и так далее.).

  2. Для каждого типа передаются параметры (например, поддерживаемые форматы файлов).

  3. Вопросы могут быть обязательными или опциональными в зависимости от ответов (например, если обнаружены повреждения, загрузка фото становится обязательной).

СЛОЖНОСТИ И РЕШЕНИЯ

Ограниченные ресурсы команды

  • Небольшая команда разработчиков, но высокая сложность системы

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

  • Ввели обязательное перекрёстное тестирование: один разработчик проверяет код другого.

  • В тестирование критичных фичей включались продакт и лид.

Проблемы с инфраструктурой

  • Грамотное разбиение системы на микросервисы, учитывая сетевые задержки.

  • Максимальный переход на асинхронные операции через Kafka.

Поддержка нагрузки

Два ключевых решения:

  • Грамотное разбиение системы на микросервисы. Важно не только разбивать микросервисы так, чтоб они содержали 2-4 таблицы, но и учитывать запросы между этими таблицами. Например, наш главный микросервис, который отвечает за задачи (task-service на схеме выше), содержит более 8 таблиц и более 40 методов. Многие GRPC-запросы включают в себя джойны 3-4 таблиц в различных комбинациях, и, поэтому, если бы мы вынесли часть таблиц в отдельный микросервис, то почти в каждый GRP-метод добавилась бы сетевая задержка, что понизило бы производительность.

  • Вынесли как можно больше операций в асинхронный формат. Каждое действие над задачей сопровождается огромным количеством операций. Например, при завершении задачи мы не просто делаем запрос UPDATE task SET status = ‘completed’. Нужно также сделать ряд операций: отправить команду на глушение, вывести авто на линию, создать задачу на проверку качества выполнения задачи (если она ручная), отправить запрос на оплату и так далее. Выполнять эти операции, хоть они и критичные, синхронно нет смысла, так как это сильно нагрузит систему и повысит время ответа пользователю. Такие операции выполняются асинхронно, либо через сообщение в Kafka, с контролем выполнения операции через статусы в бд, либо через горутины (в случае с системами, которые не поддерживают коммуникацию через кафку)

ЧАСТЬ 5. ПЛАНЫ НА БУДУЩЕЕ

Покрытие всей системы E2E-тестами с автоматическим выполнением

Юнит и интеграционные тесты помогают, но у нас распределённая система из 10+ микросервисов, которую невозможно покрыть комплексными сценариями. Есть ситуации, где в одном вызове задействовано 6 микросервисов, тогда тесты не помогут. Необходимы E2E-тесты, которые будут проверять реальное взаимодействие всей системы без заглушек. В качестве языка для таких тестов может быть выбран простой скриптовый язык (например, Python), на котором без труда смогут написать тесты и разработчики, и команда QA. Такие тесты будут выполняться отдельным процессом перед выкаткой любой фичи на продакшн.

Логирование GRPC-запросов

Хоть сейчас мы логируем любые запросы, но этого недостаточно. В нашей системе есть много динамических структур данных на уровне БД и на уровне просто GRPC-запросов между микросервисами. Поэтому в планах добавить логирование тела запросов и ответов GRPC, сообщений Kafka и запросов в PostgreSQL / Clickhouse.

Платформа для запуска лямбд

Наша система во многом построена на идее микросервисов-стратегий. Почти каждую из 100+ различных типов задач необходимо проверять, реально ли она выполнена различными алгоритмами. Сам алгоритм для каждой задачи может быть небольшой и ради 100 строк кода и одной ручки поднимать новый микросервис долго и не оптимально по вычислительным ресурсам. Оптимальнее всего будет иметь платформу, в которую можно будет добавлять новые функции и добавлять им способ взаимодействия (GRPC или Kafka).

ЧАСТЬ 6. ОБЗОР ПРИЛОЖЕНИЯ

Если с технической стороной разобрались, пора перейти к самому вкусному — как выглядит приложение, его ключевые особенности и почему мы им так гордимся:

Возможность доехать до задачи, выбрав авто на карте в приложении, не используя чаты и КЦ.

Карта, на которой исполнитель может выбрать удобную мойку самостоятельно, а не делать запрос в чате.

Карта с задачами, где видно всю информацию об авто: сколько топлива, расстояние до машины и так далее. Всё, чтобы принять решение удобно ли тебе браться за выполнение таска.

Все задачи в одном приложении со встроенной кастомной камерой. Не надо открывать несколько приложений, чтобы произвести приёмку авто.

Возможность обслуживать сразу несколько авто одновременно. Все команды производятся в приложении.

Отображение этапов прямо на экране, которые навигируют исполнителя для выполнения задач. Опытным исполнителям, возможно, они могут показаться избыточными, но для новичков наоборот! Они помогают сделать первую задачу быстро и успешно.

И кейс, которым мы гордимся больше всего — наш исполнитель с ограниченными возможностями по слуху и речи смог закрыть 12 задач за смену с первого раза через наше приложение. Раньше подобная ситуация была невозможна —  нужно было объяснить человеку все системы, чаты, таблички — это очень сложно. А для новичка, даже с ограниченными возможностями выполнять задачи — абсолютно реально.

ЧАСТЬ 7. ОБЗОР АДМИНКИ FMS

Для полноты картины давайте посмотрим, как выглядит выполнение задачи в админке FMS. Мы видим треки перемещения исполнителя: как, сколько и куда он ехал, был ли он на мойке. Каждый этап выполнения задачи теперь можно отследить со временем.

Все этапы работы раскрываются, мы видим фотографии осмотров, все касания с авто и всё ли в порядке.

Все осмотры авто потом хранятся в карточке авто по дате, и по тому, кто делал задачу. 

Мы уже месяц доступны в Ru Store для Android версии, и по ссылке в TestFlight для IOS.

За это время мы стали выполнять задачи на 32% эффективнее, чем с помощью старых инструментов. Процессы стали прозрачнее, мы знаем кто и как сделал задачу — теперь наши метрики честные. Наши осмотры, по сути, делают разметку фотографий для ML, и мы готовы к следующему прыжку, а именно: перенести все остальные задачи и сделать диспатч, что ещё сильнее бустанет нас в части эффективности и метрик.

Наше приложение FMS App и в целом FMS — это только начало пути.

И в заключение ещё раз хочу поблагодарить всех, кто участвовал в разработке проекта!