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

В WCMA (Whoosh Control Maintenance App, писали о нем в предыдущей статье), нашем внутреннем приложении для управления флотом, мы столкнулись с этой проблемой в полной мере. Напомню, в этом приложении работает наша сервисная команда, через него мы обслуживаем самокаты и велосипеды в городе, следим за их зарядом, переставляем на спросовые парковки, а также восстанавливаем и чиним.  

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

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

Меня зовут Игорь Волынский, я backend-разработчик в команде WCMA Whoosh. И сегодня я расскажу, как мы решили эту проблему: построили централизованную и гибкую систему управления статусами, добавили условные переводы с хендлерами для проверки бизнес-правил и реализовали динамические сценарии для гибкого формирования UI. Спойлер: теперь наши механики и менеджеры видят только те действия, которые им реально доступны, а бэкенд гарантирует целостность данных на уровне системы.

Таблица ESRT — фундамент управления статусами

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

Структура таблицы ESRT

Сама таблица достаточно небольшая и содержит следующие ключевые поля:

  • Entity — сущность, над которой происходит операция;

  • Roles — роли, которым доступен перевод;

  • From\To — перевод статуса А -> B;

  • Visibility — условие видимости/доступности перевода для пользователя.

Зачем мы перенесли сценарии и управление статусными моделями на бэкенд?

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

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

Проблема наглядности и документации: очень частый запрос от владельцев продукта, бизнеса: «А покажите, что может сейчас эта роль, какие переводы ей доступны». В конфлюенсе что-то описано, но ты открываешь код проекта и понимаешь, что документация устарела пару веков назад, копаешься несколько часов и выдаешь ответ «Доступно все, что не запрещено. Вот список запретов». А что доступно — то ограничено «сверху» WCMA. Ответ достаточно размытый, но, по сути, так и было — нет четкого ответа на то, что доступно роли и какой процесс смены статусов мы предлагаем пользователю, а документация очень быстро устаревает.

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

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

JSON-пример безусловного перехода, основанного на таблице разрешенных переводов

Вот как выглядит типичный ответ сервера со списком доступных переходов для сессии технического обслуживания:

{
  "admittedTransitions": {
    "IN_PROGRESS": [
      "QA",
      "WAITING"
    ],
    "QA": [
      "IN_PROGRESS",
      "NO_PARTS",
      "READY",
      "SEMI_READY"
    ],
    "SEMI_READY": [
      "WAITING"
    ],
    "DIAGNOSIS": [
      "WAITING"
    ],
    "NO_PARTS": [
      "WAITING"
    ],
    "WAITING": [
      "IN_PROGRESS",
      "NO_PARTS"
    ]
  }
}

Этот JSON статичен для конкретного пользователя и роли — он показывает только те переходы, которые в принципе доступны. Клиент знает, в каком статусе находится сущность, и сопоставляет с таблицей: например, сессия обслуживания в статусе QA — из QA доступно 4 перевода. Но реальность оказалась сложнее: не всегда переход, который разрешен на уровне ESRT, можно выполнить в конкретной ситуации.

Условные переводы и хендлеры: когда ESRT недостаточно

Таблица ESRT дает нам базовые правила — кто и какие переходы может делать в принципе. Но реальная жизнь богаче схемы в базе данных. Например, бригадир теоретически может перевести устройство из «диагностики» в «ожидание ремонта», но что, если на складе нет нужных компонентов?

Здесь на сцену выходят условные переводы — система проверок, которая фильтрует список безусловных переходов из ESRT на основе реального контекста. Все настройки с правилами включения хендлеров вынесены в отдельную таблицу — esrt_handlers.

Архитектура условных переводов

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

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

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

В первой вариации мы получали статическую таблицу доступности для роли, кешировали ее в WCMA, но сам ответ не учитывал текущее состояние сущности. Хоть все и работало весьма быстро, но не сильно стабильно: возникали ошибки при нажатии на кнопки, не было динамики — не учитывался контекст.

Во второй итерации WCMA передает нам ID сущности и некоторые дополнительные данные. Мы достаем сущности из БД; смотрим текущий статус, допустим, QA — из QA доступно 4 других статуса; для каждого такого перехода делаем «виртуальный» перевод — по сути, прогоняем все хендлеры, которые удовлетворяют условиям, и получаем результат: доступно/недоступно.

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

JSON-пример условного перехода

Вот как выглядит ответ сервера при получении условных переходов, с детализацией по каждому переходу:

{
  "maintenanceTransitions": [
    {
      "admitted": true,
      "status": "QA",
      "toStatus": "IN_PROGRESS"
    },
    {
      "admitted": false,
      "status": "QA",
      "toStatus": "NO_PARTS",
      "reason": "Все запчасти в наличии"
    },
    {
      "admitted": false,
      "status": "QA",
      "toStatus": "READY",
      "reason": "Сначала завершите все работы"
    },
    {
      "admitted": true,
      "status": "QA",
      "toStatus": "SEMI_READY"
    }
  ]
}

Пример в картинке

Процесс получения условных переводов в WCMA
Процесс получения условных переводов в WCMA

А так это выглядит в мобильном приложении для бригадира:

Карточка самоката, со списком статусов и их доступностью
Карточка самоката, со списком статусов и их доступностью

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

Хендлеры проверки по ESRT: по сути, проверяют, что такой перевод в принципе доступен; самая первая базовая проверка.

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

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

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

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

Например, срочно попросили закрыть перевод QA -> SEMI_READY для города Санкт-Петербург: можем сделать это без релиза бекенда и WCMA, да еще и изменения будут подгружены «на лету», практически мгновенно — как?

У нас на бэкенде заведен хендлер CHANGE_STATUS_REGION_FORBIDDEN_HANDLER, он делает только одну вещь — запрещает перевод без каких-либо условий «внутри» кода, но он никогда не включится, если не наполнить esrt_handlers. Добавляем новое условие, говорим, что хендлер включается только для региона Санкт-Петербург и для перевода QA -> SEMI_READY — доступный перевод сразу же пропадает в этом регионе, в зависимости от реализации UI кнопка перевода в SEMI_READY будет скрыта, либо станет неактивной. Если нажать — появится снэкбар «недоступно в вашем регионе».

Динамические сценарии: когда UI формируется на лету

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

Классический подход «хардкодить экраны в мобильном клиенте» здесь работал неоптимально — слишком много переменных, слишком частые изменения бизнес-требований. Поэтому мы решили вынести всю логику формирования UI на бэкенд через динамические сценарии.

Завели Views, Tabs и Widgets

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

Самый большие блоки назвали экранами — Views. View может содержать табы - tabs. А каждый tab содержит widgets - cамые маленькие блоки. Для каждого блока храним информацию по доступам - условия видимости для ролей.

В качестве примера можно посмотреть на ту же карточку самоката - в верхней части имеем табы, в центральной - виджеты. Одна и та же карточка самоката будет сформирована по разному для разных ролей, например для механика может не прийти виджет "Comments" или "Labels" - а для бригадира прийдет. Аналогично для табов - не всем сервисным ролям нужна таба "Map", а только тем, кто работает непосредственно в городе

Каждый виджет имеет какой-то конкретный тип (например, vertical_container, button), виджет-контейнер может содержать другие виджеты. Также завели более уникальные виджеты под разные ситуации. Вместе с android-разработчиками научили WCMA работать с разными типами виджетов.

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

Появилось 2 семейства виджетов:

  1. Уникальные виджеты: содержат только данные без структуры, «мобилка» знает, как рисовать, какие запросы отправлять на бэкенд для взаимодействия. Если надо что-то изменить, то уникальные виджеты добавляются через релиз WCMA и бэкенда, но уже добавленные можно использовать на любых экранах. Они будут приходить или не приходить в зависимости от роли пользователя и контекста.
    (Тут мы не говорим, как и что рисовать, какие кнопки, каких цветов, а просто шлем данные для отображения)

  2. Более generic-виджеты (контейнеры, кнопки): WCMA умеет их рисовать, но не хранит внутри себя информацию, как с ними работать — какие запросы и куда отправлять. Вся необходимая информация для взаимодействия содержится внутри виджета.
    (Тут мы говорим, что надо отрисовать, передается список кнопок, таблиц, их базовые параметры типа размера, стиля и, конечно же, сами данные. С такими виджетами мы настроили работу через action-based-взаимодействие)

Концепция action-based-взаимодействия

Вся магия начинается с понятия action: каждый элемент generic-виджета содержит информацию о том, что должно произойти при взаимодействии с ним. Больше никаких статических экранов — каждое нажатие кнопки, каждый тап по элементу виджета отправляет на сервер структурированные данные о совершенном действии.

JSON-структура action

Базовая структура действия выглядит так (например, кнопка «назад»):

{
  "action": {
    "viewName": "CITY_MANAGEMENT",
    "actionType": "back",
    "contextIds": {
      "78df2c9e-ca4b-11e8-a8d5-f2801f1b9fd1": {}
    }
  }
}

При нажатии на кнопку «назад» весь блок action отправляется на бэкенд. По сути, клиент передает необходимый контекст, который сформировал тоже бэкенд. Клиент не управляет контекстом, лишь следует инструкциям. Хотя без сложностей не обошлось: передавать контекст — дело непростое. Внутри action может быть что угодно: любые данные, даже можно сказать WCMA сходить по URL и выполнить какой-то запрос.

Типы виджетов и их структура

Бэкенд может формировать различные типы интерактивных виджетов:

Статистические ячейки (area_stat_cell):

{
  "type": "area_stat_cell",
  "iconUrl": "https://example.com/icon.png",
  "title": "Активные задачи: 14",
  "subtitle": "За последний час",
  "action": {
    "viewName": "TASKS_DETAIL",
    "actionType": "drill_down",
    "contextIds": {
      "taskType": "active"
    }
  }
}

Зональные ячейки (area_cell) для работы с географическими районами:

{
  "type": "area_cell",
  "id": "zone_13",
  "zoneCode": "13",
  "zoneTitle": "Центральный район--13",
  "contextId": "district-central-13",
  "editable": false,
  "selectable": true,
  "items": [
    {
      "iconUrl": "https://example.com/device.png",
      "title": "Самокаты: 156"
    },
    {
      "iconUrl": "https://example.com/repair.png",
      "title": "В ремонте: 23"
    }
  ]
}

Контракт Mobile ↔ Backend

Весь обмен данными построен на принципе «сервер знает лучше». Мобильный клиент:

  • отправляет текущий контекст и действие пользователя

  • получает готовую JSON-модель экрана с данными и возможными действиями

  • рендерит интерфейс согласно полученной модели

  • при любом взаимодействии снова обращается к серверу

Это позволяет:

  • мгновенно менять бизнес-логику без обновления мобильного приложения

  • А/Б-тестировать интерфейсы на уровне сервера

  • персонализировать UX в зависимости от роли и контекста пользователя

  • гарантировать актуальность данных — информация всегда берется из первоисточника

Пример полной JSON-модели экрана

{
  "viewName": "CITY_MANAGEMENT_OVERVIEW",
  "widgets": [
    {
      "type": "header",
      "title": "Управление городом",
      "subtitle": "Москва, Центральный округ"
    },
    {
      "type": "area_stat_cell",
      "iconUrl": "https://cdn.com",
      "title": "Активные устройства: 1,247",
      "subtitle": "На 15% больше вчерашнего",
      "action": {
        "viewName": "DEVICE_LIST",
        "actionType": "filter",
        "contextIds": {
          "status": "active",
          "region": "central"
        }
      }
    },
    {
      "type": "button_group",
      "buttons": [
        {
          "title": "Ребаланс",
          "enabled": true,
          "action": {
            "viewName": "REBALANCE_MANAGEMENT",
            "actionType": "navigate"
          }
        },
        {
          "title": "Массовое обслуживание",
          "enabled": false,
          "reason": "Недостаточно прав"
        }
      ]
    }
  ]
}

Кеширование и производительность

Как можно предположить, доработки по виджетам и ESRT приводят к частому опросу бэкенда, карточка самоката постоянно обновляется, чтобы данные были актуальными, однако это не является проблемой — все справочники кешируются, наполнение виджетов данными происходит быстро, все хендлеры используют общий контекст и сильно не грузят базу при проверках. WCMA-клиент также кеширует картинки в локальное хранилище на устройстве — не надо постоянно ходить в CDN. При полной отрисовке UI с бэкенда экраны строятся весьма быстро.

Результаты внедрения: от хаоса к порядку

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

Этапы внедрения и тестирование

Фаза 1. MVP-таблицы ESRT (2 месяца)

Начали с базовой реализации Entity Status Role Transitions для одного типа сущности — maintenance_session. Создали простейшие API для получения доступных переходов и валидации изменений статусов.

Фаза 2. Условные переводы (3 месяца)

Добавили систему хендлеров и условную логику. Перенесли старую логику проверок на новые рельсы. Постепенно расширили до региональных ограничений и временных рамок. Новые проверки добавляются очень просто и быстро.

Фаза 3. Динамические сценарии (4 месяца)

Полностью переработали формирование UI. Завели сначала уникальные виджеты, а потом перевели раздел «Управление городом 2.0» на action-based-архитектуру и динамическую генерацию экранов. Я бы сказал, что это самая сложная часть из представленных в статье. Пришлось вводить систему виджетов, понимать, как передавать контекст и actions между WCMA и бэкендом. Сложный старт geniric-виджетов: чтобы сверстать даже простой экран с бэкенда, надо сначала завести критическую массу стандартных виджетов, попросить дизайнеров использовать только их при рисовании экранов, потом в самом конце наполнить необходимый экран из виджетов, а также решить задачу разграничения доступов — один и тот же виджет на одном и том же экране может быть показан одной роли и может быть скрыт для другой.

Метрики успеха

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

Удобство для пользователей: по результатам опросов команд сервисных центров, 89% отметили, что стало проще понимать, какие действия доступны в текущей ситуации.

Мониторинг в production

Что умеем контролировать сейчас

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

Динамическое изменение бизнес-правил: новые ограничения на переходы внедряются за минуты через обновление конфигурации хендлеров без релиза приложения.

A/B-тестирование интерфейсов: можем показывать разным группам пользователей различные варианты экранов и измерять эффективность.

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

Планы развития

Система ESRT, widgets, tabs, views оказалась настолько удобной, что мы планируем:

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

  • расширить ее использование: перевести все существующие экраны на BDUI — хотим, чтобы WCMA не думало над тем, какие экраны какой роли показывать. Весь менеджмент ролей — только на бэкенде. Сами же экраны могут рисоваться как частично, так и полностью с бэкенда. В это время разработчики мобильного приложения сфокусируются на улучшении непосредственно клиентских фич: работа с картой или внешними девайсами (iot, инструменты диагностики).

  • При формировании UI через бэкенд можно строить графы достижимости экранов для конкретных ролей: например, так выглядит одна из версий «Управление городом 2.0» для координатора. CITY_MANAGMENT — это главный экран, стрелочками отмечены переходы, а в весах — действия, которые триггерят переход. Кончается сценарий назначением какого-то задания. По сути, такие же экраны получит пользователь, но уже в более красивом виде. Может быть полезно при анализе и настройке экранов через редактор.

Граф достижимости экранов
Граф достижимости экранов

Lessons learned

Не экономьте на архитектуре: потратить больше времени на проектирование системы в самом начале — значит сэкономить месяцы на рефакторинге потом.

Тестируйте на реальных данных: синтетические тесты не покажут всех edge case'ов, которые встречаются в production. Будьте готовы к доработкам.

Делайте систему наблюдаемой с первого дня: логирование и мониторинг — не «nice to have», а критически важная часть системы.

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

В результате мы получили не просто техническое решение, а платформу для быстрого внедрения новых бизнес-процессов. Система ESRT стала первым кирпичиком. Вместе с view и виджетами они образовали фундамент, на котором строятся все операционные процессы Whoosh — от простейших переводов статусов до сложных многошаговых сценариев обслуживания парка.

Если у вас есть вопросы по техническим деталям реализации или опыт решения похожих задач, буду рад обсудить в комментариях!

Над проектом работали: Илья Жариков(android), Эрдэм Цындуев(android), Дмитрий Сумбаев(java backend), Игорь Волынский(java backend), Никита Симакин(product owner), Денис Калита(java lead)

Комментарии (3)