# Каталог

Товары, характеристики, цены, склады и фасетный поиск. Самый нагруженный узел
системы: гейт этапа — сто тысяч товаров и фасетная страница за p95 < 150 мс.

## Что заменяется

Легаси-таблица товаров имела 126 колонок, и среди них `price2..price11`,
`stock2..stock10`, `var1..var15` — фиксированные слоты. Двенадцатый тип цены,
одиннадцатый склад или шестнадцатую характеристику добавить было нельзя,
а незанятые слоты стояли пустыми в каждой из сотен тысяч строк.

Здесь в карточке остаётся только то, что принадлежит товару всегда. Всё
остальное — свои таблицы, и их размер ограничен только данными.

## Схема

```
catalog_products      товар: слаг, название, артикул, GUID из 1С, тексты,
                      витрина списка (price_min, in_stock, stock_total)
product_sections      товар в разделах: связь, приоритет, публикация
product_variants      варианты: цвет, размер, объём — со своей ценой и остатком
product_relations     связи: аналоги, аксессуары, комплект
catalog_brands        бренды

catalog_attributes        характеристики: тип, GUID из 1С, фильтруется ли
catalog_attribute_options варианты списочной характеристики
product_attribute_values  значения: option_id | value_number | value_string
catalog_facets            витрина: сколько товаров раздела за вариантом

price_types           типы цен: розница, опт, «старая цена»
product_prices        цена: тип × город × группа × пользователь × количество
user_groups           группы пользователей (полноценно — в M6)
warehouses            склады, при желании привязанные к городу
product_stocks        остатки
```

Подробности колонок — [database.md](database.md).

## Цены

Цена адресуется четырьмя измерениями, и более частная перекрывает более общую:

```
пользователь → группа → город → общая
```

Это то же правило, по которому разрешаются настройки. В легаси разрешения
не было вовсе: цена лежала в колонке `price1..price11`, и «цена для этого
клиента в этом городе» собиралась условиями прямо в шаблоне.

Дополнительно цена умеет:

- **порог количества** — «от 10 штук по 90»; при равной частности выигрывает
  цена с большим порогом;
- **срок действия** — `starts_at` / `ends_at`, просроченная не применяется;
- **принадлежать варианту**, а не товару целиком.

```php
app(PriceResolver::class)->forProduct($productId, groupIds: [$groupId], userId: $userId);
app(PriceResolver::class)->forProducts($productIds);   // цены всей страницы одним запросом

app(PriceResolver::class)->resolve($productId);        // цена + база + процент скидки
app(PriceResolver::class)->resolveMany($productIds);   // то же для всей страницы
```

Второй метод — не удобство, а требование: список из тридцати карточек не должен
превращаться в тридцать запросов за ценой.

`resolve()` возвращает `ResolvedPrice` — итог, цену до скидки и процент. Одного
числа витрине мало: ей нужно зачеркнуть старую цену. Но считает их один и тот же
код, и это главное — разойдись «сколько стоит» и «сколько стоило», покупателю
однажды списали бы деньги по зачёркнутой цене.

### Товар без строк в `product_prices`

Импорт из 1С не заводит цены построчно: он пишет цену прямо в витринную колонку
`price_min` одним запросом на весь пакет. Поэтому у резолвера есть запасной путь
— цена берётся из витрины товара, если строк цен у него нет **вовсе**. Без этого
сто тысяч импортированных товаров выводились бы как «цена по запросу».

Путь включается только при полном отсутствии цен. Если цены заведены, но ни одна
не подошла — это осознанное «для этой группы (города, количества) цены нет»,
и подставлять вместо неё витринную значит показать чужую. Оптовику, у которого
оптовой цены нет, розничная тоже не подставляется.

## Скидка на товар

Скидка — четыре поля самого товара: процент, тип цены, начало и конец срока.

- **Только процентом.** Готовой «цены со скидкой» нет: цены живут в отдельной
  таблице и зависят от города, группы и количества, и одно число не сказало бы,
  к какой из них оно относится.
- **Только на один тип цены.** Пустой тип означает «тип по умолчанию» — тот,
  который видит обычный посетитель. Скидка поверх оптовой цены — это скидка
  на уже сниженную, и владелец такого не имеет в виду.
- **Срок необязателен**; последний день входит в срок целиком (`23:59:59`),
  иначе «до 30 сентября» кончалось бы на сутки раньше.
- **Ноль процентов — это «скидки нет»**, и остальные поля тогда чистятся:
  оставленный срок однажды включил бы акцию сам.

⚠️ **В `price_min` скидка НЕ вкладывается.** У товара из 1С нет ни одной строки
в `product_prices`, и базовую цену после этого негде было бы взять: смена
процента считала бы скидку от уже скидочной цены, а выключение оставило бы
товар навсегда дешевле. Плата за решение — фильтр «до 1000» и сортировка
по цене работают по цене **до** скидки.

Скидка применяется на чтении, поэтому начало и конец срока не требуют никакого
пересчёта по расписанию: акция включается и выключается сама.

## Товары из подразделов

Настройка раздела «выводить объекты из подразделов» (см.
[structure.md](structure.md#вывод-объектов-из-подразделов)) у каталога сделана
**настоящими строками** в `product_sections` с пометкой `is_cascade`, а не
условием `section_id IN (…)` в запросе.

Замер на ста тысячах товаров (та же машина, что и в разделе
«Производительность»):

| Выборка первой страницы | Время |
|---|---|
| один раздел (как сейчас) | 7 мс |
| `section_id IN (…)` по двадцати разделам | 137 мс |
| то же с `DISTINCT` (товар в двух подразделах) | 772 мс |
| каскадный раздел на связях | 6 мс |

Причина ровно та, ради которой писался планировщик: у одного раздела порядок
приходит из индекса `(site_id, section_id, sort_order, product_id)`, а по списку
разделов его приходится досортировывать. И второе, не менее важное: **витрина
фасетов строится по тем же связям** — без строк фильтры в корневой категории
показывали бы нули.

Плата — связи нужно поддерживать. Три точки:

| Когда | Кто | Что делает |
|---|---|---|
| товар сохранён | `ProductObserver` | связи предков одного товара |
| пакет из 1С влит | `ImportPipeline` | пересчёт каскадных разделов сайта |
| ветка переехала, настройку переключили, раздел удалили | слушатель `SectionTreeChanged` | то же |

Ремонт руками — `php artisan catalog:rebuild-cascade {сайт}`, как
`search:reindex` для индекса.

Полный пересчёт — два запроса: `INSERT … SELECT … ON DUPLICATE KEY UPDATE`
и `DELETE … LEFT JOIN` для товаров, ушедших из ветки. На 200 000 связей это
19,6 с и 2,1 с — поэтому событие и поднимается после транзакции, а не внутри.

⚠️ **Товару, лежащему в самом разделе, каскадная строка не заводится**: обычная
связь у него уже есть, и вторая вывела бы его в списке дважды.

## Фильтры

Значения характеристик лежат по строке на пару «товар + характеристика»,
поэтому «цвет белый И материал дуб» нельзя выразить одним `WHERE`.

- **На каждую характеристику — `EXISTS`**, а не `JOIN`. Пять выбранных фильтров
  дали бы пять соединений самой большой таблицы каталога; `EXISTS` идёт
  по покрывающему индексу `(site_id, attribute_id, option_id, product_id)`
  и не читает таблицу вовсе.
- **Галочки разных фильтров пересекаются, галочки одного складываются.**
  «Белый или чёрный, но обязательно шириной до 700».
- **Ввод приводится к числам.** Фильтр не должен становиться местом, через
  которое в запрос попадает произвольная строка.

## Фасеты

Счётчики берутся из витрины `catalog_facets`, а не считаются на каждый показ:
`COUNT` с группировкой по ста тысячам товаров на каждой странице — ровно
та нагрузка, из-за которой в легаси фильтры выключали на крупных сайтах.

Цена решения названа честно: витрина отстаёт от данных до пересчёта. Поэтому
счётчик показывается как ориентир, а **список товаров всегда строится живым
запросом** — расхождение видно как неточное число рядом с галочкой, а не как
пропавший товар.

```php
app(FacetEngine::class)->rebuildSection($sectionId);   // импорт и запись товара
app(FacetEngine::class)->forSection($sectionId);       // показ фильтров
```

## Адреса фильтров

`/katalog/filter/cvet-belyy/material-dub/` — потому что «белые столы» это
посадочная страница: на неё ссылаются и её индексируют, а `?attr[3][]=17`
не годится ни как ссылка, ни как заголовок в выдаче.

Механизм в ядре общий и о каталоге не знает: резолвер отрезает сегменты
с конца, ищет ближайший раздел и спрашивает его компонент через
`Component::acceptsPathTail()`, годится ли остаток.

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

## Группы покупателей

Группы существуют ради цен: оптовик и розничный покупатель видят разные суммы.
Поэтому и справочник (`user_groups`), и привязка (`user_group_members`) живут
в каталоге, а не в ядре. Групп у покупателя может быть несколько — «оптовик»
и «сотрудник» не взаимоисключающие, и берётся лучшая из подходящих цен.

**Резолвер цены сам знает, кому считает.** `PriceResolver` спрашивает
`CustomerGroups` о текущем покупателе, если группы не переданы явно. Так цена
персонализируется везде — в списке, в карточке, в корзине, при оформлении, —
и забыть передать группы больше нельзя: именно так и появлялись места, где
оптовик видел розничную цену. Пустой массив по-прежнему означает «явно гость».

Кто в какой группе — экран `Магазин → Покупатели`; сами группы — вкладка
в справочниках каталога.

## Как покупается товар

Каталог — витрина, а не магазин, и он не знает про модуль `shop`. Способ
покупки задаётся **у товара** (`cart`, `request`, `none`), пустое значение
означает умолчание сайта: грузовик в корзину не кладут, а запчасть к нему
кладут, и то и другое бывает на одном сайте.

Кнопку рисует не каталог, а тот, кто умеет продавать: каталог спрашивает
реестр `PurchaseActions`, магазин регистрируется в нём на режим `cart`.
Магазин выключен — товар выводится без кнопки, и каталог остаётся рабочим.
Подробности — [shop.md](shop.md).

Одностороннюю зависимость держит Deptrac: каталог вынесен в отдельный слой,
которому запрещено видеть любые другие модули.

## Карточка товара

Карточка собирается из блоков — тем же механизмом, что и страница сайта.
Устройство, наследование шаблона разделом и вкладки описаны
в [layout.md](layout.md#карточка-объекта-из-блоков); каталог даёт свои части:

| Ключ | Что выводит |
|---|---|
| `product_codes` | артикул |
| `product_price` | цена со скидкой и зачёркнутой базой |
| `product_stock` | наличие и остатки по складам (склады — настройкой) |
| `product_buy` | кнопку из реестра `PurchaseActions` |
| `product_attributes` | характеристики таблицей, с ограничением по строкам |
| `product_similar` | соседей по разделу, шаблоном плитки списка |

Название, текст и галерею даёт ядро: они есть у объекта любого компонента.

**Цена и кнопка покупки помечены персональными** (`PersonalBlockContent`):
цена разрешается по цепочке покупатель → группа → город → общая, и TTL на таком
блоке не действует — иначе цена первого зашедшего оптовика уехала бы всем.

**«Похожие» — это соседи по разделу, а не «с этим покупают»:** второе требует
истории заказов, которой у нового магазина нет, и первые полгода она врёт.
Сам товар из списка исключён — ссылка на страницу, где посетитель уже стоит,
бесполезна.

Шаблонов карточки у сайта нет — товар выводится Blade-шаблоном `catalog::detail`,
как и раньше.

## Админка

- **Справочники** (`Магазин → Справочники каталога`): характеристики
  с вариантами, типы цен, склады. Один экран на три справочника — это одна
  работа, настройка каталога перед наполнением.
- **Карточка товара** (`/admin/catalog/products/{id}`): цены, остатки,
  характеристики, варианты. Основные поля товара при этом правит общий экран
  содержимого, как у любого другого компонента.

Сохранение карточки пересчитывает витрину списка (`price_min`, `in_stock`)
и витрину фасетов: иначе сортировка по цене и счётчики фильтров разойдутся
с содержимым раздела.

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

Гейт этапа проверяется командой:

```bash
php artisan catalog:benchmark --products=100000 --iterations=30
```

Результат на рабочей машине (MySQL 8.4, `innodb_buffer_pool_size` 128 МБ):

| Запрос | p50 | p95 |
|---|---|---|
| страница раздела без фильтра | 9 мс | 13 мс |
| один фильтр | 10 мс | 106 мс |
| три фильтра | 67 мс | 124 мс |
| фасеты | 2 мс | 3 мс |
| **фасетная страница целиком** | | **127 мс** |

Путь от 730 мс к 127 мс состоял из пяти шагов, и каждый стоит помнить.

**1. Подсказка оптимизатору — разница в двести раз.** Без `JOIN_ORDER(ps,
catalog_products)` MySQL ведёт выборку от товаров, сортирует все товары сайта
и только потом берёт двадцать четыре: 1200 мс. С подсказкой ведущей становится
`product_sections`, порядок приходит из индекса, товары читаются по первичному
ключу: 7 мс. Оптимизатор ошибается предсказуемо — он оценивает выборку
по разделу как половину таблицы.

**2. Флаг публикации продублирован в связь.** Пока он был только в товаре,
`COUNT` для пагинации ходил в таблицу товаров сорок семь тысяч раз — 417 мс.
С флагом в `product_sections` подсчёт не выходит за пределы индекса.
Синхронизацию делает наблюдатель товара.

**3. Число товаров кэшируется по ключу фильтра.** Точный `COUNT` по ста тысячам
строк стоит около восьмидесяти миллисекунд, и дешевле его не сделать — это
чтение диапазона индекса. Комбинации галочек повторяются, поэтому второй
посетитель с тем же фильтром считать заново не должен.

**4. План разворачивается от таблицы значений, когда фильтр селективен.**
Страница с одним фильтром шла от связи с разделом и проверяла `EXISTS` на всех
ста тысячах строк ради двенадцати тысяч подходящих — 334 мс. Ведущим
становится фильтр, оставляющий меньше всего товаров; выбирается он бесплатно,
по витрине фасетов, которая эти числа уже знает.

В ведущие годится только фильтр с одним выбранным вариантом: при нескольких
товар с двумя подходящими значениями попал бы в выборку дважды, и потребовался
бы `DISTINCT` — а он стоит временной таблицы.

**5. Сортировка по приоритету раздела — только на узких выборках.** В плане
от таблицы значений порядок раздела приходится досортировывать: 152 мс против
3 мс на двадцати шести тысячах товаров. Поэтому широкие выборки идут в порядке
индекса значений, а приоритет раздела сохраняется там, где сортировка дёшева
(до двух тысяч товаров).

## Чего в каталоге ещё нет

| Что | Когда |
|---|---|
| Пересчёт витрины фасетов очередью | вместе с импортом, M7 |
| Полнотекстовый поиск вместо LIKE | M7, за интерфейсом `SearchEngine` |
| Связи товара (аналоги, аксессуары) в интерфейсе | таблица есть, экрана нет |
| Цены и остатки у вариантов в интерфейсе | сейчас правятся только у товара |
| Многоосевые варианты товара | отложено сознательно |
