01 Системные B2B-проекты и экосистемы
B2B медтех · enterprise 2024 — н.в. Архитектор и разработчик системы

Платформа учёта дилерских проектов

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 роли, каждая видит своё

  1. dealer — рядовой сотрудник компании-дилера. Видит только свои брони. Может создавать, переводить в следующий статус, запросить продление.
  2. dealer_admin — главный по компании-дилеру. Видит все брони компании, может переносить их между сотрудниками, управляет сотрудниками (CRUD), не видит внутренних заметок менеджеров.
  3. manager — региональный менеджер дистрибьютора. Видит брони своих регионов. В Москве — только те, что назначены персонально. Одобряет заявки, ведёт переписку, управляет продлениями.
  4. 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.

Как со мной связаться

Расскажите задачу —
разберём, как её решать.

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

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