Тема
ТЗ — Свойства/характеристики (cms/commerce-attributes)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: catalog (имя проекта, путь не выдан) Статус: ТЗ к разработке
Назначение и возможности
Типизированные свойства товаров и торговых предложений с наборами per-категория и флагами фильтруемости/сравнимости. Разделение важно для доменной модели: свойства товара (материал, бренд) — на Product, свойства-опции ТП (размер, цвет) — на Variant (см. /cms-v2/commerce-model).
- Типы свойств: строка, число, список (select/multiselect), булево, единицы измерения
- Наборы свойств per-категория (набор атрибутов зависит от категории товара)
- Разделение: свойства товара vs свойства-опции ТП (размер/цвет — на ТП, не на товаре)
- Флаг «фильтруемо» — участвует в
cms/commerce-facets - Флаг «сравнимо» — участвует в таблице сравнения
cms/commerce-wishlist - JSONB-хранение значений на товаре/ТП с GIN-индексом под фильтрацию
- Единицы измерения (кг, см, л) со справочником групп и конвертацией отображения
Зависимости и выключение
requires: cms/commerce-catalog · suggests: cms/commerce-facets, cms/commerce-wishlist
Поведение при выключении: карточка и листинг показывают только базовые поля товара без дополнительных характеристик — деградация витрины без поломки каталога.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_attributes | id, code, type, unit_id (nullable), is_filterable, is_comparable, lock_version | справочник свойств; type — PHP Enum (string|number|select|multiselect|bool); тип неизменяем после первого записанного значения (см. «Крайние случаи»); lock_version — optimistic lock |
commerce_measurement_units | id, code, title, unit_group, ratio_to_base | справочник единиц измерения; unit_group — PHP Enum (weight|length|volume|area|…); конвертация отображения — по ratio_to_base только внутри одной группы |
commerce_attribute_sets | id, category_id, attribute_id, sort, lock_version | набор свойств per-категория; FK category_id/attribute_id — constrained()->index(); lock_version — optimistic lock на конкурентную правку набора |
commerce_attribute_options | id, attribute_id, value, sort | справочник значений для type ∈ {select,multiselect}; FK attribute_id — constrained()->index() |
commerce_product_attribute_values | product_id, attribute_id, value (jsonb) | значения свойств товара, GIN по value; для select/multiselect — ссылка на commerce_attribute_options.id внутри value |
commerce_variant_attribute_values | variant_id, attribute_id, value (jsonb) | значения свойств-опций ТП, GIN по value |
ПДн-паспорт. Модуль не хранит персональных данных — атрибуты и их значения описывают товар/ТП, не субъекта; участие в «выгрузить/забыть по субъекту» не требуется.
Входные и выходные данные
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
Форма атрибута (POST/PUT /api/v1/admin/commerce-attributes) | code, type, unit_id?, is_filterable, is_comparable, options[]? | FormRequest whitelist; type неизменяем при наличии значений (422 attribute_type_locked); options[] допустимы только при type ∈ {select,multiselect}; unit_id допустим только при type = number |
Набор атрибутов категории (POST/PUT .../commerce-attributes/sets) | category_id, attribute_id, sort | FormRequest whitelist; лимит max_per_category; конфликт правки — lock_version |
Значение атрибута товара/ТП (пишется сервисом cms/commerce-catalog при сохранении товара/ТП, отдельного публичного эндпоинта записи нет) | attribute_id, value | валидация по type атрибута: string — экранирование/санитизация без сырого HTML (см. «Безопасность»), number — числовой формат + единица группы, select/multiselect — существующий commerce_attribute_options.id, bool — true/false |
Импорт значений (cms:commerce-attributes:import-legacy --source=<профиль>) | построчный маппинг «атрибут → товар/ТП» из донора | схема профиля; батчевый upsert; --dry-run с отчётом расхождений |
Слушаемых событий нет — модуль не подписывается на чужую шину (см. «События и обмен»). Всё, что не перечислено выше, отвергается (whitelist-принцип §11 стандарта): неизвестные поля атрибута, значение не по type, option_id вне справочника атрибута.
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
Публичный набор атрибутов категории (GET /api/v1/catalog/categories/{slug}/attributes) | атрибуты набора + справочник единиц | REST-конверт {data:[{attribute, options?, unit}], meta} |
cms/commerce-facets | флаг is_filterable, значения фильтруемых атрибутов | события AttributeFilterableChanged, AttributeDeleted, AttributeValueChanged |
cms/commerce-wishlist | флаг is_comparable, значения сравнимых атрибутов | событие AttributeDeleted (убрать колонку), прямое чтение значений при построении таблицы сравнения |
| Filament (админка) | справочник атрибутов, конструктор набора per-категория, справочник единиц | admin-view |
cms/commerce-catalog | подтверждение записи значения (успех/ошибка валидации по типу) | ответ сервис-вызова |
Настройки (группа commerce-attributes)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-attributes.max_per_category | int | 50 | нет | Лимит атрибутов в наборе категории |
commerce-attributes.cache_ttl | int | 3600 | да | TTL кеша справочника атрибутов и наборов |
commerce-attributes.max_options_per_attribute | int | 200 | нет | Лимит значений справочника для select/multiselect |
commerce-attributes.max_string_value_length | int | 500 | нет | Лимит длины строкового значения атрибута |
commerce-attributes.default_unit_system | string | metric | да | Система единиц отображения (metric/imperial) |
commerce-attributes.facet_sync_enabled | bool | true | нет | Kill-switch: аварийная остановка событийной синхронизации с cms/commerce-facets без выключения модуля |
Достижение лимитов (max_per_category, max_options_per_attribute, max_string_value_length) отдаёт понятную ошибку 422 с кодом, не тихое обрезание списка/строки.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/catalog/categories/{slug}/attributes | public | Набор свойств категории для карточки/фильтра |
| GET | /api/v1/admin/commerce-attributes | admin (commerce-attributes.view) | Справочник свойств |
| POST | /api/v1/admin/commerce-attributes | admin (commerce-attributes.manage) | Создание/правка свойства и набора |
| DELETE | /api/v1/admin/commerce-attributes/{id} | admin (commerce-attributes.manage) | Удаление атрибута из справочника (эмитит AttributeDeleted) |
| GET | /api/v1/admin/commerce-attributes/units | admin (commerce-attributes.view) | Справочник единиц измерения и групп |
Компоненты
Filament: справочник свойств с типами и единицами измерения, конструктор набора per-категория (drag-n-drop сортировки), редактор справочника единиц измерения. Команды: cms:commerce-attributes:sync-facets --json, cms:commerce-attributes:doctor --json.
Демо-контент: сидер демо-категории с полным набором типов атрибутов (строка, число с единицей, select, multiselect, bool) — галерея /_gallery показывает карточку с характеристиками без ручного ввода. Фронтенд-бюджет: таблица характеристик на карточке рендерится без дополнительного JS-чанка (SSR/Blade-фрагмент), фасетный виджет — на стороне cms/commerce-facets (lazy-load там же); таблица характеристик доступна для скринридеров (семантическая <table> или <dl>, не div-сетка).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
AttributeValueChanged | изменено значение свойства товара/ТП | entity_type, entity_id, attribute_id |
AttributeSetChanged | изменён набор свойств категории | category_id |
AttributeFilterableChanged | изменён флаг is_filterable атрибута | attribute_id, is_filterable |
AttributeDeleted | атрибут удалён из справочника | attribute_id, code |
Слушает: — (модуль не подписан на чужие события).
Provides-контракты: не предоставляет. FilterBus: не использует напрямую; AttributeValueChanged, AttributeFilterableChanged и AttributeDeleted триггерят пересчёт/удаление фасетов cms/commerce-facets событийно, без прямого вызова.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-catalog | прямой сервис-вызов по requires | attributes → cms/commerce-catalog | проверка существования товара/ТП перед записью значения через публичный сервис каталога |
cms/commerce-facets | событие (шина) | attributes → cms/commerce-facets | AttributeValueChanged/AttributeFilterableChanged/AttributeDeleted триггерят пересчёт или удаление фасетной конфигурации |
cms/commerce-wishlist | событие (шина) | attributes → cms/commerce-wishlist | AttributeDeleted убирает колонку сравнения; таблица сравнения читает значения is_comparable-атрибутов напрямую |
cms/search (косвенно, через cms/commerce-catalog) | событие (шина) | attributes → cms/search | значения фильтруемых атрибутов участвуют в поисковом индексе через переиндексацию каталога, не напрямую |
| Filament (админка) | внутренний рендер | — | конструктор набора атрибутов per-категория, справочник единиц измерения |
Фоновая работа
Значения атрибутов пишутся синхронно в рамках сохранения товара/ТП в cms/commerce-catalog. Фоновая работа — только у синхронизации и импорта:
sync-facets— джоба по событиямAttributeFilterableChanged/AttributeDeleted/AttributeValueChanged, не синхронно в запросе (не блокирует сохранение атрибута); пропускается целиком приfacet_sync_enabled=false;import-legacy— батчевый upsert значений через очередьcommerce-attributes, идемпотентен поexternal_idна связке «атрибут + сущность».
Эксплуатация (ранбук). Метрики: темп записи значений атрибутов, отставание очереди sync-facets, число значений, ссылающихся на несуществующий commerce_attribute_options.id (orphaned reference). Алерты: отставание sync-facets выше порога, max_options_per_attribute близок к исчерпанию у активно используемого атрибута.
| Симптом | Что проверить | Команда |
|---|---|---|
| Фасет не обновился после смены атрибута | очередь commerce-attributes отстаёт или facet_sync_enabled=false | cms:commerce-attributes:sync-facets --json |
| Значения атрибута расходятся со справочником опций | orphaned-ссылки на удалённые commerce_attribute_options | cms:commerce-attributes:doctor --json |
| Импорт значений завис/частично применился | построчные ошибки джобы импорта | cms:commerce-attributes:import-legacy --source=<профиль> --dry-run |
Бэкап/рестор: таблицы модуля (commerce_attributes, commerce_measurement_units, commerce_attribute_sets, commerce_attribute_options, значения) — целиком в бэкап. После рестора ссылки значений на commerce_attribute_options проверяются cms:commerce-attributes:doctor --json; денормализованные фасетные агрегации cms/commerce-facets пересчитываются отдельно на стороне того модуля, не восстанавливаются этим бэкапом.
Производительность и кеш
Ожидаемые объёмы: миллионы строк в commerce_product_attribute_values/ commerce_variant_attribute_values (по числу атрибутов на товар/ТП — см. commerce-model, «Highload-требования»). Горячий путь — сборка набора атрибутов категории и значений текущего товара для карточки/фильтра, бюджет ≤ 2 запроса (кешированный набор категории + значения товара/ТП одним select). Настройки читаются из кеша группы — 0 запросов на горячем пути.
Индексы: GIN по value в обеих value-таблицах (обязателен под фильтрацию JSONB); уникальный составной (product_id, attribute_id) и (variant_id, attribute_id) — не дублируем значение атрибута на сущности; индекс (category_id, attribute_id) в commerce_attribute_sets.
Тег commerce-attributes:category:{id} на набор атрибутов категории. Инвалидация — событием AttributeSetChanged, TTL справочника — commerce-attributes.cache_ttl. Массовая правка/импорт значений — батчевая инвалидация по категории чанками (принцип import_batch_sizecms/commerce-catalog), не поштучно на товар.
Безопасность
Вход — FormRequest-whitelist на создание/правку свойства и набора; значения JSONB валидируются по типу атрибута до записи, а не постфактум. Строковые значения по умолчанию — plain text: любой HTML экранируется на вывод (); если инсталляция допускает форматированный текст в атрибуте (например, описание материала с разметкой), запись обязана пройти двойной барьер санитизации rich-text ядра (§11 стандарта: санитайзер на входе + автоэскейп на выходе) — сырой непровалидированный HTML в значении атрибута запрещён категорически (XSS-риск при выводе на публичной карточке). Rate-limit — на публичный GET набора атрибутов категории. Конкурентная правка набора атрибутов категории — lock_version (optimistic lock), конфликт — 409 version_conflict.
ПДн-паспорт — см. «Модель данных» (модуль не хранит персональных данных).
Матрица ролей:
| Действие | Редактор | Менеджер (commerce-attributes.manage) | Админ | studio |
|---|---|---|---|---|
| Просмотр справочника атрибутов и наборов | ✅ | ✅ | ✅ | ✅ |
| Создание/правка атрибута (без смены типа при значениях) | — | ✅ | ✅ | ✅ |
| Смена типа атрибута до появления значений | — | ✅ | ✅ | ✅ |
| Удаление атрибута из справочника | — | — | ✅ | ✅ |
| Правка набора атрибутов категории | — | ✅ | ✅ | ✅ |
Запуск import-legacy | — | — | ✅ | ✅ |
Отключение kill-switch facet_sync_enabled | — | — | — | ✅ |
Права: commerce-attributes.view, commerce-attributes.manage.
UX-требования
Для админа: пустая категория без набора атрибутов — подсказка «настроить характеристики», не пустая таблица; конструктор набора — drag-n-drop с подтверждением при удалении атрибута из набора категории (данные значений на товарах не теряются, просто перестают показываться); попытка сменить тип атрибута при существующих значениях — ошибка на человеческом языке («нельзя изменить тип: у атрибута N уже есть M значений — создайте новый атрибут»), не код исключения; удаление атрибута из справочника — отдельное необратимо звучащее действие с явным подтверждением («удалить атрибут и все его значения на N товарах?»).
Для покупателя/посетителя: таблица характеристик на карточке рендерится без задержки (бюджет запросов выше — часть общего рендера карточки, не отдельный AJAX); единицы измерения показываются в системе, привычной аудитории (default_unit_system); таблица характеристик доступна для скринридеров.
Крайние случаи и типовые баги
- Смена типа атрибута при существующих значениях → запрещена (422
attribute_type_locked); чтобы сменить тип, нужно создать новый атрибут и явно смигрировать/перезаполнить значения — тихого автопереноса между несовместимыми типами (строка → число) нет. - Атрибут удалён или помечён нефильтруемым →
AttributeDeleted/AttributeFilterableChangedуходят на шину;cms/commerce-facetsреагирует событийно (пересчитывает/убирает конфиг и агрегации), без polling и без прямого вызова из этого модуля. - Единицы измерения разных групп → атрибуту с уже выбранной
unit_idиз группыweightнельзя присвоить единицу из группыlength— конвертацияratio_to_baseимеет смысл только внутри однойunit_group, попытка отклоняется валидацией. - HTML/произвольный текст в строковом значении → по умолчанию plain text с автоэскейпом на выходе; форматированный текст — только через двойной барьер санитизации rich-text ядра (§11); попытка ввести неразрешённую разметку — понятная ошибка, не тихое обрезание тегов.
- Массовый импорт значений → батчевый upsert, инвалидация кеша набора — по категориям чанками, не по каждому изменённому значению (лавина инвалидации на миллион строк недопустима).
- Конкурентная правка набора атрибутов категории двумя админами →
lock_version, конфликт — 409version_conflictс понятным сообщением, не «последний победил» молча. - Значение ссылается на удалённую опцию справочника (
select/multiselect) → удалениеcommerce_attribute_options, на которую есть ссылки в значениях, запрещено напрямую — сначала нужно очистить/перенести значения (аналогично запрету удаления категории с товарами вcms/commerce-catalog). cms/commerce-facetsвыключен →AttributeFilterableChanged/AttributeDeletedпубликуются как обычно (шина не проверяет наличие подписчиков, fire-and-forget) — это не ошибка, просто событие временно без потребителя.- Достижение лимитов (
max_per_category,max_options_per_attribute,max_string_value_length) → понятная ошибка 422 с кодом при попытке превысить, не тихое обрезание списка/строки. - ⚠️ Противоречие:
commerce-model.mdдекларирует опции ТП (цвет/размер, генерирующие вариант) через движок полей на типе товара, а этот модуль хранит «свойства-опции ТП» вcommerce_variant_attribute_valuesкак отдельный механизм — не зафиксировано, дублируют ли они друг друга (одно и то же значение «цвет: красный» одновременно вариант-генерирующая опция и фильтруемый/сравнимый атрибут) или это разные сущности с похожими именами. Разрешение:commerce_variant_options(опции движка полей, порождают ТП) иcommerce_variant_attribute_values(атрибуты для фасетов/сравнения) — разные по назначению таблицы; атрибут, дублирующий опцию-генератор (например «цвет»), создаётся с полем, ссылающимся на тот жеoption_idдвижка полей, — одно введённое значение обслуживает и генерацию варианта, и фильтр/сравнение, без повторного ввода администратором.
Донорский код
| Что взять | Путь |
|---|---|
| Модель типизированных свойств и наборов per-категория | catalog (имя проекта, путь не выдан) |
Миграция legacy: cms:commerce-attributes:import-legacy --source=<профиль> (донор catalog) — маппинг старых характеристик товаров на новую схему атрибутов/наборов; идемпотентна по external_id на связке «атрибут + сущность», --dry-run с отчётом расхождений (создано/обновлено/пропущено и почему).
Тесты и приёмка
- [ ] Контрактный тест: свойства-опции ТП (размер/цвет) хранятся отдельно от свойств товара
- [ ] Смена типа атрибута при существующих значениях отклоняется с кодом
attribute_type_locked - [ ]
AttributeFilterableChanged/AttributeDeletedтриггерят пересчёт/удаление фасетовcms/commerce-facetsбез прямой связки в коде - [ ] Единицы измерения конвертируются только внутри одной
unit_group, кросс-групповая единица отклоняется - [ ] Строковое значение с сырым HTML не проходит без двойного барьера санитизации (contract-тест на XSS-пейлоад)
- [ ] Удаление опции справочника, на которую есть ссылки в значениях, блокируется
- [ ] Конкурентная правка набора атрибутов категории отдаёт 409
version_conflict - [ ] GIN-индекс на
valueиспользуется при фильтрации по JSONB (EXPLAIN подтверждает Index Scan) - [ ] Набор атрибутов категории кешируется, инвалидация — по тегу категории, батчево при импорте
- [ ] При выключении модуля карточка/листинг не падают, показывают базовые поля без атрибутов
- [ ] Достижение лимитов (
max_per_category,max_options_per_attribute,max_string_value_length) отдаёт 422 с понятным кодом - [ ] Права
commerce-attributes.view/.manageи матрица ролей разграничивают чтение, правку и удаление справочника - [ ] Контрактный набор
cms-testingи testbench-изоляция зелёные, feature-тест на каждый роут - [ ] Тестовая БД только
commerce-attributes_test;migrate:fresh/refresh/reset,db:wipeзапрещены