Тема
ТЗ — ERP/учётные системы (cms/erp)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Обмен с ERP-системами помимо 1С (МойСклад и др.) через адаптеры поверх шины интеграций: номенклатура/цены/остатки в сайт, заказы/отгрузки обратно в ERP.
- Адаптеры под конкретные ERP (МойСклад и др.):
provides: erp-connector - Импорт номенклатуры/цен/остатков через
cms/import, общий маппинг сcms/integration-1cпри использовании обеих интеграций одновременно - Выгрузка заказов и отгрузок из сайта в ERP
- Расписание автообмена и ручной запуск из Filament
- Журнал сессий обмена с диагностикой ошибок адаптера
Зависимости и выключение
requires: cms/integrations-bus · suggests: cms/integration-1c (общие маппинги) · provides: erp-connector
Поведение при выключении: номенклатура/цены/остатки перестают обновляться из ERP, заказы накапливаются в очереди выгрузки — сайт продолжает работать на последнем импортированном состоянии.
Стоимость внешних API: большинство ERP (1С-семейство, МойСклад) не тарифицируют вызовы API — не применимо. Исключение — отдельные облачные ERP/агрегаторы с лимитом запросов в тарифе: если адаптер работает с такой ERP, он обязан отдавать остаток квоты через свой health-чек и деградировать (не падать) при исчерпании — фиксируется в docs/module.md конкретного адаптера, не в этом файле.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_erp_sessions | id, provider_code, direction, status, started_at, finished_at, error | сессия обмена с ERP |
cms_erp_mappings | id, provider_code, entity_type, external_id, internal_id | маппинг номенклатуры/складов на модели ядра |
cms_erp_conflicts | id, session_id, provider_code, entity_type, field, erp_value (json), site_value (json), resolution, resolved_at | журнал конфликтов «изменено с обеих сторон» и их разрешения (по аналогии с cms_integration_1c_conflicts) |
Индексы: direction/status/resolution — PHP Enum; уникальный индекс на provider_code+entity_type+external_id в mappings (тот же индекс защищает upsert импорта от дублей); cms_erp_sessions/cms_erp_conflicts — журналы, append-only, BRIN по started_at/created_at; FK session_id в conflicts — constrained() + index().
Карта сущностей и направление синхронизации. Generic-коннектор (provides: erp-connector) не подразумевает «синхронизируется всё одинаково» — направление задаётся per сущность:
| Сущность | Синхронизируется | Направление по умолчанию | Настройка |
|---|---|---|---|
| Номенклатура (товары) | да | ERP → сайт | erp.sync_direction.catalog |
| Остатки | да | ERP → сайт | erp.sync_direction.stock |
| Цены | да | ERP → сайт | erp.sync_direction.pricing |
| Заказы / отгрузки | да | сайт → ERP | erp.sync_direction.orders |
| Статусы заказов | опционально | ERP → сайт | erp.sync_direction.order_status |
| Контрагенты (покупатели) | опционально | сайт → ERP | erp.sync_direction.customers |
Конкретный адаптер декларирует, какие направления он реально поддерживает (не все ERP отдают статусы обратно) — недекларированное направление недоступно в настройке, а не молча игнорируется при попытке включить.
ПДн-паспорт. Собственные таблицы модуля ПДн не хранят (маппинги/сессии — технические id). При выгрузке заказов/контрагентов в ERP (направление «сайт → ERP») передаются ФИО, адрес, телефон покупателя — законная цель (исполнение заказа/бухучёт), поля читаются из cms/commerce-orders на лету, не дублируются в таблицах модуля и не попадают в payload событий (ErpOrderExported несёт только order_id). «Забыть по запросу» — у владельца данных (cms/commerce-orders), модуль лишь единожды передал их в момент выгрузки.
Входные и выходные данные
Принцип whitelist: всё, что не перечислено ниже как вход, модуль обязан отвергать — неизвестная структура ответа ERP, посторонний адаптер без зарегистрированного provider_code, устаревшая версия схемы — понятная ошибка в журнале, не тихий проброс дальше по конвейеру импорта.
Входы
Здесь входящий канал — не пассивная точка приёма файлов (как у CommerceML), а активный исходящий HTTP-вызов адаптера к API ERP: сайт сам инициирует запрос, «вход» — это ответ ERP, который нужно аутентифицировать и провалидировать по схеме, а не входящее соединение.
| Источник | Канал | Что приходит | Проверка |
|---|---|---|---|
| Ответ ERP API на вызов адаптера | исходящий HTTP-вызов (инициирует сам erp) | номенклатура/цены/остатки/статусы (по декларированным направлениям) | ключ API адаптера из .env, схема ответа валидируется адаптером перед импортом |
| Ручной запуск обмена | Filament / cms:erp:run | команда «начать сессию» | erp.manage, кулдаун manual_run_cooldown_minutes |
| Расписание | cron (schedule_cron) | автозапуск сессии | внутренний, входа извне нет |
| Факт заказа/отгрузки | событие OrderPaid/смена статуса (канал 1) от cms/commerce-orders | сигнал «есть что выгружать в ERP» | внутренний, не внешний вход |
Выходы
| Получатель | Канал | Что отдаётся | Формат |
|---|---|---|---|
| ERP API | исходящий HTTP-вызов адаптера | заказы/отгрузки (направление «сайт → ERP») | JSON/XML — по протоколу конкретной ERP, определяет адаптер |
| Подписчики ядра/модулей | событие (канал 1) | ErpImportCompleted, ErpOrderExported, ErpConflictDetected, ErpExchangeFailed | см. «События и обмен» |
| Другие модули | provides-контракт erp-connector (канал 3) | реализация абстракции «получить данные ERP» | интерфейс cms/core-contracts |
| Админ | Filament / /api/v1/admin/erp/* | журнал сессий, маппинги, конфликты | JSON, keyset-пагинация |
| Разработчик/health | cms:erp:doctor --json | доступность API ERP, валидность маппингов | JSON |
Настройки (группа erp)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
erp.enabled_provider | string | null | нет | Код активного адаптера ERP (осознанное ограничение — один активный адаптер, см. «Крайние случаи») |
erp.sync_paused | bool | false | нет | Kill-switch: приостановить обмен без выключения модуля/смены enabled_provider |
erp.schedule_cron | string | "0 */3 * * *" | нет | Расписание автообмена |
erp.import_batch_size | int | 500 | нет | Размер батча импорта номенклатуры/остатков |
erp.manual_run_cooldown_minutes | int | 5 | нет | Минимальный интервал между ручными запусками |
erp.sync_direction | json | см. таблицу выше | нет | Направление синхронизации per сущность (in/out/off) |
erp.unavailable_alert_threshold_hours | int | 6 | нет | Порог простоя ERP API, после которого — алерт (не сразу при первом таймауте) |
Секреты доступа к API ERP — только .env.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/erp/sessions | admin (erp.view) | Журнал сессий обмена |
| POST | /api/v1/admin/erp/run | admin (erp.manage) | Ручной запуск обмена |
| GET | /api/v1/admin/erp/mappings | admin (erp.view) | Маппинги номенклатуры/складов |
| GET | /api/v1/admin/erp/conflicts | admin (erp.view) | Журнал конфликтов «изменено с обеих сторон» |
Журнал сессий, маппинги и конфликты — keyset-пагинация, не OFFSET.
Компоненты
Filament: журнал сессий обмена, таблица маппингов, журнал конфликтов, переключатель sync_paused рядом с выбором enabled_provider (два разных действия — см. «Настройки»). Команды: cms:erp:run --json, cms:erp:doctor --json.
Демо-контент и фронтенд-бюджет — не применимо. Модуль не регистрирует блоки/виджеты и ничего не рендерит на публичном сайте (чисто админ-инструмент обмена): сидеров demo-данных для галереи блоков не требуется, JS/CSS-бюджет и a11y-требования к формам ограничиваются стандартной админкой Filament, отдельного фронтенд-кода у модуля нет.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ErpImportCompleted | импорт номенклатуры/остатков/цен завершён | provider_code, session_id, entities_count |
ErpOrderExported | заказ/отгрузка выгружены в ERP | provider_code, order_id, session_id |
ErpConflictDetected | обнаружено расхождение «изменено с обеих сторон» | provider_code, session_id, entity_type, field, resolution |
ErpExchangeFailed | сессия обмена завершилась ошибкой | provider_code, session_id, direction, error |
Реализует provides: erp-connector. Импорт номенклатуры — через cms/import; весь обмен с ERP-адаптерами — через cms/integrations-bus.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/import | 5 — очередь | erp → cms/import | батч номенклатуры/остатков/цен передаётся генерик-импортёру пакетами import_batch_size |
cms/integrations-bus | 5 — очередь / внешний | erp → integrations-bus | все HTTP-вызовы к API конкретной ERP маршрутизируются через шину интеграций |
provides: erp-connector | 3 — сервисный контракт | consumer → erp | любой модуль, которому нужна способность «получить данные ERP», резолвит контракт по интерфейсу, не завязываясь на конкретный адаптер |
cms/integration-1c | suggests, общий пул маппингов | erp ↔ integration-1c | при совместном включении — общий external_id/internal_id пространство, риск конфликта владения (см. «Крайние случаи», ⚠️) |
cms/commerce-orders | 1 — событие (слушает OrderPaid/смену статуса) | commerce-orders → erp | сигнал «заказ/отгрузка готовы к выгрузке», сами данные читаются через сервис владельца |
Фоновая работа
Импорт/выгрузка — именованная очередь erp, батчами (import_batch_size) через Bus::batch с прогрессом, видимым в Filament. Расписание — schedule_cron через ScheduleRegistrar ядра; ручной запуск — из Filament, с учётом manual_run_cooldown_minutes. Ошибка одного адаптера не влияет на другие подключённые интеграции — изоляция ошибок на уровне адаптера (сбой МойСклад не трогает параллельно работающий cms/integration-1c).
Мини-ранбук (типовые инциденты):
| Симптом | Что проверить | Команда |
|---|---|---|
Обмен «завис» (сессия в status=running дольше обычного) | Отставание очереди erp, живость воркера | cms:erp:doctor --json, php artisan queue:failed |
| Данные адаптера расходятся с сайтом или с 1С | Журнал конфликтов, erp.sync_direction для спорной сущности | GET /api/v1/admin/erp/conflicts, cms:erp:run --dry-run |
| ERP не отвечает дольше обычного | Порог unavailable_alert_threshold_hours, статус sync_paused | cms:erp:doctor --json, GET /api/v1/system/health |
Производительность и кеш
- Ожидаемые объёмы: номенклатура ERP — оценочно десятки–сотни тысяч SKU (тот же порядок, что у
cms/integration-1c); для типичных инсталляций МойСклад — обычно тысячи–десятки тысяч позиций. - Горячие пути: обращения к ERP API — исходящие вызовы адаптера, не публичные страницы; бюджет применяется к самому обмену, не к HTTP-ответу пользователю.
- Бюджет батчевого импорта: без N+1 — upsert по уникальному индексу
provider_code+entity_type+external_id, не построчныйSELECT;Bus::batchчанками поimport_batch_size(дефолт 500). - Индексы сошлись с «Моделью данных»: уникальный на
mappings, BRIN на журналах. - Явное правило: весь обмен с ERP — только из очереди; ручной запуск лишь ставит job (HTTP-ответ админке — мгновенный «сессия поставлена в очередь»), синхронных вызовов к ERP API в ответ на запрос пользователя нет.
Безопасность
- Батчевый импорт идемпотентен — повторный импорт того же снапшота не дублирует записи.
- Обмен выполняется по расписанию и вручную, оба пути логируются в
cms_erp_sessionsдля аудита. - Секреты доступа к API ERP — только
.env, per-адаптер (не общий ключ на все ERP). - Ответ ERP API валидируется адаптером по ожидаемой схеме перед импортом — несовместимый ответ не проходит дальше парсинга (см. «Крайние случаи»).
- Права:
erp.view,erp.manage.
Матрица ролей:
| Роль | erp.view (журнал/маппинги/конфликты) | erp.manage (ручной запуск, sync_paused, enabled_provider) | Настройки/секреты API |
|---|---|---|---|
| studio | ✅ | ✅ | ✅ (включая .env) |
| админ клиента | ✅ | ✅ | ✅ кроме .env-секретов |
| менеджер | ✅ | — | — |
| редактор | — | — | — |
UX-требования
- Админ видит статус синхронизации: время и итог последнего успешного обмена по активному провайдеру, прогресс текущего батча.
- Журнал обменов со скачиваемым построчным отчётом ошибок адаптера (не только «N ошибок»).
- Ошибки на человеческом языке: «ERP МойСклад не ответила за 30 секунд» вместо «cURL error 28», «формат ответа ERP не распознан» вместо стектрейса парсера.
- Подтверждение необратимых операций: принудительный запуск при незавершённой предыдущей сессии — по умолчанию отклоняется; смена
enabled_providerпри уже настроенных маппингах — предупреждение «старые маппинги останутся, но перестанут обновляться».
Крайние случаи и типовые баги
- ⚠️ Противоречие: несколько источников для одной сущности. При одновременном включении
cms/erpиcms/integration-1cс пересекающимся набором сущностей (оба синхронизируют, например, остатки) нет единого арбитра:integration-1cфиксирует себя мастером черезmaster_catalog_source: '1c', а уcms/erpаналогичной настройки нет. Разрешение: явная карта владенияowner_moduleper сущность/поле должна жить на уровне владельца данных (cms/commerce-catalog/cms/commerce-pricing/cms/commerce-stock), а не быть настройкой внутри одного из модулей-источников; до появления этого механизма в ядре — эксплуатационное правило: сущности, уже покрытыеcms/integration-1c, должны иметьerp.sync_direction.<entity> = off, совместная синхронизация одной сущности из двух источников не поддерживается. - Конфликт «изменено с обеих сторон» (товар отредактирован и в ERP, и в админке сайта до следующего обмена) → правило приоритета по умолчанию: ERP побеждает по цене/остаткам (это её домен), сайт побеждает по SEO-полям и контенту карточки (это не зона ERP); каждое расхождение фиксируется в
cms_erp_conflictsс обоими значениями и решением — молчаливая перезапись запрещена. - Несколько ERP-адаптеров одновременно (МойСклад + ещё один) →
erp.enabled_provider— осознанное ограничение (один активный адаптер на инсталляцию, упрощает разрешение конфликтов из пункта выше). Попытка включить второй адаптер при уже установленномenabled_provider→ отклоняется настройкой с понятным сообщением «сначала отключите текущий адаптер»; расширение до параллельных адаптеров потребовало бы также распространить карту владения на пару адаптеров — вне объёма текущего ТЗ. - Таймаут/недоступность ERP API → импорт откладывается (сессия помечается
error, следующая попытка — по расписанию), сайт работает на последнем импортированном состоянии; алерт уходит не на первый же таймаут, а послеunavailable_alert_threshold_hoursпростоя. - Смена схемы ответа ERP API (адаптер несовместим после обновления ERP) → деградация конкретного адаптера (сессии этого
provider_codeзавершаются ошибкой валидации схемы), алерт разработчику; другие подключённые интеграции (например,cms/integration-1c) не затрагиваются — изоляция на уровне адаптера. - Пустой ответ ERP API (0 позиций номенклатуры) → сессия завершается успешно с
entities_count: 0, не считается ошибкой автоматически, но при ненулевой истории — аномалия, видимая в журнале. - Большой объём номенклатуры за один вызов (сотни тысяч позиций) → адаптер обязан поддерживать постраничный забор (курсор/offset конкретного ERP API), импорт не блокирует HTTP и не грузит весь ответ в память одним куском.
Донорский код
Донор: — (новая разработка).
Начальная загрузка номенклатуры (первый обмен с новой ERP) — не отдельный legacy-импортёр, а частный случай обычного обмена через cms:erp:run. Отдельный legacy-импортёр по §16 стандарта модулю не требуется — донора с боевыми данными нет; перенос данных из старой ERP в новую (при смене учётной системы клиентом) — задача экспортных инструментов самой ERP или разового скрипта на стороне интегратора, не этого модуля.
Тесты и приёмка
- [ ] Мок API ERP-адаптера: тест импорта номенклатуры/остатков и выгрузки заказа
- [ ] Батчевый импорт идемпотентен — повторный импорт того же снапшота не дублирует записи
- [ ] Обмен выполняется по расписанию и вручную, оба пути логируются в
cms_erp_sessions - [ ] При выключении модуля заказы накапливаются в очереди выгрузки без потери
- [ ] Ошибка одного адаптера не влияет на другие подключённые интеграции (изоляция)
- [ ] Права
erp.manageразграничивают просмотр журнала и ручной запуск обмена - [ ] Контрактный тест на
erp.sync_direction: сущность с направлениемoffне импортируется/не выгружается - [ ] Контрактный тест конфликта «изменено с обеих сторон» — приоритет применяется корректно, запись в
cms_erp_conflicts - [ ] Контрактный тест деградации: ERP API недоступен → сайт работает на последнем импортированном состоянии, алерт после порога простоя
- [ ] Тест смены схемы ответа ERP API — деградирует только адаптер, остальные интеграции не затронуты
- [ ] Попытка включить второй
enabled_providerпри уже активном — отклоняется с понятным сообщением - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
erp_test,migrate:freshзапрещён