Skip to content

ТЗ — Каталог + торговые предложения (cms/commerce-catalog)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: universal (каталог-витрина), catalog (гео-каталог), Lunar (референс модели ТП) Статус: ТЗ к разработке

🔄 Ревизия (корпоративный MVP, 15.07.2026): для корпоративной витрины введён отдельный лёгкий модуль cms/catalog-showcase (без корзины/ склада/вариантов). commerce-catalog — highload-уровень «фаза 2» для настоящей e-commerce; два уровня одной задачи, вместе в одну инсталляцию не ставятся; апгрейд витрина→commerce — отдельная миграция данных.

Назначение и возможности

Ядро e-commerce: товар (Product) как «зонт» карточки и торговое предложение (Variant, ТП) — единственная сущность, с которой работают корзина, остатки и цены. Простой товар без вариаций — один неявный (implicit) вариант, без ветвлений if ($hasVariants) по коду. Модель — см. /cms-v2/commerce-model. HIGHLOAD: рассчитан на миллионы товаров/ТП.

  • Product → Variant по опциям (цвет × размер и т.п.), опции декларируются движком полей
  • Простой товар = implicit-вариант, не отдельная ветка бизнес-логики
  • Дерево категорий с ЧПУ; товар может состоять в нескольких категориях одновременно, ровно одна из них — каноническая (источник SEO-URL, см. «Модель данных»)
  • Свойства/атрибуты через cms/commerce-attributes (на товаре и на ТП раздельно)
  • SEO-карточки: канонический URL — у товара, ТП не плодят индексируемых страниц; смена slug — только через RedirectService/previous_slug (ревизия ядра 14.07.2026, см. «Крайние случаи»)
  • Витрина без покупки (universal-модель «каталог + лид») — корзина не обязательна
  • Медиа товара и опционально своё медиа на ТП (фото по цвету)
  • Листинг категории и карточка с переключателем ТП (живой остаток/цена по выбору)

Зависимости и выключение

requires: ядро (медиа, движок полей) · suggests: cms/search, cms/commerce-attributes, cms/commerce-pricing, cms/commerce-stock

Поведение при выключении: модуль базовый для e-commerce-контура — выключение убирает витрину каталога целиком; остальные commerce-*-модули без него не работают (жёсткая зависимость, не деградация).

Модель данных

ТаблицаКлючевые поляПримечание
commerce_productsid, slug, canonical_category_id, title, is_active, vat_rate_id, lock_versionтовар-«зонт», SEO на товаре; canonical_category_id — денормализованная ссылка на каноническую категорию (FK constrained()->index(), синхронизируется из pivot ниже, не источник истины принадлежности); lock_version — optimistic lock
commerce_product_categoriesproduct_id, category_id, is_canonicalмногие-ко-многим товар↔категория; составной уникальный индекс (product_id, category_id); частичный уникальный индекс (product_id) WHERE is_canonical = true — ровно одна каноническая категория на товар
commerce_variantsid, product_id, sku, barcode, weight, dimensions (json), is_active, lock_versionТП, корзина/остатки/цены оперируют только variant_id; dimensions — cast array; product_id — FK constrained()->index(), NOT NULL (переходные сироты — только в карантинной таблице ниже, не в боевой)
commerce_variant_optionsvariant_id, option_id, option_value_idкомбинация значений опций (цвет × размер) ТП движка полей; составной уникальный индекс (variant_id, option_id)
commerce_categoriesid, parent_id, slug, title, pathдерево категорий, ЧПУ; parent_id — FK constrained()->index()
commerce_listing_projectionsvariant_id, category_id, min_price, total_stock, in_stockденормализованная проекция листинга (HIGHLOAD); category_id денормализован от канонической категории товара — под индекс листинга; кандидат на партиционирование при миллионах строк
commerce_variant_import_orphansid, external_variant_id, external_product_id, payload (jsonb), first_seen_atкарантин ТП, пришедших в импорте раньше родителя-товара (см. «Крайние случаи»); очищается финализацией импорта

ПДн-паспорт. Модуль не хранит персональных данных — только карточки товаров/ТП, дерево категорий и денормализованные проекции листинга; участие в «выгрузить/забыть по субъекту» не требуется.

Входные и выходные данные

Входы:

ИсточникПоляЧем валидируется
Форма товара (POST/PUT /api/v1/admin/catalog/products)title, slug?, category_ids[], canonical_category_id, vat_rate_id, is_active, variants[]{sku,barcode,weight,dimensions,options[]}FormRequest whitelist; canonical_category_idcategory_ids[]; уникальность slug; уникальность комбинации опций внутри товара (нет двух одинаковых ТП); лимит max_categories_per_product
Массовые действия (POST /api/v1/admin/catalog/products/bulk)action ∈ {activate,deactivate,assign_category}, product_ids[]FormRequest whitelist; product_ids[]bulk_action_max_items; действие доступно только при bulk_actions_enabled=true
Деактивация товара (POST /api/v1/admin/catalog/products/{id}/deactivate)reason?admin-права, мягкая операция (не удаление)
Импорт каталога (cms:commerce-catalog:import-legacy --source=<профиль>)построчный маппинг фида (1С/CSV/донор) → Product/Variant по external_idсхема профиля импорта; батчи import_batch_size; --dry-run с отчётом расхождений
Слушаемые событияStockMoved/StockReserved/StockReleased (cms/commerce-stock), PriceChanged/PricesBulkImported (cms/commerce-pricing)payload доверенный (внутренняя шина, не пользовательский ввод) — триггерят пересчёт commerce_listing_projections

Всё, что не перечислено выше, модуль обязан отвергать (whitelist-принцип §11 стандарта): неизвестные поля товара/ТП, произвольные category_id вне справочника, canonical_category_id не из числа выбранных категорий.

Выходы:

ПотребительДанныеФормат
Публичный листинг (GET /api/v1/catalog/categories/{slug})страница товаров категории по проекциямREST-конверт {data:[...], meta:{next_cursor, degraded?}}, keyset-пагинация
Публичная карточка (GET /api/v1/catalog/products/{slug})товар + список ТП + опции{data:{...,variants:[...]}}
Блок листинга/карточки/переключателя ТП (BlockRegistry)props блокадемо-props при выключенном контенте, версия _v схемы
cms/search (индексация)ProductPublished, ListingProjectionRebuiltсобытия шины
RedirectService (ядро)смена URL товара при смене slugсервис-вызов с previous_slug
SitemapRegistry (ядро)декларация URL-источников (товары, категории)регистрация при boot модуля
Filament (админка)список/карточка товара с вложенной таблицей ТПadmin-view

Настройки (группа commerce-catalog)

КлючТипДефолтaffectsPageCacheОписание
commerce-catalog.require_cartbooltrueнетОбязательна ли покупка (false — витрина+лид без корзины)
commerce-catalog.listing_page_sizeint24даРазмер страницы листинга категории
commerce-catalog.projection_queuestringdefaultнетОчередь пересчёта денормализованных проекций
commerce-catalog.max_categories_per_productint10нетЛимит категорий на товар (многие-ко-многим)
commerce-catalog.import_batch_sizeint1000нетРазмер чанка батчевого upsert при импорте
commerce-catalog.bulk_action_max_itemsint500нетЛимит товаров в одном массовом действии админки
commerce-catalog.bulk_actions_enabledbooltrueнетKill-switch: аварийное отключение массовых операций админки без выключения модуля

Достижение лимитов (max_categories_per_product, bulk_action_max_items) отдаёт понятную ошибку 422 с кодом, не 500 и не тихое обрезание списка.

API

МетодПутьДоступНазначение
GET/api/v1/catalog/categories/{slug}publicЛистинг категории (keyset-пагинация)
GET/api/v1/catalog/products/{slug}publicКарточка товара со списком ТП
GET/api/v1/catalog/variants/{id}publicДанные конкретного ТП (цена/остаток/опции)
GET/api/v1/admin/catalog/productsadmin (commerce-catalog.view)Список товаров для админки
POST/api/v1/admin/catalog/productsadmin (commerce-catalog.manage)Создание/правка товара и ТП
POST/api/v1/admin/catalog/products/{id}/deactivateadmin (commerce-catalog.manage)Мягкая деактивация товара
POST/api/v1/admin/catalog/products/bulkadmin (commerce-catalog.manage)Массовые действия над списком товаров

Коллекции (листинг категории, список товаров админки) — keyset-пагинация (cursorPaginate), OFFSET на глубине запрещён. Мутации товара/ТП с денежным эффектом на связанных модулях (импорт цен через commerce-pricing) несут Idempotency-Key на стороне владельца операции.

Компоненты

Блоки (BlockRegistry): листинг категории, карточка товара, переключатель ТП. Filament: ресурс товара с вложенной таблицей ТП, дерево категорий, конструктор принадлежности к категориям с выбором канонической. Команды: cms:commerce-catalog:rebuild-projections --json, cms:commerce-catalog:doctor --json.

Демо-контент: сидер демо-категории с товарами (простыми и с вариациями) — галерея /_gallery и playground показывают листинг/карточку/переключатель без ручного ввода. Фронтенд-бюджет: переключатель ТП — свой лёгкий чанк (не блокирует рендер карточки), изображения листинга — lazy-load по видимости, область цены/остатка на карточке зарезервирована по размеру (переключение ТП не даёт CLS), свотчи опций доступны с клавиатуры и несут aria-label.

События и обмен

СобытиеКогдаPayload
ProductPublishedтовар опубликован/сохранёнproduct_id, slug, previous_slug (nullable)
ProductDeactivatedтовар деактивирован (вручную, массово или импортом)product_id, reason ∈ {manual,bulk,import}
ListingProjectionRebuiltпересчитана денормализованная проекция листингаvariant_id, category_id, min_price, in_stock
CatalogImportCompletedзавершён батчевый импорт каталогаimported_count, updated_count, orphaned_count

Слушает: StockMoved/StockReserved/StockReleased из cms/commerce-stock и PriceChanged/ PricesBulkImported из cms/commerce-pricing — для пересчёта проекций листинга.

Provides-контракты: не предоставляет (базовый модуль-владелец каталога). FilterBus: не использует (сам является источником данных для фильтров остальных commerce-*-модулей).

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-stockсобытие (шина)cms/commerce-stock → catalogStockMoved/StockReserved/StockReleased триггерят пересчёт проекции листинга
cms/commerce-pricingсобытие (шина)cms/commerce-pricing → catalogPriceChanged/PricesBulkImported триггерят пересчёт проекции листинга
cms/commerce-attributesпрямой сервис-вызов по requirescms/commerce-attributes → catalogатрибуты проверяют существование товара/ТП перед записью значения через публичный сервис каталога
cms/commerce-cartпрямой сервис-вызов по requirescms/commerce-cart → catalogкорзина резолвит variant_id в снапшот (цена/название) и проверяет is_active при оформлении
cms/commerce-wishlistпрямой сервис-вызов по requirescms/commerce-wishlist → catalogпроверка доступности товара, отображение статуса «недоступен» без удаления позиции
cms/searchсобытие (шина)catalog → cms/searchProductPublished/ListingProjectionRebuilt инициируют переиндексацию
RedirectService (ядро)прямой сервис-вызов по requirescatalog → ядросмена slug товара регистрирует 301, не самописная таблица редиректов
SitemapRegistry (ядро)provides-контракт/регистрацияcatalog → ядродекларативная регистрация URL товаров/категорий в /sitemap.xml

Фоновая работа

Очередь commerce-catalog: пересчёт commerce_listing_projections по событиям StockMoved/StockReserved/StockReleased/PriceChanged/PricesBulkImported — асинхронно, без блокировки записи товара/ТП. Расписание — периодическая сверка проекций через ScheduleRegistrar (страховка от пропущенных событий). Импорт каталога — COPY/батчевый upsert чанками import_batch_size через Bus::batch; после каждого батча — реконсиляция карантина commerce_variant_import_orphans (джоба идемпотентна по external_variant_id).

Эксплуатация (ранбук). Метрики: отставание очереди rebuild-projections, число записей в commerce_variant_import_orphans старше суток, пропускная способность импорта (товаров/сек). Алерты: отставание очереди пересчёта проекций выше порога, сироты импорта не реконсилированы дольше 24 часов.

СимптомЧто проверитьКоманда
Цена/остаток на карточке не совпадает с commerce-pricing/commerce-stockотставание очереди commerce-catalog, пропущенные событияcms:commerce-catalog:rebuild-projections --json --dry-run, затем без --dry-run
Товары из импорта не появляются в каталогекарантин commerce_variant_import_orphans не реконсилирован (родитель ещё не создан)cms:commerce-catalog:doctor --json
Листинг категории пуст при наличии товаровпроекции не пересчитаны или каноническая категория не проставленаcms:commerce-catalog:rebuild-projections --json

Бэкап/рестор: в бэкап попадают commerce_products, commerce_product_categories, commerce_variants, commerce_variant_options, commerce_categories и связанные медиа. commerce_listing_projections и commerce_variant_import_orphans — деривативные, после рестора пересоздаются rebuild-projections/повторным прогоном импорта, в критичный бэкап можно не включать (рестор без пересчёта проекций не считается завершённым).

Производительность и кеш

Ожидаемые объёмы — до миллионов товаров/ТП на инсталляцию (см. commerce-model, «Highload-требования»). Горячие пути: листинг категории, карточка товара, переключатель ТП.

Бюджет запросов (контрактный тест, N+1 = баг):

  • листинг категории — ≤ 3 запроса (кеш дерева категорий + keyset-запрос по commerce_listing_projections + гидрация нужных колонок товара, без COUNT(*));
  • карточка товара — ≤ 4 запроса (товар + ТП + опции ТП + медиа, explicit select);
  • переключатель ТП (GET /api/v1/catalog/variants/{id}) — ≤ 1 запрос (одна строка проекции).

Индексы: составной (category_id, is_active) в commerce_listing_projections под WHERE/ORDER BY листинга; частичный уникальный (product_id) WHERE is_canonical = true под SEO; составной уникальный (product_id, category_id) в pivot-таблице; GIN — по JSONB-атрибутам ТП (владеет cms/commerce-attributes, каталог использует те же индексы на своих join'ах).

Теги commerce-catalog:product:{id}, commerce-catalog:category:{id}. Инвалидация — событиями ProductPublished, ProductDeactivated, ListingProjectionRebuilt. Импорт каталога — батчевая инвалидация по категории чанками import_batch_size, не поштучно на товар (лавина инвалидации на миллион товаров кладёт Redis).

Безопасность

Вход — FormRequest-whitelist на создание/правку товара, ТП и принадлежности категориям; массовые действия — whitelist action-enum и лимит product_ids[]. Rate-limit на публичных GET листинга/карточки (защита highload-витрины от скрейпинга). Конкурентная правка товара/ТП — lock_version (optimistic lock), конфликт отдаёт 409 с кодом version_conflict, не «последний победил» молча.

Матрица ролей:

ДействиеРедакторМенеджер (commerce-catalog.manage)Админstudio
Просмотр каталога, листинга, карточек
Создание/правка товара и ТП
Деактивация одного товара
Массовые действия (bulk)
Запуск import-legacy
Отключение kill-switch bulk_actions_enabled
Удаление категории с товарами✅ (после переноса товаров)

Права: commerce-catalog.view, commerce-catalog.manage.

UX-требования

Для админа: пустая категория без товаров — подсказка «добавить товар» / «запустить импорт», не пустая таблица без объяснения; массовые действия (деактивация, назначение категории) требуют подтверждения с явным числом затронутых товаров, деактивация — с предупреждением, если товары лежат в активных корзинах/вишлистах пользователей; ошибки импорта — построчный отчёт на человеческом языке («строка 132: не найдена категория external_id=X»), не голый stack trace; удаление категории с товарами — отдельное необратимо звучащее действие с явным подтверждением.

Для покупателя/посетителя: переключатель ТП обновляет цену/остаток без перезагрузки страницы и без сдвига layout (зарезервированная область); при недоступности выбранной комбинации опций — понятное сообщение «нет в наличии», а не пустой экран; листинг категории отдаёт первую страницу быстро (бюджет запросов выше) — воспринимаемая скорость важнее на глубине страницы, где конкуренты используют дорогой OFFSET; карточка и фильтр доступны с клавиатуры, свотчи опций имеют aria-label.

Крайние случаи и типовые баги

  • Товар в нескольких категориях → многие-ко-многим (commerce_product_categories), ровно одна каноническая (частичный уникальный индекс) — SEO-канонический URL строится от неё; смена канонической категории — отдельная явная операция, не побочный эффект добавления новой связи.
  • Деактивация товара, лежащего в чужих корзинах/вишлистах → листинг/карточка скрывают товар сразу; уже добавленные позиции корзины сохраняют снапшот (цена/название, см. cms/commerce-cart) и не исчезают молча, но оформление заказа с такой позицией блокируется ошибкой с кодом product_unavailable, требующей убрать позицию; вишлист помечает позицию бейджем «недоступен», не удаляет автоматически.
  • Массовый импорт и батчевая инвалидация → инвалидация тегов кеша идёт чанками import_batch_size по категориям, не по товару; CatalogImportCompleted несёт агрегированную статистику, а не событие на каждый товар.
  • ТП существует без явного родителя-Product в переходных состояниях импортаcommerce_variants.product_id остаётся NOT NULL в боевой таблице; строки, пришедшие раньше родителя, уходят в карантин commerce_variant_import_orphans; финализирующий шаг переносит их после появления Product, ключ идемпотентности — external_variant_id; --dry-run показывает оставшихся сирот как расхождение, сироты старше 24 часов — алерт.
  • Смена slug товара → только через RedirectService/previous_slug в payload ProductPublished (ревизия ядра 14.07.2026); самописная таблица редиректов запрещена — потребители (cms/cdn, SEO) делают purge и 301 по стандартному контракту.
  • Конкурентное редактирование товара/ТП двумя админамиlock_version (optimistic lock), конфликт — 409 version_conflict с человеческим сообщением, не «последний победил» молча.
  • Удаление категории, в которой есть товары → запрещено напрямую; API отдаёт понятную ошибку с подсказкой перенести или деактивировать товары сначала — каскадное удаление товаров вместе с категорией не допускается.
  • Пустой листинг vs листинг на миллионах строк → пустая категория показывает подсказку, не голую таблицу; на глубине листинг не деградирует (keyset-пагинация вместо OFFSET, см. «Производительность»).
  • require_cart=false (лид-режим витрины) → карточка товара работает как лид-форма, но цена всё равно резолвится и показывается информативно через cms/commerce-pricing (если подключён).
  • ⚠️ Противоречие: commerce-catalog.require_cart=false (витрина без покупки) при включённом cms/commerce-stock, который резервирует остаток на «оформление заказа», — в лид-режиме заказа и резерва не существует вовсе, конфигурация резервирования становится мёртвым кодом без диагностики. Разрешение: cms:commerce-catalog:doctor при require_cart=false и включённом cms/commerce-stock алертит в админке о несовместимой комбинации настроек, а не оставляет её тихо неработающей.

Донорский код

Что взятьПуть
Модель каталога-витрины без обязательной корзиныuniversal (имя проекта, путь не выдан)
Гео-специфика каталога (город/склад в выдаче)catalog (имя проекта, путь не выдан)
Референс модели variants/pricingLunar (open-source, путь не выдан)

Миграция legacy: cms:commerce-catalog:import-legacy --source=<профиль> — переносит товары/ТП/категории с донорских проектов (universal, catalog) и произвольных 1С/CSV-фидов по профилю маппинга; идемпотентна по external_id на товаре и ТП, --dry-run с отчётом расхождений (создано/обновлено/сирот/построчных ошибок). Обязательна для переезда клиентов с этих донор-проектов, где каталог уже существовал.

Тесты и приёмка

  • [ ] Контрактный тест: листинг категории использует cursorPaginate, OFFSET на глубине запрещён
  • [ ] Простой товар создаётся с ровно одним implicit-вариантом, корзина всегда получает variant_id
  • [ ] Деривативы листинга (мин. цена, остаток, «в наличии») читаются из commerce_listing_projections, не подзапросами на лету
  • [ ] Пересчёт проекций идёт событиями через очередь, без синхронной блокировки записи товара
  • [ ] Горячие пути листинга/карточки/переключателя ТП укладываются в заявленный бюджет запросов, без N+1 (contract-тест)
  • [ ] Составные индексы под WHERE/ORDER BY листинга, частичный уникальный индекс канонической категории
  • [ ] Товар не может иметь ноль или больше одной канонической категории (contract-тест на pivot)
  • [ ] Деактивация товара с активными позициями в корзинах/вишлистах не удаляет их молча, оформление блокируется кодом product_unavailable
  • [ ] Карантинная реконсиляция сирот импорта переносит ТП в боевую таблицу после появления родителя, идемпотентно
  • [ ] Смена slug товара регистрирует редирект через RedirectService, не самописной таблицей
  • [ ] Конкурентная правка товара/ТП отдаёт 409 version_conflict, не «последний победил»
  • [ ] Массовый импорт идёт батчевым upsert (Bus::batch), инвалидация кеша — чанками, не поштучно
  • [ ] При require_cart=false карточка товара работает как лид-форма без корзины; несовместимость с commerce-stock алертится, не молчит
  • [ ] Права commerce-catalog.view/.manage и матрица ролей разграничивают чтение, правку, bulk-действия и удаление категории
  • [ ] Контрактный набор cms-testing и testbench-изоляция зелёные, feature-тест на каждый роут
  • [ ] Тестовая БД только commerce-catalog_test; migrate:fresh/refresh/reset, db:wipe запрещены

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