Платформа учёта дилерских проектов
B2B-платформа, в которой дилеры регистрируют проекты до реализации, а головной офис выдаёт аппрув. Снимает конфликты между дилерами и делает дистрибьюторскую сеть управляемой.
mediace.ru ↗Коротко
- Задача — дистрибьютор должен видеть сделки дилеров до их реализации. Без этого нет контроля поставок, маржи и конфликтов по клиентам.
- Подход — workflow-платформа с 4 ролями (admin / manager / dealer / dealer_admin), статусной машиной и интеграцией с Контур.Фокус по ИНН.
- Устойчивость — 164 автотеста, 5 раундов аудита безопасности, 8 уровней rate limiting, защита от race conditions на уровне транзакций.
- Интеграции — Контур.Фокус для реквизитов по ИНН (кэш 7 дней), 11 типов email-уведомлений, cron-задачи авто-отмены и архивирования.
Цифры, которые можно сверить
- API endpoints
- 55
- Автотестов
- 164
- Раундов аудита безопасности
- 5
- Ролей доступа
- 4
- Типов email-уведомлений
- 11
- Docker-сервисов в продакшне
- 4
Что за задача
У дистрибьютора — сеть дилеров. Каждый дилер самостоятельно ведёт свои продажи: ищет клиник, договаривается, делает поставки. Снаружи это выглядит красиво. Внутри — хаос:
- Два дилера независимо работают с одной клиникой, обе стороны инвестируют в переговоры, побеждает тот, кто быстрее даст скидку. Маржа распределяется между дистрибьютором и дилером не в пользу дистрибьютора.
- Головной офис не знает, сколько проектов в активной стадии в каждом регионе, и не может планировать поставки.
- По отдельно взятому ИНН клиники может быть 2–3 филиала в разных регионах — бронировать их должны разные менеджеры, а не «кто первый успел».
- Срок проекта уходит, дилер не закрывает сделку, бронь виси́т в системе месяцами — никто не помнит, что с ней.
Нужна была платформа, которая делает воронку продаж прозрачной для всех ролей: дилер видит свои проекты, дилерский директор — все проекты своей компании, региональный менеджер — проекты своего региона, админ — всё и управление.
Что построили
Production-система mediace.ru. Работает с 2024 года, за это время пережила 5 аудитов безопасности и непрерывно ведётся.
Набор сайтов и рекламных кампаний превращает дистрибьютора в маркетинговую машину. А вот платформа учёта сделок превращает его в управляемый бизнес.
Архитектура — классическая B2B SPA + REST API:
- Backend: Node.js 20, Express 4.22, PostgreSQL 15, Prisma 6.19. 55 endpoints, 13 моделей БД, 4 enum.
- Frontend: Vanilla JS на ES6-модулях, nginx как SPA-router. Без React — потому что задача не требует, а поддержка проще.
- Deploy: Docker Compose, 4 сервиса (postgres, backend, frontend, certbot для SSL). Graceful shutdown, health checks.
- Auth: JWT (HS256, 4 часа жизни), bcrypt для паролей, rate limiting 5 попыток логина в 15 минут.
- Security: Helmet, CORS, rate limiting на 8 разных уровнях, express-validator на 23 ручках, серверная санитизация HTML.
4 роли, каждая видит своё
- dealer — рядовой сотрудник компании-дилера. Видит только свои брони. Может создавать, переводить в следующий статус, запросить продление.
- dealer_admin — главный по компании-дилеру. Видит все брони компании, может переносить их между сотрудниками, управляет сотрудниками (CRUD), не видит внутренних заметок менеджеров.
- manager — региональный менеджер дистрибьютора. Видит брони своих регионов. В Москве — только те, что назначены персонально. Одобряет заявки, ведёт переписку, управляет продлениями.
- admin — головной офис. Видит всё: пользователи, компании, брони, регионы, закреплённые ИНН, история изменений, экспорт статистики в CSV.
Москва — особый регион. Менеджеров там несколько, бронь адресуется конкретному через assignedManagerId. Менеджер Москвы видит только свои назначенные брони (а не все московские подряд), и может передать бронь коллеге с обязательной причиной, которая записывается в историю.
Статусная машина брони
pending ──approve──→ in_progress ↔ awaiting_delivery ──complete──→ completed
│ │ │
│ reject │ cancel │ cancel
↓ ↓ ↓
rejected cancelled cancelled
- При одобрении менеджером:
pending → in_progress, срок = сейчас + 90 дней. - Между
in_progressиawaiting_deliveryдилер ходит сам (типично: пока готовит документы —in_progress, когда ждёт поставки —awaiting_delivery). - Продление — отдельное поле
extensionStatus, не ломает статусную машину. Дилер запрашивает продление → бронь замораживается на +7 дней → менеджер одобряет (+90 дней) или отказывает (возврат к прежнему сроку, возможно авто-отмена). - Финальные статусы (
completed,cancelled,rejected) нельзя изменить. Бейдж без дропдауна. - Cron каждый час отменяет истёкшие брони и
pending-заявки старше 24 часов. Отдельный cron раз в сутки — email-предупреждения о скором истечении. - Архивация — брони старше 180 дней в финальных статусах уходят в архив, таблица основных броней не разрастается.
Как решили проблему «один ИНН — разные филиалы»
Клиника в Москве может иметь 5 филиалов, каждый со своим адресом и своим договором. Бронировать их могут разные дилеры — это нормально. А вот два дилера на один и тот же филиал — это конфликт, который надо ловить до реализации.
checkDuplicate(inn, region, city, street, building)блокирует только совпадение ИНН и точного адреса. Проверка повторно вызывается внутри транзакции создания — защита от race conditions, 409 при конкурентном создании.- Глобальное закрепление ИНН для особых случаев: если все активные брони по ИНН уже от одной компании — админ может заблокировать ИНН за ней целиком (никто другой не сможет бронировать). Снятие закрепления — с предварительным просмотром последствий (какие брони истекут сразу) и автоматическими уведомлениями региональным менеджерам.
Интеграция с Контур.Фокус
При создании брони дилер вводит ИНН — система обращается к API Контур.Фокус и возвращает карточку организации: официальное название, ОГРН, юридический адрес, статус, медицинские лицензии, список филиалов с доступностью (какие уже кем-то забронированы).
- Кэш 7 дней — экономит бюджет на API, ускоряет повторные проверки.
- Rate limit 60 запросов / 15 минут на эндпоинт Контура.
- Graceful degradation — если API недоступен или ключ не задан, фронтенд переключается в режим ручного ввода адреса. Бронь создаётся, просто без «зелёной галочки верифицирован».
- Маппинг регионов — 85 субъектов РФ нормализованы в 8 федеральных округов системы. Контур возвращает субъект, система кладёт в соответствующий регион.
Cron-задачи и email
Платформа активно общается с пользователями по почте: 11 типов писем через Nodemailer SMTP (STARTTLS). Создание брони, одобрение, отклонение, смена статуса, запрос на продление, истечение, передача менеджеру — на каждое событие свой шаблон.
Cron работает через три независимых планировщика:
- Каждый час — проверка истёкших броней (
expiryDate < now, не закреплены, не в процессе запроса на продление) → авто-отмена. Плюс отменаpending-заявок старше 24 часов. - Раз в сутки — email-предупреждения о близящемся истечении: за 7 и за 1 день до.
- Раз в сутки — архивирование финальных броней старше 180 дней.
Каждая cron-задача идемпотентна и использует retry до 3 попыток. Если упала база — ничего не теряется, на следующем часе обработается заново.
Защита от гонок состояний
Для workflow-системы это важнее любого фичеринга. Типовая ситуация: дилер открыл форму, параллельно админ изменил статус брони, дилер нажал «сохранить». Без защиты второй запрос ломает состояние.
Система использует два приёма:
- TOCTOU-защита через preconditions. Запросы смены статуса и переноса брони включают условие на текущий статус/ID владельца в
updateMany. Если условие не выполнилось — 409 Conflict, клиент показывает «состояние изменилось, обновите». - Транзакции для всех многошаговых операций: создание брони + проверка дубликата, смена менеджера с записью в историю, удаление компании с каскадами.
Что показательно как инженерный кейс
- Бизнес-логика превосходит технологию. Самые интересные решения в этом кейсе — не в стеке, а в правилах: 5 сценариев сравнения адресов при проверке дубликатов, закрепление ИНН с preview последствий, передача брони с обязательной причиной в истории.
- Vanilla JS вместо React не отрицание, а решение. Задача — ровно та, с которой справляется ES6-модульный фронт. Меньше зависимостей, быстрее онбординг.
- Cron и email — полноценные узлы системы, а не «приложения сверху». Если они плохо работают, платформа ломается.
- 164 автотеста и 5 раундов аудита — стоимость того, что в любой момент можно безопасно релизить.
Результаты для бизнеса
- Прозрачная воронка продаж. Головной офис видит, сколько проектов в каждом регионе, в каком статусе.
- Меньше конфликтов между дилерами. Дубликаты ловятся на этапе создания, глобальные закрепления ИНН решают спорные зоны.
- Управляемая сеть. Срок проекта жёстко ограничен, просрочки автоматически убираются из активной воронки.
- Прозрачные эскалации. Передачи броней между менеджерами Москвы с причинами — записываются в историю, снимают субъективные решения.
Что из этого кейса можно забрать себе
- Любая партнёрская сеть (дилеры, франчайзи, реселлеры, агенты) приходит к одним и тем же проблемам: дубликаты, перехваты, прозрачность, сроки.
- Контур.Фокус или подобный сервис снимают с процесса половину ручной работы по верификации юрлиц, но требуют грамотной обработки случаев, когда API недоступен.
- Cron + email + статусная машина — это минимальный джентльменский набор любой внутренней системы с workflow.
- Rate limiting на 8 уровнях — не паранойя. Единственная защита от кейса «сотрудник случайно запустил скрипт в цикле».
Если у вас есть партнёрская сеть, которую нужно сделать прозрачной, или внутренний workflow, который пока живёт в Excel и переписке, — напишите мне в Telegram.