# Управление подтоварами через админку

## Контекст

«Подтовары» — это записи в таблице `subProduct`, привязанные к продукту по `productId`. На публичной стороне они отображаются как «комплектация» товара (`views/product/_complectation.php` и аналоги в темах): пользователь видит список модулей-подтоваров, может включать/выключать чекбоксы, менять количество, и получает итоговую сумму. Кнопка «Собрать свой комплект» на странице товара ведёт именно в этот блок.

Исторически подтовары наполнялись только из импорт-крона (`commands/ImportController.php::actionSubProduct`) — донорская БД содержала таблицу `shopSubProducts`, которую крон зеркалил в локальный `subProduct`. После закрытия импорта проект и его БД стали единственным источником истины, и потребовалась возможность редактировать подтовары руками: править поля, загружать картинки, создавать новые и удалять.

Этот документ описывает админский раздел, который эту возможность реализует.

## Терминология

| Слово | Что это |
|---|---|
| **«Подтовар»** | строка в таблице `subProduct` — компонент комплектации товара (разные размеры, варианты, дополнения). Один товар может иметь много подтоваров. |
| **«Комплект»** | товар с `product.type='module'` — отдельная админская фича со своей таблицей `productModule`. Сейчас на витрине **не используется** (0 активных). Не путать с подтоварами. |
| **«Опции»** | для `product.type='simpleOption'` — варианты `property`/`productPropertyValue`/`ppvPrice`. Не имеют отношения к подтоварам. |

В команде подтовары иногда называют «модульными товарами», но в коде слово «модульный» (`type='module'`) занято под другую сущность. Чтобы избежать путаницы, в админ-UI используется только термин **«Подтовары»**.

## Где находится UI

Вкладка **«Подтовары»** в боковой навигации карточки товара. Видна **только** для товаров с `product.type='import'` (исторический носитель подтоваров — ~96% всех строк `subProduct`). Регистрация таба — `src/admin/product-page/index.js`, поле `allowedProductTypes: ["import"]`. На остальных типах вкладка скрывается, хотя бэкенд-эндпоинты доступны независимо от типа товара.

## Бэкенд

### Контроллер `modules/admin/controllers/SubProductController.php`

Все экшены — JSON, доступ — роли `admin` и `contentMaker`. Модель — `SubProductBase` (без `afterFind`-надбавки, которая есть у публичной `SubProduct`), пишем и читаем сырые поля.

| Action | URL | Метод | Параметры | Что делает |
|---|---|---|---|---|
| `actionList` | `/admin/sub-product/list` | GET | `productId` | Возвращает массив всех подтоваров товара с `isDelete=0`, отсортированных по `ordering, id`. |
| `actionCreate` | `/admin/sub-product/create` | POST | `productId` | Создаёт пустой подтовар (`title='Новый субтовар'`, `defaultOn=0`, `ordering = max+1`). Пересчёт цены товара не запускает. |
| `actionUpdate` | `/admin/sub-product/update` | POST | body: `data` (JSON с полем `id` и редактируемыми полями) | Обновляет поля `title, vid, articul, size, dimensions, weight, qty, purchasePrice, price, defaultOn, ordering` + `updateAt`. После сохранения дёргает `recalcProductPrice`. |
| `actionDelete` | `/admin/sub-product/delete` | POST | `id` | Soft-delete (`isDelete=1`). После сохранения дёргает `recalcProductPrice`. |
| `actionPostImage` | `/admin/sub-product/post-image` | POST | `id`, FormData `image` | Сохраняет файл через `ProductBase::saveImage` и пишет путь в `subProduct.image` и `subProduct.publicImage`. Пересчёт цены не запускает. |
| `actionSorting` | `/admin/sub-product/sorting` | POST | body: `data` (JSON-массив `id` в нужной последовательности) | Массово выставляет `ordering`. Зарезервировано под drag-n-drop, в текущем UI не вызывается. |

### Пересчёт цены товара

Метод `SubProductController::recalcProductPrice(int $productId): int` срабатывает после `update` и `delete` подтовара. Логика:

```
product.purchasePrice = Σ (subProduct.purchasePrice × qty)
для всех subProduct, у которых productId = X, isDelete = 0, defaultOn = 1
```

`qty` хранится как `varchar`; если пусто / не число / `≤ 0` — берётся `1`. Запись в БД делается прямым `UPDATE product SET purchasePrice = ?, price = ? WHERE id = ?`. **Обе колонки** обновляются одним значением, потому что:

- `purchasePrice` — это «источник истины» для расчётов;
- `price` тоже синхронизируется, иначе `ProductBase::beforeSave` (где есть строка `$this->purchasePrice = (int) $this->price`) при следующем сохранении продукта из обычной админ-формы откатит покупочную цену к старому `price`.

Прямой `UPDATE` (а не сохранение через ActiveRecord) выбран намеренно — он не дёргает `ProductBase::beforeSave` с его flash-сообщением «Данные сохранены» и slugify URL.

Эндпоинты `update` и `delete` возвращают в JSON-ответе новое значение `productPurchasePrice`, чтобы фронт мог обновить интерфейс без перезагрузки.

### Загрузка изображения

`actionPostImage` делегирует сохранение в `ProductBase::saveImage($_FILES)` родительского продукта. Что происходит на каждом upload-е:

1. Валидация: разрешённые расширения `jpeg/jpg/png/gif` (любой регистр), лимит **10 МБ** (`$file_size > 10485760`).
2. Уникальное имя: `md5(originalName + time()) + '.' + ext`. Файл сохраняется в `web/<publicImageDir>/<md5>.<ext>` без перекодировки.
3. **Локальный тамб 300×200** через `yii\imagine\Image::thumbnail` в режиме `THUMBNAIL_OUTBOUND` (центрированный кроп), `quality=80`, имя `<md5>_300x200.<ext>` в той же папке. Это файл, который читает `SubProduct::getPublicImageThumb()` как локальный fallback.
4. Возвращается путь к оригиналу — он записывается и в `subProduct.image`, и в `subProduct.publicImage`.

**Чего НЕ делается синхронно при upload-е:**

- ❌ CDN-варианты `sm/md/lg` в таблице `subProductImageSizes` — они генерируются отдельной CLI-командой `yii image/public subProduct` (`commands/ImageController.php::actionPublic`), которую обычно запускают cron-ом. До её прогона `SubProduct::getCardImage()` упадёт на локальный `_300x200`-тамб (см. `docs/images.md`).
- ❌ WebP/AVIF.
- ❌ Удаление старого файла при замене картинки — старый `<md5>.<ext>` остаётся «сиротой» на диске.

## Фронтенд

### Компонент `src/admin/product-sub-product/index.jsx`

Подключается в `src/admin/product-page/index.js` и монтируется при выборе таба «Подтовары». Функциональные компоненты на хуках, локальные стили — `src/admin/product-sub-product/styles.css` (классы с префиксом `sp-`).

`ProductSubProduct` (контейнер):

- При монтировании грузит список через `GET /admin/sub-product/list?productId=`.
- **Верхняя панель** содержит минимум:
  - warning-баннер **«Цена товара обнулится»**, если ни у одного подтовара не стоит `defaultOn=1` — тот же кейс, что отдельно подсвечивается в «Управлении ценами»;
  - инпут поиска по `title` с дебаунсом 300мс. Никаких чекбоксов фильтрации, селекта сортировки или агрегатных счётчиков — раздел намеренно простой, цифры по комплектации видно в «Управлении ценами».
- **Плавающая нижняя панель** (`position: fixed; bottom: 1.25rem`, по центру viewport) показывается **только** при наличии несохранённых правок и содержит pill «Несохранено: N», кнопки «Отменить все» и «Сохранить всё (N)». Анимация — slide-in снизу. После сохранения/отката панель исчезает.
- **Кнопка «+ Добавить подтовар»** стоит под списком, по центру. При клике вызывает `POST /create`; если бэк отдал созданную запись с пустым `qty`, фронт сразу делает follow-up `POST /update` с `qty: "1"` — новая карточка появляется уже с `qty=1` в БД, без «грязных» полей.
- `Ctrl/⌘+S` — сохранить все dirty-строки разом (параллельные `POST /update` через `Promise.allSettled`). `beforeunload` — гард на закрытие вкладки с несохранёнными правками.
- Уведомления о сохранении/удалении/ошибках появляются как toast'ы в правом нижнем углу.

`SubProductRow` (карточка одного подтовара) — без локального стейта (кроме флага битой картинки), всё edit/dirty живёт в родителе:

- **Левая колонка**: превью изображения + кнопка «Загрузить» / «Заменить». Картинка кликабельна — открывает оригинал в новой вкладке. Поверх области превью работает **drop zone**: перетащил файл на плашку → `POST /post-image`. Существование файла **проверяется на фронте через `onError`** на `<img>`: если запись в БД есть, а файла на диске нет — плашка перекрашивается в красноватую с текстом «Файл не найден на сервере. Загрузите заново.» Серверный `file_exists`-чек намеренно не делается (250+ stat-вызовов на каждую загрузку списка), `onError` срабатывает в том же сетевом запросе, который браузер делает и так.
- **Правая колонка**: заголовок отдельным крупным инпутом, обычный чекбокс **«По умолчанию»** (соответствует `defaultOn`, без какого-либо pill-обёртывания) и сетка из 4 полей: `purchasePrice, qty, size, weight`. Поля `vid`, `articul`, `dimensions` сохраняются в БД (импорт-крон их пишет), но из формы убраны.
- Поле `qty` имеет минимум **1**: на `blur` и на сохранении значение принудительно клемпится к `>= 1` (`""`, `"0"`, `"-5"` или нечисловое → `"1"`). Это согласовано с `recalcProductPrice`, который и так трактует `qty <= 0` как `1` — но теперь это видно в UI, а не «магически на бэке».
- Левый акцент-бордер карточки цветной по статусу: зелёный (`defaultOn=1`) / серый (опциональный) / жёлтый (есть несохранённые правки) / красный (последний save вернул ошибку).
- В нижнем углу справа (`position: absolute`) — две кнопки рядом: **drag-handle ⋮⋮** (нативный HTML5 dnd, отключается во время активного поиска) и **«Удалить»** (через `confirm`). Per-row кнопок «Сохранить» / «Откатить» нет — это делается глобально через нижнюю плавающую панель или `Ctrl/⌘+S`. Статус строки (`«Несохранено»` / `«✓ Сохранено»` / текст ошибки) выводится в `.sp-row-foot` в правой колонке.

### Сортировка drag-n-drop

Реализована на нативном HTML5 API без внешних либ. Сам draggable — кнопка-handle в правом нижнем углу карточки (рядом с «Удалить»). При перетаскивании одной карточки на другую строка вставляется в позицию таргета, новый порядок сразу шлётся в `POST /admin/sub-product/sorting` (массив `id` в нужной последовательности). При сетевой ошибке порядок откатывается локально и появляется toast — ничего не «теряется тихо».

Drag отключается, пока активен поиск — handle получает `disabled` и `cursor: not-allowed`. Это исключает кейс, когда пользователь видит отфильтрованный список, тащит строку и думает, что меняет `ordering` относительно всего списка, а потом удивляется, что после очистки поиска порядок «прыгнул».

### Что НЕ редактируется через UI

- `price` (вычисляемая цена с надбавками — пересчитывается на лету в `SubProduct::afterFind` из `purchasePrice + factory% + domain%`, см. `components/Format.php::price`);
- `vid`, `articul`, `dimensions` — поля присутствуют в БД (заполняются импорт-кроном), но из формы вкладки убраны как ненужные контент-менеджеру;
- `ordering` — выставляется автоматически при создании; меняется через drag-n-drop карточек (handle в правом нижнем углу), не через явный инпут;
- `isDelete`, `isExist`, `updateAt`, `image`, `publicImage` — служебные / синхронизируются автоматически.

## Поток данных целиком

1. Контент-менеджер открывает товар `type='import'` в админке → вкладка «Подтовары».
2. React-компонент дёргает `GET /admin/sub-product/list?productId=N`.
3. Менеджер правит `purchasePrice` (или `qty`, `size`, `weight`, `title`, `defaultOn`). В нижнем правом углу появляется плавающая панель «Несохранено: N · Сохранить всё».
4. Клик по «Сохранить всё» или `Ctrl/⌘+S` → параллельные `POST /admin/sub-product/update` по всем dirty-строкам (через `Promise.allSettled`). Перед отправкой `qty` принудительно клемпится к `>= 1`.
5. Каждый `POST /update` → `SubProductBase` сохраняет ряд → `recalcProductPrice` пересчитывает Σ дефолтных и пишет в `product.purchasePrice` и `product.price` одним `UPDATE`-ом.
6. Ответ возвращает обновлённый `subProduct` + `productPurchasePrice`. Фронт мерджит ответ в локальный list, плавающая панель скрывается, на строке коротко мигает «✓ Сохранено».

На публичной стороне:

- `views/product/_complectation.php` (и аналоги в темах) выводит список подтоваров и стартовую сумму = `$model->price`. Эта сумма берётся из БД, поэтому теперь она автоматически совпадает с реальной суммой дефолтной комплектации (раньше она лежала с импорта и могла расходиться, особенно для товаров с «маркетинговой» ценой типа «от 18 900 ₽»).
- При любом изменении галочек/количества фронт пересчитывает `priceTotal = Σ(data-price × qty)` среди checked-подтоваров (`src/client/product-page/_complectation/index.js`) — серверная цена при этом игнорируется, считается из dataset.

## Связь с публичной моделью `SubProduct`

Админка пишет через `SubProductBase`, а витрина читает через `SubProduct`. Разница — `SubProduct::afterFind`: на каждое чтение он перетирает `$this->price` результатом `Format::price($this->purchasePrice, factoryMarkup%, domainMarkup%, domainMarkup)`. То есть колонка `price` в БД — фактически кэш, который никем не читается, потому что перезаписывается на лету. Админ редактирует только `purchasePrice`, а финальная цена для покупателя получается:

```
price_for_customer = purchasePrice
                   × (1 + factoryMarkupPercent / 100)
                   × (1 + domainMarkupPercent / 100)
                   + domainMarkup
```

Надбавки берутся из текущего домена и его отдела продаж (`UserDomain::get()` + `factorySaleDepartment.markup`), то есть один и тот же `purchasePrice` отдаёт разные цены на разных доменах сети.

## Что ещё стоит сделать (TODO)

- Удаление файлов-сирот при замене картинки.
- Синхронная генерация CDN-размеров при upload-е, либо хук в `actionPostImage`, который дёргает `image/public subProduct` для конкретной строки.
- Виртуализация списка для товаров с сотнями подтоваров (`productId=43668` имеет 249 строк) — сейчас 249 карточек рендерятся одним списком, поиск/сортировка дают приемлемую UX, но при первом рендере заметна задержка.
- Undo для `delete` через таймер на клиенте (сейчас удаление сразу идёт на бэк — toast уведомляет, но откатить нельзя, бэк не имеет undelete-эндпоинта).
