Тема
ТЗ — CRM-коннекторы (cms/integration-crm)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Коннекторы CRM-систем (Битрикс24, amoCRM, RetailCRM) поверх cms/integrations-bus: отправляют лиды и заказы во внешнюю CRM событийно, из очереди, с настраиваемым маппингом полей и приёмом статусов обратно через вебхуки.
- Коннекторы Битрикс24, amoCRM, RetailCRM:
provides: crm-connector - Отправка лидов/заказов в CRM асинхронно из очереди с ретраями через
cms/integrations-bus - Настраиваемый маппинг полей (сайт → CRM) без правки кода, через Filament
- Приём статусов сделки/заказа обратно через
cms/webhooks-in - Дедупликация контактов по телефону/email — не создаёт дубль лида в CRM
- Журнал синхронизации с диагностикой ошибок маппинга
Зависимости и выключение
requires: cms/integrations-bus, cms/webhooks-in · provides: crm-connector
Поведение при выключении: лиды и заказы перестают отправляться во внешнюю CRM и остаются только в сайте — обработка заявок продолжается через встроенные инструменты студии (cms/leads, если подключён), без потери данных.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_integration_crm_field_mappings | id, crm_code, source_field, target_field | настраиваемый маппинг полей |
cms_integration_crm_sync_log | id, crm_code, entity_type, entity_id, external_id, status, attempts | журнал отправки лидов/заказов |
Индексы: status — PHP Enum; уникальный индекс на crm_code+entity_type+entity_id — одновременно быстрый поиск по сущности и гарантия идемпотентности при параллельной отправке одного лида дважды (см. «Крайние случаи»); cms_integration_crm_sync_log — журнал, append-only, BRIN по created_at.
ПДн-паспорт. В CRM уходят: ФИО, телефон, email, комментарий лида/заказа, UTM-метки — ровно те поля, что настроены в cms_integration_crm_field_mappings. Срок хранения cms_integration_crm_sync_log — 12 месяцев (ретеншн-джоба cms:integration-crm:prune-log, регистрируется через ScheduleRegistrar), payload лида/заказа в самой CRM модуль не хранит и не обязан «забывать» (данные субъекта в CRM — вне периметра сайта; хук ядра «забыть по запросу» удаляет запись в sync_log, а не данные в CRM).
Входные и выходные данные
Входы (всё прочее модуль отвергает — whitelist-принцип §11 стандарта):
| Вход | Источник | Канал | Проверка |
|---|---|---|---|
| Лид создан | событие LeadCreated (cms/core) | 1 — событие | факт от ядра, доп. валидации не требует |
| Заказ оплачен/сменил статус | событие OrderPaid/OrderStatusChanged (cms/commerce-orders) | 1 — событие | обрабатывается только если sync_orders=true |
Событие LeadStatusChanged (ядро) → lead, from, to | ядро | 1 — событие | триггер отправки смены статуса в CRM |
| Маппинг полей (форма Filament/API) | админ | API | FormRequest, whitelist полей источника/цели |
| Ручная переотправка (retry) | админ | API | permission integration-crm.manage, Idempotency-Key |
| Вебхук статуса сделки/заказа из CRM | внешняя CRM → cms/webhooks-in | 5 — очередь (приём) | подпись/токен коннектора верифицируется до постановки job; неверная подпись — 401, запись в sync-log не создаётся; проверка timestamp против replay (окно ядра cms/webhooks-in) |
Выходы:
| Выход | Получатель | Формат |
|---|---|---|
CrmLeadSynced / CrmSyncFailed / CrmStatusReceived | подписчики (канал 1) | событие, payload см. «События и обмен» |
| Лид/сделка в CRM | внешняя CRM (через коннектор) | payload по активному маппингу полей, только замапленные поля |
| Журнал синхронизации | админка | cms_integration_crm_sync_log, keyset-пагинация |
| Отчёт ошибок маппинга | админка | запись в sync-log со статусом mapping_error + детали |
Любое поле payload, не описанное в cms_integration_crm_field_mappings, в CRM не уходит; любое событие, кроме перечисленных, модуль не слушает (нет подписки — не скрытая интеграция).
Настройки (группа integration-crm)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
integration-crm.enabled_connectors | array | [] | нет | Активные коды CRM |
integration-crm.dedup_by | string | "phone" | нет | Поле дедупликации контактов (phone/email) |
integration-crm.sync_orders | bool | true | нет | Отправлять заказы, не только лиды |
integration-crm.retry_attempts | int | 3 | нет | Число ретраев отправки при ошибке |
integration-crm.batch_size | int | 20 | нет | Макс. число лидов в одном батч-запросе к CRM (там, где API это поддерживает) |
integration-crm.rate_limit_per_minute | int | 60 | нет | Лимит вызовов API на CRM — троттлинг очереди, защита от блокировки аккаунта |
integration-crm.max_pending_queue_size | int | 1000 | нет | Порог записей в очереди на CRM; превышение — алерт cms/health, отправка не останавливается тихо |
integration-crm.max_pending_age_hours | int | 24 | нет | Порог возраста неотправленной записи; превышение — алерт cms/health |
integration-crm.kill_switch | array | [] | нет | Коды CRM с аварийно приостановленной синхронизацией — без выключения модуля и без потери маппинга |
Секреты доступа к API CRM (токены, вебхук-URL) — только .env.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/integration-crm/mappings | admin (integration-crm.manage) | Настройка маппинга полей |
| PUT | /api/v1/admin/integration-crm/mappings | admin (integration-crm.manage) | Сохранение маппинга полей |
| GET | /api/v1/admin/integration-crm/sync-log | admin (integration-crm.view) | Журнал синхронизации с фильтрами |
| POST | /api/v1/admin/integration-crm/sync-log/{id}/retry | admin (integration-crm.manage) | Повторная отправка вручную |
| POST | /api/v1/admin/integration-crm/sync-log/retry-bulk | admin (integration-crm.manage) | Массовая переотправка группы failed-записей (список id) |
Журнал синхронизации — keyset-пагинация, не OFFSET. Оба retry-эндпоинта — мутация с внешним эффектом (повторная отправка в CRM) → обязателен заголовок Idempotency-Key (§7 стандарта, конверт ошибок — код mapping_error/rate_limited/connector_disabled в поле code).
Компоненты
Filament: форма маппинга полей, журнал синхронизации с ретраем. Команды: cms:integration-crm:retry-failed --json, cms:integration-crm:doctor --json.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
CrmLeadSynced | лид успешно отправлен в CRM | crm_code, lead_id, external_id |
CrmSyncFailed | отправка завершилась ошибкой после всех ретраев | crm_code, entity_type, entity_id, error |
CrmStatusReceived | получен статус сделки/заказа обратно из CRM | crm_code, external_id, status |
Слушает: WebhookReceived из cms/webhooks-in для статусов из CRM; LeadStatusChanged (ядро) — триггер исходящей отправки смены статуса в CRM (с анти-эхо по origin, см. «Крайние случаи»). Реализует provides: crm-connector. Весь обмен с CRM — через cms/integrations-bus.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
LeadCreated (cms/core) | 1 — событие | core → integration-crm | лид ставится в очередь integration-crm на отправку |
OrderPaid/OrderStatusChanged (cms/commerce-orders) | 1 — событие | commerce-orders → integration-crm | заказ ставится в очередь, если sync_orders=true |
cms/webhooks-in | 5 — очередь (приём) | CRM → webhooks-in → integration-crm | подписанный вебхук статуса кладёт job разбора; см. «Входные и выходные данные» |
cms/integrations-bus, provides: crm-connector | 3 — provides-контракт | integration-crm → шина | реализация способности «отправить лид/сделку», резолв по crm_code |
cms/health | 1 — событие CrmSyncFailed | integration-crm → health | алерт при исчерпании ретраев/систематических ошибках/превышении max_pending_* |
cms/leads (если подключён) | — (связи нет) | — | заявки на сайте обрабатываются независимо от статуса CRM-синхронизации |
Фоновая работа
Отправка лидов/заказов — именованная очередь integration-crm, асинхронно, батчами по batch_size там, где API коннектора поддерживает батч-отправку (иначе по одной с троттлингом по rate_limit_per_minute), с ретраями (backoff) через cms/integrations-bus; circuit breaker на CRM при систематических ошибках (открывается — коннектор помечается degraded, алерт разработчику, другие коннекторы не затронуты). Слушатели событий тонкие — валидируют факт и кладут job, вся логика маппинга и дедупликации — в сервисе. Внешние HTTP-вызовы к CRM выполняются только из очереди — никогда синхронно в потоке ответа.
Ранбук (мини, §15 стандарта):
| Симптом | Что проверить | Команда |
|---|---|---|
Очередь integration-crm растёт, CRM не отвечает | health-чек коннектора, max_pending_queue_size/max_pending_age_hours в алертах cms/health | cms:integration-crm:doctor --json; при затяжном сбое — kill_switch на код CRM |
Массовая CrmSyncFailed с кодом mapping_error | воронка/поля в CRM изменились — сверить маппинг с текущей схемой CRM | Filament: форма маппинга полей → пересохранить; cms:integration-crm:retry-failed --json после правки |
Производительность и кеш
Ожидаемые объёмы (оценочно, типовой сайт студии): лиды — от десятков до ~2 000 в сутки, заказы (при sync_orders=true) — сопоставимо с суточным объёмом заказов сайта. Горячие пути: постановка лида/заказа в очередь (слушатель тонкий, ≤2 запроса: чтение кеша маппинга + insert job/лога) и приём вебхука статуса (подпись верифицирует cms/webhooks-in до контроллера модуля, сам обработчик ≤3 запроса: найти запись по crm_code+external_id, обновить статус, применить анти-эхо по origin). Индексы — см. «Модель данных» (crm_code+entity_type+entity_id, BRIN по created_at). Тег integration-crm:mappings — кеш маппинга полей на CRM, инвалидируется при сохранении настроек маппинга в Filament (0 запросов на горячем пути отправки). Внешние вызовы к CRM — только из очереди, никогда на горячем пути публичной страницы или синхронного ответа API.
Безопасность
- Приём статуса из вебхука идемпотентен по
event_id— повторная доставка не создаёт дублирующий переход статуса. - Дедупликация контактов по телефону/email не создаёт повторный лид при повторной отправке (поиск существующего контакта — до создания нового, см. «Крайние случаи»).
- Вебхуки: подпись/токен каждого коннектора (amoCRM —
X-Signature, Битрикс24 — токен приложения, RetailCRM — API-key) верифицируется на уровнеcms/webhooks-inдо постановки job; неверная подпись → 401, событие безопасности — вcms/audit/cms/attack-monitor. Защита от replay — проверкаtimestampвебхука против окна ядра (по умолчанию из настроекcms/webhooks-in). - Секреты доступа к API CRM — только
.env, в БД/логах/UI не хранятся и не выводятся. - В логи и sync-log не попадают полные ПДн сверх необходимого — телефон/email логируются маскированно, полный payload доступен только через сам запрос к CRM.
Матрица ролей (permissions из манифеста):
| Permission | admin | менеджер | редактор | studio |
|---|---|---|---|---|
integration-crm.view (журнал, статусы, расход квоты API) | ✅ | ✅ | ❌ | ✅ |
integration-crm.manage (маппинг полей, retry/bulk-retry, kill-switch коннектора) | ✅ | ❌ | ❌ | ✅ |
Опасные действия (массовая переотправка, kill-switch) — только повышенные роли (admin/studio), с подтверждением в UI (см. «UX-требования»).
UX-требования
Админ:
- статус синхронизации виден на карточке лида/заказа (бейдж: не отправлен / в очереди / отправлен / ошибка);
- журнал обменов с фильтрами по CRM/статусу/дате, пустое состояние — «Синхронизация ещё не запускалась» со ссылкой на настройку маппинга;
- ошибки — на человеческом языке, не техническим кодом: «amoCRM не отвечает, лид поставлен в очередь на отправку», «Битрикс24: превышен лимит запросов, повтор через N минут», «Поле "Источник" не найдено в текущей воронке amoCRM — проверьте маппинг» (не «Error 500» и не сырой стек-трейс);
- массовые действия: чекбоксы в журнале для группового retry failed-записей, с подтверждением количества («Переотправить 42 записи?»);
- подтверждение необратимых/рискованных операций: включение kill-switch при накопленной очереди — «N лидов не будут отправляться в CRM, продолжить?».
Посетитель: отправка формы лида никогда не ждёт ответа CRM (весь обмен асинхронный из очереди) — сбой CRM не виден на сайте и не блокирует приём заявки.
Крайние случаи и типовые баги
- Дубли лидов → перед постановкой в очередь на создание сервис ищет существующий контакт в CRM по
dedup_by(телефон/email, с fallback на второе поле, если первое пусто) до создания новой сущности; найден — сделка создаётся на существующий контакт, не создаётся новый; не найден — создаётся новый контакт. Постфактум-сверка не применяется, поиск — обязательный шаг перед созданием. - Двусторонний маппинг статусов создаёт петлю событий (сайт меняет статус → уходит в CRM → CRM шлёт вебхук с тем же статусом → сайт снова пытается отправить) → каждая запись
cms_integration_crm_sync_logнесётorigin(site/crm); исходящий job создаётся только при изменении статуса сorigin=site; изменение, пришедшее сorigin=crmи совпадающее с уже сохранённым статусом, не порождает исходящую отправку (анти-эхо). - Rate-limit провайдера (amoCRM/Битрикс24 жёстко лимитируют запросы) → батчинг отправки (
batch_size) вместо отправки по одному там, где API это поддерживает; троттлинг очереди поrate_limit_per_minute, а не одновременный залп джобов. - CRM недоступна длительное время → очередь копится;
max_pending_queue_sizeиmax_pending_age_hours— явные пороги алерта вcms/health, не безлимитный рост; данные не теряются и не отбрасываются тихо, решение (ждать/kill-switch) — за админом. - Вебхук статуса пришёл на несуществующую/уже удалённую сделку → запись в лог со статусом
unknown_deal+ алерт при частоте выше нормы, ответ 200 (подтверждение приёма вебхука по контракту провайдера), не 500. - Конфликт маппинга полей — CRM вернула поле, которого нет в текущей настройке (изменилась схема воронки) → запись в журнал ошибок со статусом
mapping_errorи деталями расхождения, не блокирует синхронизацию остальных лидов/заказов. - Смена схемы ответа API CRM (breaking change у провайдера) → circuit breaker переводит коннектор в degraded, алерт разработчику через
cms/health, другие подключённые CRM не затронуты. - Параллельная отправка одного лида дважды (гонка при повторной обработке очереди) → уникальный индекс
crm_code+entity_type+entity_idвsync_log+WithoutOverlappingна job — идемпотентность, не дубль сделки в CRM. - Лид без телефона и email (нечем дедуплицировать) → создаётся новый контакт без попытки поиска, событие/запись помечается
dedup_skipped— видно в журнале, не тихо.
Донорский код
Донор: — (новая разработка). Legacy-импорт (§16 стандарта): при переезде клиента со старой платформы, где уже велась синхронизация с той же CRM, cms:integration-crm:import- legacy --source=<профиль> переносит связку external_id (сущность сайта ↔ id в CRM) в cms_integration_crm_sync_log, чтобы после запуска модуль не создавал в CRM дублирующие контакты/сделки для уже синхронизированных лидов; сами лиды/заказы остаются в своих модулях (cms/leads, cms/commerce-orders) — импортируется только связка идентификаторов. Идемпотентно (повторный прогон обновляет связку, не дублирует), --dry-run с отчётом расхождений.
Тесты и приёмка
- [ ] Мок API CRM: тест отправки лида, получения статуса, обработки ошибки с ретраем
- [ ] Дедупликация по телефону/email не создаёт повторный лид при повторной отправке; тест явно проверяет, что поиск контакта происходит до создания нового
- [ ] Маппинг полей применяется без изменения кода — только через Filament-настройку
- [ ] Обработка статуса из вебхука идемпотентна по
event_id - [ ] При выключении модуля заказы/лиды не теряются, обработка продолжается на сайте
- [ ] Права
integration-crm.manageразграничивают просмотр журнала и настройку маппинга - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
integration_crm_test,migrate:freshзапрещён - [ ] Тест деградации: недоступный коннектор → circuit breaker открывается,
200 + meta.degradedна связанных admin-эндпоинтах (не 5xx), алерт вcms/health - [ ] Тест на дубли вебхуков: повторная доставка одного
event_idне создаёт второй переход статуса - [ ] Тест анти-эхо: статус, пришедший с
origin=crm, не порождает исходящую отправку того же статуса обратно в CRM - [ ] Тест идемпотентности параллельной отправки: два одновременных job на один лид → одна запись в CRM, не дубль
- [ ] Тест лимита очереди: превышение
max_pending_queue_size/max_pending_age_hours→ алертcms/health, отправка не блокируется молча - [ ] Тест kill-switch: код CRM в
integration-crm.kill_switch→ исходящие job не создаются, входящие вебхуки по-прежнему принимаются и логируются - [ ] Тест на
LeadStatusChanged(ядро): смена статуса лида сorigin=siteставит job отправки статуса в CRM; смена, пришедшая сorigin=crm, повторной отправки не порождает