Skip to content

ТЗ — Витрина товаров (cms/catalog-showcase)

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

🔄 Ревизия (волна E корп-MVP, 15.07.2026, реализация): построен MVP-срез: таблицы categories/products (цены — integer копейки + currency_code, attributes jsonb

  • GIN, search_vector tsvector + GIN, HasDeclensions, lock_version, nullable-измерения), сервисы (дерево одним рекурсивным CTE с кешем, фасеты по whitelist facet_keys, keyset-листинг, FTS websearch_to_tsquery с ILIKE-fallback, похожие same-category, собственный ProductJsonLd), публичные HTML /catalog[/{category}[/{product}]] (SEO generic-контекст ядра, JSON-LD Product/Offer/BreadcrumbList, форма заявки блоком ядра) и публичное API (6 эндпоинтов, kill_switch → meta.degraded), блоки catalog_grid/product_showcase, Filament-ресурсы с bulk-действиями (set_price/set_unit/set_price_on_request/set_sort/activate/deactivate, лимит bulk_action_max_items), демо-сидер. Вне среза: admin REST API (CRUD/bulk — Filament закрывает), XLSX-экспорт прайса, legacy-импорт, варианты товара, цена-по-городу (P2, решение №20), related-content-стратегия похожих, событие CatalogShowcaseLeadSubmitted (product_id в payload формы — открытый вопрос ядра).

🔄 Ревизия (корпоративный MVP, 15.07.2026): модуль осознанно остаётся вне движка типов контента — фасеты, полнотекст и масштаб листинга не покрываются общей JSONB-схемой записи; см. границу гибрида engine §1 и критерий «тип перерос движок» engine §12.

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

Лёгкая витрина товаров/услуг для корпоративного сайта: категории + карточка товара с ценой (или «по запросу»), без корзины, склада, торговых предложений (вариантов) и движка цен. Решение студии: это отдельный модуль вне namespace commerce-*, не тянущий highload-стек cms/commerce-catalog (Product/Variant, денормализованные проекции листинга, партиционирование, весь кластер commerce-*). Профиль нагрузки — до нескольких тысяч SKU, нормализованная, но простая модель без вариаций.

Граница с commerce-catalog (архитектурное решение, зафиксировано студией).commerce-catalog в режиме лид-каталога (require_cart=false) — это «старший брат», апгрейд-путь «фаза 2» для настоящей e-commerce/highload-инсталляции (миллионы товаров, торговые предложения по опциям, фасетные агрегации из поискового индекса). catalog-showcase и commerce-catalog не ставятся вместе в одну инсталляцию — это два продуктовых уровня одной задачи «показать товары на сайте», а не два независимых модуля одного уровня. Переезд с витрины на полноценный каталог — отдельная осознанная миграция данных (см. «Донорский код»/«Крайние случаи»), не «заодно» при апгрейде.

  • Дерево категорий с ЧПУ и склонениями (для оборотов вида «в категории «Насосы» 12 товаров»)
  • Карточка товара: фиксированная цена или «по запросу»/«узнать цену» без пугающего «0 ₽»
  • Произвольные атрибуты товара (характеристики) как JSONB — без движка вариантов/опций
  • Лёгкие фасеты листинга (whitelist ключей атрибутов + диапазон габаритов) — свои, для объёма витрины; при росте каталога выше профиля — апгрейд на cms/commerce-facets
  • Полнотекстовый поиск и подсказки — интеграция с cms/search, ILIKE-fallback ядра при его отсутствии
  • Похожие товары — интеграция с cms/related-content, fallback «та же категория»
  • Заявка «получить цену»/«по запросу» — карточка товара работает как лид-форма поверх ядровых cms_forms/LeadService, собственной таблицы лидов у модуля нет
  • Экспорт прайс-листа в XLSX (нативные Filament Exports), по городу — при cms/multicity
  • Bulk-редактирование в админке: цена/единица измерения/«по запросу»/сортировка/активность
  • Блоки для конструктора страниц: сетка категорий и подборка товаров

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

requires: ядро (cms/core-contracts: движок полей, MediaService, LeadService + конструктор форм cms_forms, CacheTags, RequestContext, BlockRegistry) · suggests: cms/search (полнотекстовая выдача и подсказки вместо ILIKE-fallback), cms/related-content (похожие товары вместо fallback «та же категория»), cms/multicity (цена/видимость/URL по городу — без него city_id просто nullable), cms/galleries (доп. портфолио к товару, опционально), cms/seo-filter (умные посадочные под комбинации атрибутов, опционально). Не requires и не suggests ни один модуль commerce-* — это осознанная граница (см. «Назначение»).

Поведение при выключении: блоки «Сетка категорий»/«Подборка товаров» отдают fallback-заглушку (пусто), публичный листинг/карточка отвечают 404 на собственных маршрутах, данные (категории, товары, атрибуты, медиа) не удаляются и полностью восстанавливаются при повторном включении. Ни одна сторонняя сущность не хранит FK на таблицы модуля — выключение catalog-showcase не блокирует работу остального сайта.

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

ТаблицаКлючевые поляПримечание
cms_catalog_showcase_categoriesid, parent_id (nullable), slug, title, declensions (json), icon (nullable), description (nullable), sort, is_active, lock_version, city_id (nullable), locale (nullable)дерево категорий, ЧПУ
cms_catalog_showcase_productsid, category_id, slug, title, description, price (int, nullable), currency_code, price_on_request, price_note (nullable), unit (nullable), length_mm/width_mm/height_mm/weight_kg (nullable), attributes (jsonb), search_vector (tsvector), sort, is_active, lock_version, city_id (nullable), locale (nullable)карточка товара, публичный ключ — slug

parent_idconstrained()->index() (самоссылка, обход дерева — один закешированный рекурсивный CTE-запрос, не N рекурсивных вызовов ORM); category_idconstrained()->index(); slug уникален в рамках сайта на обеих таблицах (не автоинкрементный id в публичных URL/ответах API); declensions — JSONB, тот же паттерн, что у cms/multicity (HasDeclensions) — «в категории», «категорию», «к категории» без хардкода окончаний в шаблонах.

price — integer minor units (копейки), currency_code рядом (float запрещён, §4 стандарта); price_on_request — bool: true отключает показ price независимо от его значения в БД (защита от случая «цену забыли стереть, но включили „по запросу“» — см. «Крайние случаи»); price_note — нехардкодный текст вроде «цена за м²», «от 1000 шт.». attributes — JSONB, cast array, GIN-индекс — источник и для карточки характеристик, и для лёгких фасетов (whitelist ключей — настройка facet_keys, не весь JSON целиком). search_vector — tsvector, GIN-индекс, источник PG ILIKE/tsvector-fallback и материал для индексации cms/search, если он включён.

Медиа товара (галерея + превью) — через медиатеку ядра (MediaService, spatie/medialibrary), собственной таблицы медиа у модуля нет — переиспользование конверсий и защита от дублирования файлов (§1 стандарта: чужого не трогаем, но и не изобретаем своё там, где есть механизм ядра).

lock_version — optimistic lock на обеих таблицах (конкурентное редактирование в Filament); city_id/locale — nullable-измерения (§4 стандарта): категория/товар с city_id = null видны во всех городах, работа без cms/multicity не требует отдельной ветки кода.

ПДн-паспорт. Модуль не хранит персональных данных: категории/товары — публичный контент, атрибуты и медиа — не о людях. Данные заявки «получить цену» (имя, телефон, email) уходят напрямую в LeadService/cms_leads ядра и там же живут — ретеншн, «выгрузить всё по субъекту» и «забыть по запросу» (152-ФЗ) обеспечивает владелец, ядро; catalog-showcase данные заявки не персистит (тот же паттерн, что у cms/popups и cms/calculator).

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

Входы (whitelist-принцип §11 стандарта — всё, что не перечислено, отклоняется):

ИсточникПоляЧем валидируется
Admin CRUD категории (/api/v1/admin/catalog-showcase/categories)parent_id, slug, title, declensions, icon, description, sort, is_active, lock_versionFormRequest-whitelist; slug уникален; parent_id не создаёт цикл в дереве; lock_version сверяется — иначе 409
Admin CRUD товара (/api/v1/admin/catalog-showcase/products)category_id, slug, title, description, price, currency_code, price_on_request, price_note, unit, length_mm/width_mm/height_mm/weight_kg, attributes, is_active, lock_versionFormRequest-whitelist; category_id существует; attributes — ключи только из зарегистрированного набора атрибутов (движок полей типа товара, не произвольный JSON от клиента); price_on_request=false требует price не null
Bulk-редактирование (POST /api/v1/admin/catalog-showcase/products/bulk)action ∈ {set_price,set_unit,set_price_on_request,set_sort,activate,deactivate}, product_ids[], value?FormRequest-whitelist по action-enum; product_ids[]bulk_action_max_items; действие требует bulk_actions_enabled=true
Публичный листинг (GET /products)filter[category], filter[attr][…], filter[price][min|max], sort, cursorFormRequest сверяет filter[attr][…] со списком facet_keys из настроек — незарегистрированный ключ отклоняется 422
Заявка на карточке (форма ядра, slug из catalog-showcase.lead_form_slug)стандартные поля формы ядра + product_id, product_title в контекстевалидируется движком форм ядра (не своим кодом); отправка идёт на канонический POST /api/v1/forms/{slug}/submit, у модуля нет собственного эндпоинта приёма заявки
Экспорт прайс-листа (Filament-действие/команда)category_id?, city_id? (если cms/multicity)admin-права, whitelist параметров экспорта
Legacy-импорт (cms:catalog-showcase:import-legacy --source=<профиль>)построчный маппинг фида/донорских таблиц → категория/товар по external_idсхема профиля; --dry-run с отчётом расхождений
Слушаемые событияRelatedContentLinked/RelatedContentUnlinked (cms/related-content), SearchIndexRebuilt (cms/search)внутренний источник, не пользовательский ввод — триггеры инвалидации кеша похожих/подсказок

Выходы:

ПотребительДанныеФормат
Публичный листинг (GET /api/v1/catalog-showcase/products)страница товаров категории/фильтраконверт {data:[...], meta:{next_cursor}}, keyset-пагинация
Публичная карточка (GET /api/v1/catalog-showcase/products/{slug})товар с атрибутами, медиа, ценой либо признаком «по запросу»{data:{...}}
Похожие товары (GET /products/{slug}/similar)подборка через cms/related-content или fallback категории{data:[...], meta:{source:"related-content"|"same-category"}}
Фасеты (GET /categories/{slug}/facets)пары «значение → count» по whitelist facet_keys + диапазоны габаритов{data:{facets:[...]}}
Блок «Сетка категорий» (BlockRegistry)дерево/список категорий с иконкой и счётчиком товаровprops блока, demo-props в галерее, версия _v
Блок «Подборка товаров» (BlockRegistry)товары по категории или явному списку idprops блока, версия _v
LeadService::create() (ядро)заявка «получить цену»/«по запросу» с product_id/product_title в payloadвызов сервиса ядра (канал 4), модуль данные заявки не хранит
cms/search (если включён)ProductPublished, search_vector как источниксобытие шины — индексация
Filament (админка)список/карточка товара с bulk-действиями, конструктор дерева категорийadmin-view
Экспорт прайс-листаXLSX по категории/городуфайл через Filament Export
Legacy-импортотчёт прогона--json: создано/обновлено/пропущено/ошибки построчно

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

КлючТипДефолтaffectsPageCacheОписание
catalog-showcase.per_pageint24даРазмер страницы листинга
catalog-showcase.facet_keysarray[]даWhitelist ключей attributes, доступных как фасеты фильтра (декларативно, без хардкода)
catalog-showcase.default_currencystringRUBнетКод валюты по умолчанию для новых товаров
catalog-showcase.show_price_without_authbooltrueдаПоказывать цену гостю; false — цена видна только авторизованным (B2B-сценарий), гость видит «узнать цену»
catalog-showcase.similar_strategystringrelated-contentнетИсточник похожих товаров (related-content/same-category)
catalog-showcase.lead_form_slugstringcatalog-showcase-requestнетSlug формы ядра для заявки «получить цену» на карточке
catalog-showcase.bulk_action_max_itemsint200нетЛимит товаров в одном массовом действии админки
catalog-showcase.max_facet_valuesint20нетЛимит значений на один фасет («показать ещё» сверх лимита)
catalog-showcase.kill_switchboolfalseдаАварийное отключение витрины: публичные роуты отдают fallback без выключения модуля

Достижение bulk_action_max_items — понятная ошибка 422 с кодом, не 500 и не тихое обрезание списка.

API

МетодПутьДоступНазначение
GET/api/v1/catalog-showcase/categoriespublicДерево категорий
GET/api/v1/catalog-showcase/categories/{slug}publicКатегория + её товары (листинг)
GET/api/v1/catalog-showcase/categories/{slug}/facetspublicЛёгкие фасетные счётчики (whitelist facet_keys)
GET/api/v1/catalog-showcase/productspublicЛистинг с filter[…]/sort по whitelist, keyset-пагинация
GET/api/v1/catalog-showcase/products/{slug}publicКарточка товара
GET/api/v1/catalog-showcase/products/{slug}/similarpublicПохожие товары
GET/POST/PUT/DELETE/api/v1/admin/catalog-showcase/categories…admin (catalog-showcase.manage)CRUD дерева категорий
GET/POST/PUT/DELETE/api/v1/admin/catalog-showcase/products…admin (catalog-showcase.manage)CRUD товаров
POST/api/v1/admin/catalog-showcase/products/bulkadmin (catalog-showcase.manage)Массовые действия над списком товаров

Заявка «получить цену» не имеет своего эндпоинта — карточка встраивает форму ядра по catalog-showcase.lead_form_slug, отправка идёт на канонический POST /api/v1/forms/{slug}/submit (тот же паттерн, что у cms/popups/cms/landings: модуль не дублирует приём и валидацию форм ядра). Мутации категории/товара несут lock_version — рассинхрон отдаёт 409 (конвенции ядра §7 стандарта). Коллекции — только keyset-пагинация, OFFSET на глубине запрещён.

Компоненты

Блоки (BlockRegistry): «Сетка категорий» (catalog_grid) и «Подборка товаров» (product_showcase, по категории или явному списку product_ids) — данные рендера берутся из сервиса модуля (CatalogShowcaseService), не запросами из Blade напрямую (§8 стандарта); демо-props в галерее, версия схемы _v + data-миграции. Карточка и листинг — контроллеры (Controller → Service → Model), не блоки.

Схема.org: карточка публикует Product/Offer; при price_on_request=falseOffer с price/priceCurrency; при trueOffer без цены и с признаком «по запросу» (availability, соответствующий предложению без немедленной покупки), чтобы не публиковать вводящую в заблуждение микроразметку «0 ₽».

Filament: конструктор дерева категорий (drag-n-drop, склонения), ресурс товара с характеристиками из движка полей, bulk-действия на списке (цена, единица, «по запросу», сортировка, активность), действие экспорта прайс-листа в XLSX (нативные Filament Exports; по городу — при cms/multicity). Команды: cms:catalog-showcase:rebuild-search--json (пересборка search_vector), cms:catalog-showcase:export-price --json, cms:catalog-showcase:import-legacy --source=<профиль> --json, cms:catalog-showcase:doctor --json. Демо-сидер: 2–3 категории с товарами (в т.ч. один «по запросу») — галерея /_gallery и playground показывают оба блока без ручного ввода.

Фронтенд-бюджет: изображения листинга — lazy-load по видимости, область цены на карточке зарезервирована по размеру (переключение «по запросу» ↔ цена не даёт CLS), фильтры фасетов доступны с клавиатуры и несут aria-label.

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

СобытиеКогдаPayload
ProductPublishedтовар создан/сохранён и активенproduct_id, slug, category_id
ProductDeactivatedтовар деактивирован (вручную, массово, импортом)product_id, reason ∈ {manual,bulk,import}
CategorySavedкатегория создана/измененаcategory_id, parent_id
CatalogShowcaseLeadSubmittedзаявка «получить цену» успешно создана через LeadServiceproduct_id, lead_id

Слушает: RelatedContentLinked/RelatedContentUnlinked (cms/related-content, инвалидация кеша похожих), SearchIndexRebuilt (cms/search, признак, что подсказки можно брать из индекса, а не ILIKE-fallback).

Provides-контрактов не реализует. FilterBus: не использует.

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

Сущность/модульКаналНаправлениеЧто происходит
LeadService (ядро)4 — сервис-вызов по requirescatalog-showcase → ядроотправка формы «получить цену» создаёт лид с product_id/product_title в payload; модуль данные заявки не хранит. ⚠️ Противоречие (ревизия ядра): привязка «заявка ↔ товар» через payload — временный костыль; типизированная связь появится с полиморфным leadable_type/leadable_id в cms_leads (open-questions, п.3). До этого отчёты «заявки по товару X» строятся разбором payload.
Конструктор форм ядра (cms_forms)4 — сервис-вызовcatalog-showcase → ядрокарточка резолвит и рендерит форму по catalog-showcase.lead_form_slug, сабмит идёт напрямую в ядро
MediaService (ядро)4 — сервис-вызовcatalog-showcase → ядрогалерея/превью товара — конверсии медиатеки, без своей таблицы
RequestContext (ядро)4 — сервис-вызовядро → catalog-showcasecity_id/locale текущего запроса — измерение листинга/карточки и ключа кеша
cms/search (suggests)3 — provides-контракт search-provider + событие SearchIndexRebuiltcatalog-showcasesearchпри наличии — выдача/подсказки из индекса; событие ProductPublished — триггер индексации; без модуля — ILIKE-fallback ядра
cms/related-content (suggests)4 — сервис-вызов + события RelatedContentLinked/Unlinkedcatalog-showcaserelated-contentпохожие товары запрашиваются через сервис модуля; без него — fallback «та же категория»
cms/multicity (suggests)3 — provides-контракт city-contextcatalog-showcasemulticityвключён — city_id ограничивает видимость товара/категории и сегментирует кеш/экспорт; не установлен — city_id nullable, видно всем городам
cms/galleries (suggests)нет прямого канала (опционально)косвенноесли включён, карточка товара может показать привязанную коллекцию галереи как доп. портфолио — интеграция на стороне galleries (мягкая ссылка, без FK), catalog-showcase о galleries не обязан знать
Настройка catalog-showcase.kill_switchsettings-storecatalog-showcase → рендервключена — публичные роуты и блоки отдают fallback без 500

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

Очередь catalog-showcase: пересборка search_vector при сохранении товара (job, идемпотентна — повторный прогон безопасен), экспорт прайс-листа в XLSX (генерация файла для больших категорий — фоном, ссылка по готовности, не блокирует запрос), батчевый import-legacy (маппинг категорий/товаров по external_id, идемпотентен). Расписание — через ScheduleRegistrar ядра: периодическая сверка search_vector на случай пропущенного события сохранения (страховка, не основной путь).

Эксплуатация (ранбук). Метрики: отставание очереди catalog-showcase, доля товаров с price_on_request=true без price_note (кандидат на доработку карточки), число неотправленных экспортов прайс-листа. Алерты: очередь catalog-showcase отстаёт дольше порога, cms:catalog-showcase:doctor находит товары без активной категории.

СимптомЧто проверитьКоманда
Поиск/подсказки не находят новый товаробновлён ли search_vector; если включён cms/search — прошла ли индексацияcms:catalog-showcase:rebuild-search --json, health-чек search
Похожие товары не отображаютсявключён ли cms/related-content; если да — есть ли ручные связи/пересечение таксономийcms:catalog-showcase:doctor --json
Прайс-лист не выгружаетсяглубина очереди catalog-showcase, права на экспортcms:catalog-showcase:export-price --json --dry-run

Бэкап/рестор: в бэкап попадают cms_catalog_showcase_categories, cms_catalog_showcase_products и связанные медиа. search_vector — деривативное поле, после рестора БД пересчитывается cms:catalog-showcase:rebuild-search --json; если включён cms/search, требуется дополнительно cms:search:reindex (шаг ранбука самого cms/search) — рестор без этих шагов не считается завершённым.

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

Ожидаемые объёмы — до нескольких тысяч товаров на инсталляцию (профиль витрины корпоративного сайта, не highload; сверх этого объёма — апгрейд на commerce-catalog + commerce-facets, см. «Назначение»). Горячие пути: дерево категорий, листинг категории, карточка товара.

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

  • дерево категорий — 1 закешированный рекурсивный CTE-запрос на весь сайт;
  • листинг категории — ≤ 2 запроса (keyset-запрос по товарам категории + ->with('media'), без COUNT(*) для пагинации);
  • карточка товара — ≤ 2 запроса (товар с атрибутами + медиа, explicit select);
  • фасеты (GET .../facets) — 1 закешированный запрос на всю категорию разом (объём витрины позволяет прямой GROUP BY по whitelist-ключам attributes, в отличие от commerce-facets, где на миллионах строк это запрещено — здесь это осознанно допустимый путь для профиля «до нескольких тысяч SKU»).

Индексы: GIN на attributes (JSONB) и search_vector (tsvector); составной (category_id, is_active, sort) под листинг; уникальные slug на обеих таблицах; parent_id (FK+index) под обход дерева.

Теги кеша catalog-showcase:category:{id}, catalog-showcase:product:{id}. Инвалидация — событиями ProductPublished, ProductDeactivated, CategorySaved, а также RelatedContentLinked/Unlinked (кеш блока похожих) и изменением настроек группы (facet_keys, kill_switch). Ключ кеша листинга/карточки включает city_id/locale измерений RequestContext — счётчики и цена одного города не утекают в другой.

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

Границы входа: FormRequest-whitelist на CRUD категории/товара (админка); attributes — ключи только из зарегистрированного набора движка полей типа товара, не произвольный JSON от клиента; фильтры листинга/фасетов — whitelist facet_keys, попытка запросить незарегистрированный ключ отклоняется на границе (422), не пробрасывается в SQL как есть. Заявка «получить цену» не имеет собственной точки приёма — валидация и rate-limit целиком на стороне конструктора форм ядра (модуль не изобретает свою защиту там, где есть механизм ядра). Конкурентная правка категории/товара — lock_version, конфликт — 409 с человеческим сообщением.

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

ДействиеРедакторМенеджерАдминStudio
Просмотр каталога, листинга, карточек (публично)
CRUD товара (без удаления)
Удаление товара
CRUD категории
Массовые действия (bulk)
Экспорт прайс-листа
Удаление категории с товарами✅ (после переноса товаров)
Запуск import-legacy
catalog-showcase.kill_switch

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

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

Админ:

  • Пустая категория без товаров — подсказка «в категории нет товаров» с кнопкой «Добавить товар», не пустая таблица без объяснения;
  • Массовое действие: изменение цены/единицы/«по запросу»/сортировки/активности сразу для выбранных товаров, с подтверждением и явным числом затронутых строк;
  • Товар с price_on_request=true — поле цены в форме скрыто/задизейблено с подсказкой «Цена не показывается, карточка предложит оставить заявку», чтобы админ не пытался одновременно выставить цену и «по запросу»;
  • Конфликт lock_version — «Товар изменён другим пользователем, обновите страницу», не молчаливая перезапись;
  • Удаление категории с товарами — отдельное действие с явным подтверждением и предложением перенести товары в другую категорию.

Посетитель:

  • Товар «по запросу» показывает форму заявки/кнопку «узнать цену», никогда «0 ₽» или пустую строку;
  • Переключение состояния цены/наличия на карточке не создаёт CLS (зарезервированная область);
  • Фильтры фасетов и форма заявки доступны с клавиатуры, поля формы — с label;
  • Отправка формы заявки не теряет введённые данные при ошибке валидации (сохранение состояния формы — общее UX-требование стандарта).

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

  • Товар «по запросу» без цены → карточка показывает форму заявки, не «0 ₽» и не пустую строку; price_on_request=true имеет приоритет над значением price в БД (защита от рассинхрона «цену забыли обнулить, но включили „по запросу“»).
  • Отсутствие cms/search → выдача листинга/подсказок работает на ILIKE/tsvector ядра (search_vector), медленнее и грубее по релевантности, но корректно — не 500 и не пустая выдача.
  • Отсутствие cms/related-content → блок похожих товаров переключается на fallback «та же категория» (по category_id, исключая сам товар), без ошибки.
  • Отсутствие cms/multicitycity_id nullable, товар/категория с пустым городом видны всем городам; контрактный тест гоняется в обоих режимах (§4 стандарта).
  • Конкурентная правка товара/категории двумя админамиlock_version, конфликт — 409, не «последний победил» молча.
  • Удаление категории с товарами → запрещено напрямую; API отдаёт понятную ошибку с подсказкой перенести товары в другую категорию либо деактивировать их сначала.
  • Bulk-действие на большом списке → лимит bulk_action_max_items, превышение — 422 с кодом, не частичное молчаливое выполнение и не 500.
  • Огромное число уникальных значений одного фасетаmax_facet_values ограничивает вывод по count desc, «показать ещё» — отдельная подгрузка.
  • Пустая категория/пустая выдача фильтраdata: [], не 404/500; UI показывает «ничего не найдено» с кнопкой сброса фильтров.
  • Апгрейд на commerce-catalog → это отдельная явная миграция данных (маппинг категорий/товаров витрины в Product/Variant с implicit-вариантом на каждый товар без вариаций), выполняется осознанно при переходе клиента на полноценный e-commerce — не «заодно» при обычном обновлении модуля и не автоматическое переключение.
  • Рост каталога выше профиля витрины (десятки тысяч SKU) → catalog-showcase не деградирует незаметно: прямой GROUP BY для фасетов и полный обход дерева категорий рассчитаны на объём «до нескольких тысяч SKU» — cms:catalog-showcase:doctor сигнализирует о превышении ожидаемого объёма и рекомендует апгрейд-путь, вместо того чтобы тихо тормозить на боевом трафике.

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

Что взятьПуть
Модель каталога-витрины без обязательной корзиныuniversal (имя проекта, путь не выдан)

Legacy-импорт: cms:catalog-showcase:import-legacy --source=<профиль> — маппинг донорских таблиц каталога-витрины (категория/товар/цена/атрибуты) на cms_catalog_showcase_categories/cms_catalog_showcase_products по external_id; идемпотентен (повторный прогон обновляет, не дублирует), --dry-run — отчёт расхождений без записи. Прогон на копии боевых данных донора — часть приёмки модуля.

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

  • [ ] Контрактный тест: листинг категории использует cursorPaginate, OFFSET на глубине запрещён
  • [ ] Товар с price_on_request=true никогда не рендерит цену независимо от значения price в БД
  • [ ] При выключении модуля публичные роуты и блоки отдают fallback без 500
  • [ ] Дерево категорий строится одним закешированным рекурсивным CTE-запросом
  • [ ] Нет N+1 при листинге/карточке товара (->with('media'), explicit select)
  • [ ] Похожие товары используют cms/related-content, при его отсутствии — fallback «та же категория»
  • [ ] Поиск/подсказки работают через cms/search, при его отсутствии — ILIKE/tsvector-fallback без 500
  • [ ] Фасеты листинга ограничены whitelist facet_keys, незарегистрированный ключ — 422
  • [ ] Конкурентное редактирование товара/категории → 409 по lock_version
  • [ ] Bulk-действие свыше bulk_action_max_items — 422, не тихое обрезание списка
  • [ ] Удаление категории с товарами блокируется понятной ошибкой без каскадного удаления товаров
  • [ ] Заявка «получить цену» создаёт лид через LeadService, модуль не хранит данные заявки
  • [ ] Измерения city_id/locale работают и при отсутствии cms/multicity/мультиязычности (оба режима)
  • [ ] Legacy-импорт идемпотентен по external_id, --dry-run не пишет в БД
  • [ ] Права catalog-showcase.view/.manage разграничивают чтение, правку, bulk и удаление категории
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (листинг, карточка, фасеты, похожие, admin CRUD, bulk)
  • [ ] Тестовая БД только catalog-showcase_test; migrate:fresh/refresh/reset/db:wipe запрещены

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