Тема
ТЗ — Каталог + торговые предложения (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_products | id, 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_categories | product_id, category_id, is_canonical | многие-ко-многим товар↔категория; составной уникальный индекс (product_id, category_id); частичный уникальный индекс (product_id) WHERE is_canonical = true — ровно одна каноническая категория на товар |
commerce_variants | id, 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_options | variant_id, option_id, option_value_id | комбинация значений опций (цвет × размер) ТП движка полей; составной уникальный индекс (variant_id, option_id) |
commerce_categories | id, parent_id, slug, title, path | дерево категорий, ЧПУ; parent_id — FK constrained()->index() |
commerce_listing_projections | variant_id, category_id, min_price, total_stock, in_stock | денормализованная проекция листинга (HIGHLOAD); category_id денормализован от канонической категории товара — под индекс листинга; кандидат на партиционирование при миллионах строк |
commerce_variant_import_orphans | id, 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_id ⊆ category_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_cart | bool | true | нет | Обязательна ли покупка (false — витрина+лид без корзины) |
commerce-catalog.listing_page_size | int | 24 | да | Размер страницы листинга категории |
commerce-catalog.projection_queue | string | default | нет | Очередь пересчёта денормализованных проекций |
commerce-catalog.max_categories_per_product | int | 10 | нет | Лимит категорий на товар (многие-ко-многим) |
commerce-catalog.import_batch_size | int | 1000 | нет | Размер чанка батчевого upsert при импорте |
commerce-catalog.bulk_action_max_items | int | 500 | нет | Лимит товаров в одном массовом действии админки |
commerce-catalog.bulk_actions_enabled | bool | true | нет | 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/products | admin (commerce-catalog.view) | Список товаров для админки |
| POST | /api/v1/admin/catalog/products | admin (commerce-catalog.manage) | Создание/правка товара и ТП |
| POST | /api/v1/admin/catalog/products/{id}/deactivate | admin (commerce-catalog.manage) | Мягкая деактивация товара |
| POST | /api/v1/admin/catalog/products/bulk | admin (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 → catalog | StockMoved/StockReserved/StockReleased триггерят пересчёт проекции листинга |
cms/commerce-pricing | событие (шина) | cms/commerce-pricing → catalog | PriceChanged/PricesBulkImported триггерят пересчёт проекции листинга |
cms/commerce-attributes | прямой сервис-вызов по requires | cms/commerce-attributes → catalog | атрибуты проверяют существование товара/ТП перед записью значения через публичный сервис каталога |
cms/commerce-cart | прямой сервис-вызов по requires | cms/commerce-cart → catalog | корзина резолвит variant_id в снапшот (цена/название) и проверяет is_active при оформлении |
cms/commerce-wishlist | прямой сервис-вызов по requires | cms/commerce-wishlist → catalog | проверка доступности товара, отображение статуса «недоступен» без удаления позиции |
cms/search | событие (шина) | catalog → cms/search | ProductPublished/ListingProjectionRebuilt инициируют переиндексацию |
RedirectService (ядро) | прямой сервис-вызов по requires | catalog → ядро | смена 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в payloadProductPublished(ревизия ядра 14.07.2026); самописная таблица редиректов запрещена — потребители (cms/cdn, SEO) делают purge и 301 по стандартному контракту. - Конкурентное редактирование товара/ТП двумя админами →
lock_version(optimistic lock), конфликт — 409version_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/pricing | Lunar (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запрещены