Skip to content

ТЗ — 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_sessionsid, provider_code, direction, status, started_at, finished_at, errorсессия обмена с ERP
cms_erp_mappingsid, provider_code, entity_type, external_id, internal_idмаппинг номенклатуры/складов на модели ядра
cms_erp_conflictsid, 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 в conflictsconstrained() + index().

Карта сущностей и направление синхронизации. Generic-коннектор (provides: erp-connector) не подразумевает «синхронизируется всё одинаково» — направление задаётся per сущность:

СущностьСинхронизируетсяНаправление по умолчаниюНастройка
Номенклатура (товары)даERP → сайтerp.sync_direction.catalog
ОстаткидаERP → сайтerp.sync_direction.stock
ЦеныдаERP → сайтerp.sync_direction.pricing
Заказы / отгрузкидасайт → ERPerp.sync_direction.orders
Статусы заказовопциональноERP → сайтerp.sync_direction.order_status
Контрагенты (покупатели)опциональносайт → ERPerp.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-пагинация
Разработчик/healthcms:erp:doctor --jsonдоступность API ERP, валидность маппинговJSON

Настройки (группа erp)

КлючТипДефолтaffectsPageCacheОписание
erp.enabled_providerstringnullнетКод активного адаптера ERP (осознанное ограничение — один активный адаптер, см. «Крайние случаи»)
erp.sync_pausedboolfalseнетKill-switch: приостановить обмен без выключения модуля/смены enabled_provider
erp.schedule_cronstring"0 */3 * * *"нетРасписание автообмена
erp.import_batch_sizeint500нетРазмер батча импорта номенклатуры/остатков
erp.manual_run_cooldown_minutesint5нетМинимальный интервал между ручными запусками
erp.sync_directionjsonсм. таблицу вышенетНаправление синхронизации per сущность (in/out/off)
erp.unavailable_alert_threshold_hoursint6нетПорог простоя ERP API, после которого — алерт (не сразу при первом таймауте)

Секреты доступа к API ERP — только .env.

API

МетодПутьДоступНазначение
GET/api/v1/admin/erp/sessionsadmin (erp.view)Журнал сессий обмена
POST/api/v1/admin/erp/runadmin (erp.manage)Ручной запуск обмена
GET/api/v1/admin/erp/mappingsadmin (erp.view)Маппинги номенклатуры/складов
GET/api/v1/admin/erp/conflictsadmin (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заказ/отгрузка выгружены в ERPprovider_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/import5 — очередьerp → cms/importбатч номенклатуры/остатков/цен передаётся генерик-импортёру пакетами import_batch_size
cms/integrations-bus5 — очередь / внешнийerp → integrations-busвсе HTTP-вызовы к API конкретной ERP маршрутизируются через шину интеграций
provides: erp-connector3 — сервисный контрактconsumer → erpлюбой модуль, которому нужна способность «получить данные ERP», резолвит контракт по интерфейсу, не завязываясь на конкретный адаптер
cms/integration-1csuggests, общий пул маппинговerp ↔ integration-1cпри совместном включении — общий external_id/internal_id пространство, риск конфликта владения (см. «Крайние случаи», ⚠️)
cms/commerce-orders1 — событие (слушает 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_pausedcms: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_module per сущность/поле должна жить на уровне владельца данных (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 запрещён

Внутренняя база знаний студии