Тема
ТЗ — Витрина товаров (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_categories | id, parent_id (nullable), slug, title, declensions (json), icon (nullable), description (nullable), sort, is_active, lock_version, city_id (nullable), locale (nullable) | дерево категорий, ЧПУ |
cms_catalog_showcase_products | id, 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_id — constrained()->index() (самоссылка, обход дерева — один закешированный рекурсивный CTE-запрос, не N рекурсивных вызовов ORM); category_id — constrained()->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_version | FormRequest-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_version | FormRequest-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, cursor | FormRequest сверяет 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) | товары по категории или явному списку id | props блока, версия _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_page | int | 24 | да | Размер страницы листинга |
catalog-showcase.facet_keys | array | [] | да | Whitelist ключей attributes, доступных как фасеты фильтра (декларативно, без хардкода) |
catalog-showcase.default_currency | string | RUB | нет | Код валюты по умолчанию для новых товаров |
catalog-showcase.show_price_without_auth | bool | true | да | Показывать цену гостю; false — цена видна только авторизованным (B2B-сценарий), гость видит «узнать цену» |
catalog-showcase.similar_strategy | string | related-content | нет | Источник похожих товаров (related-content/same-category) |
catalog-showcase.lead_form_slug | string | catalog-showcase-request | нет | Slug формы ядра для заявки «получить цену» на карточке |
catalog-showcase.bulk_action_max_items | int | 200 | нет | Лимит товаров в одном массовом действии админки |
catalog-showcase.max_facet_values | int | 20 | нет | Лимит значений на один фасет («показать ещё» сверх лимита) |
catalog-showcase.kill_switch | bool | false | да | Аварийное отключение витрины: публичные роуты отдают fallback без выключения модуля |
Достижение bulk_action_max_items — понятная ошибка 422 с кодом, не 500 и не тихое обрезание списка.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/catalog-showcase/categories | public | Дерево категорий |
| GET | /api/v1/catalog-showcase/categories/{slug} | public | Категория + её товары (листинг) |
| GET | /api/v1/catalog-showcase/categories/{slug}/facets | public | Лёгкие фасетные счётчики (whitelist facet_keys) |
| GET | /api/v1/catalog-showcase/products | public | Листинг с filter[…]/sort по whitelist, keyset-пагинация |
| GET | /api/v1/catalog-showcase/products/{slug} | public | Карточка товара |
| GET | /api/v1/catalog-showcase/products/{slug}/similar | public | Похожие товары |
| 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/bulk | admin (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=false — Offer с price/priceCurrency; при true — Offer без цены и с признаком «по запросу» (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 | заявка «получить цену» успешно создана через LeadService | product_id, lead_id |
Слушает: RelatedContentLinked/RelatedContentUnlinked (cms/related-content, инвалидация кеша похожих), SearchIndexRebuilt (cms/search, признак, что подсказки можно брать из индекса, а не ILIKE-fallback).
Provides-контрактов не реализует. FilterBus: не использует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
LeadService (ядро) | 4 — сервис-вызов по requires | catalog-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-showcase | city_id/locale текущего запроса — измерение листинга/карточки и ключа кеша |
cms/search (suggests) | 3 — provides-контракт search-provider + событие SearchIndexRebuilt | catalog-showcase ↔ search | при наличии — выдача/подсказки из индекса; событие ProductPublished — триггер индексации; без модуля — ILIKE-fallback ядра |
cms/related-content (suggests) | 4 — сервис-вызов + события RelatedContentLinked/Unlinked | catalog-showcase ↔ related-content | похожие товары запрашиваются через сервис модуля; без него — fallback «та же категория» |
cms/multicity (suggests) | 3 — provides-контракт city-context | catalog-showcase ← multicity | включён — city_id ограничивает видимость товара/категории и сегментирует кеш/экспорт; не установлен — city_id nullable, видно всем городам |
cms/galleries (suggests) | нет прямого канала (опционально) | косвенно | если включён, карточка товара может показать привязанную коллекцию галереи как доп. портфолио — интеграция на стороне galleries (мягкая ссылка, без FK), catalog-showcase о galleries не обязан знать |
Настройка catalog-showcase.kill_switch | settings-store | catalog-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/multicity→city_idnullable, товар/категория с пустым городом видны всем городам; контрактный тест гоняется в обоих режимах (§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'), explicitselect) - [ ] Похожие товары используют
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запрещены