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

## Контекст

В каталоге сосуществуют две модели хранения цены:

- **Простой товар** — цена лежит прямо в `product.purchasePrice` (а `product.price` хранит ту же сумму как кэш — см. ниже).
- **Товар с подтоварами** (`product.type='import'` и часть других) — цена товара не редактируется напрямую: она вычисляется как Σ(`subProduct.purchasePrice × qty`) по всем подтоварам с `defaultOn=1`. Если править `product.purchasePrice` руками, ближайшее сохранение подтовара через `SubProductController::recalcProductPrice` откатит её обратно к сумме комплектации (см. [docs/subProducts.md](./subProducts.md)).

До этого раздела цены редактировались в двух разных интерфейсах:

- Простые — в карточке товара (`/admin/product/update?id=...`).
- Подтовары — на вкладке «Подтовары» той же карточки.

«Управление ценами» (`/admin/price-manager`) собирает обе сущности в одну React-таблицу и автоматически выбирает, в какую таблицу писать: в `product` или в `subProduct`. Это даёт массовое редактирование без переключения между карточками.

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

Пункт **«Управление ценами»** в боковом меню админки (`modules/admin/views/layouts/main.php`). Видим всем с ролью `contentMaker` и выше — той же, что управляет товарами.

URL — `/admin/price-manager`. Контроллер — `modules/admin/controllers/PriceManagerController.php`, вью — `modules/admin/views/price-manager/index.php` (один `<div id="priceManagerApp">`, всё остальное рендерит React).

Точка входа React — `src/admin/price-manager/index.tsx`, подключена в основной бандл админки через `src/admin/index.js`.

## Логика таблицы

Таблица всегда «плоская» — один уровень строк, без раскрывающихся групп:

| Тип товара | Сколько строк | Что в колонке «Название» | Что в колонке «Цена» |
|---|---|---|---|
| Без подтоваров | **1** | `product.title` | `product.purchasePrice` |
| С подтоварами | **N** (по числу подтоваров) | `«{product.title} — {subProduct.title}»` | `subProduct.purchasePrice` |

То есть для товара с подтоварами **родительская строка не рисуется вообще** — потому что редактировать её бессмысленно (значение всё равно перетрётся пересчётом). Это сразу отвечает на вопрос «куда сохранять»: каждая строка знает свою сущность и пишет в нужную таблицу.

Дополнительные элементы строки:

- В колонке **ID** всегда показывается `product.id` — даже для строк-подтоваров. Id самого подтовара в UI не выводится: пользователю он не нужен (всё, что можно сделать с конкретным подтоваром по id — это править его поля, для этого есть вкладка «Подтовары» в карточке товара). Это позволяет идти глазами по таблице и сразу понимать, что несколько подряд идущих строк относятся к одному товару. Сама группировка дополнительно подчёркнута тонким левым акцент-бордером, который чередуется между товарами — так визуально видно границу даже когда у товара 249 подтоваров (см. `productId=43668`).
- Бейдж типа: `product.type` для строк-товаров, `sub` для строк-подтоваров.
- Бейдж «по умолчанию» — если у подтовара `defaultOn=1` (он попадёт в стартовую комплектацию и в пересчёт цены товара).
- Метка `× qty`, если у подтовара `qty` не пустое и не `«1»` (в пересчёт умножается).
- Бейдж SKU, если у родительского товара заполнен `sku`.
- Бейдж «⚠ цена обнулится» — для строк-подтоваров товара, у которого **ни один** подтовар не имеет `defaultOn=1`: после ближайшего `recalcProductPrice` `product.purchasePrice` станет `0` и витрина потеряет цену. Такие строки также подсвечиваются красным акцент-бордером и розовым фоном, чтобы было видно при беглом сканировании.
- Ссылка с ID ведёт на карточку родительского товара (`/admin/product/update?id=...`), открывается в новой вкладке.

### Состояния строки и инлайн-диф

- **Чистая** — белый фон, в правой колонке выведена текущая `purchasePrice`, отформатированная как «10 000 ₽» (NBSP-разделитель тысяч).
- **Грязная** — жёлтый фон + жёлтый бордер вокруг инпута. Под инпутом выводится `<del>было</del> → ±дельта (±%)`, цвет дельты — зелёный, если цена выросла, и красный, если упала. Это снимает необходимость держать в голове старое значение и сразу даёт привязку «насколько изменили».
- **Обнулится** — розовый фон + красный левый бордер (см. выше).
- **Выбрана** — голубой фон (для bulk-операций). Если строка одновременно выбрана и грязная — оба эффекта складываются.

## Фильтры и пагинация

- Поиск (`q`): строка ⇒ `LIKE %x%` по `title` и `sku`; число ⇒ точный поиск по `id`. Применяется автоматически с дебаунсом 400мс после остановки ввода — не нужно жать кнопку. Очистка поля — крестиком справа в инпуте.
- Фильтр `type` — все значения из `product.type`. Применяется по `onChange` (без отдельной кнопки).
- Чекбокс **«Только с подтоварами»** — отдаёт только товары, у которых есть не-удалённые записи в `subProduct` (серверный фильтр).
- Чекбокс **«Только с расхождением price ↔ purchasePrice»** — клиентский фильтр поверх загруженной страницы: оставляет только строки, где значения `price` и `purchasePrice` разошлись. Полезно для аудита старых данных, у которых синхрон `price = purchasePrice` ещё не прошёл.
- Серверная пагинация: размер страницы переключается в правом нижнем углу (25 / 50 / 100 / 200, дефолт 50; бэкенд принимает `perPage` до 200). Это размер в *товарах*, а не в строках — товар с N подтоварами превращается в N строк, поэтому реальное число строк может быть в разы больше. Один товар-рекордсмен (`productId=43668`, см. `docs/subProducts.md`) имеет 249 подтоваров и в одиночку даст 249 строк на странице. В sticky-тулбаре поэтому выводятся обе цифры: «Товаров: N · Строк на странице: M».
- В пагинации есть кнопки `«` / `»` (первая/последняя) и поле ввода номера страницы — для прыжков по большим выборкам.

## Сохранение

Все правки накапливаются в локальном стейте `edited: Record<DirtyKey, string>`, где `DirtyKey = 'product:{id}' | 'sub:{id}'`. Значение хранится как сырой текст из инпута — это нужно, чтобы пустое поле не «схлопывалось» в `0` пока пользователь редактирует. На лету текст парсится в `int` для подсчёта `dirtyCount` и для сабмита. Если на `blur` поле осталось пустым — запись удаляется из `edited` (строка возвращается в «не-грязное» состояние и подхватывает базовое значение). Sticky-тулбар сверху показывает счётчик «Несохранено: N» и кнопку «Сохранить (N)» — разом для всех правок на странице (мульти-row bulk update).

### Клавиатура

- `Enter` в поле цены — переход к инпуту следующей строки (быстрое последовательное редактирование).
- `Esc` в поле цены — откат именно этой строки и снятие фокуса.
- `Ctrl/⌘ + S` — глобальное сохранение (эквивалент клика по «Сохранить»). Стандартный браузерный диалог сохранения страницы перехватывается.

При наличии несохранённых изменений включается `beforeunload`-предупреждение — закрыть/перезагрузить вкладку случайным жестом не получится.

### Массовое редактирование

Слева в каждой строке стоит чекбокс. Чекбокс в шапке — выбрать всё на странице (с indeterminate-состоянием для частичной выборки). Когда выбрана хотя бы одна строка, над таблицей появляется панель bulk-операций:

- **Изменить на %** — `value += value × N / 100`. Принимает отрицательные (`-5` = скидка 5%).
- **Умножить на** — `value *= N`. Удобно при заводской переоценке: вписали `1.15` → +15% всем.
- **Установить ₽** — выставить одинаковое фикс-значение.

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

Запрос — `POST /admin/price-manager/bulk-update` с FormData:

```json
{
  "products":    [ { "id": 12, "price": 1000, "purchasePrice": 1000 } ],
  "subProducts": [ { "id": 71, "price":  200, "purchasePrice":  200 } ]
}
```

Фронт отправляет `price` и `purchasePrice` одним значением (UI-колонка одна). Это сделано намеренно: `ProductBase::beforeSave` синхронизирует `purchasePrice = (int) $this->price`, поэтому если оставить `price` старым, следующее обычное сохранение товара через `/admin/product/update` откатит покупочную цену. Дублируя значения, мы держим обе колонки в синхроне.

### Что делает контроллер

`PriceManagerController::actionBulkUpdate` (`modules/admin/controllers/PriceManagerController.php`):

1. Для каждой строки в `products[]` — прямой `UPDATE product SET price=?, purchasePrice=?, updateAt=? WHERE id=? AND isDelete=0`.
2. Для каждой строки в `subProducts[]` — то же самое, но в `subProduct`, плюс запоминает `productId` затронутых подтоваров в `$touchedProductIds`.
3. После всех апдейтов — для каждого затронутого `productId` вызывается `recalcProductPrice($productId)` (та же формула, что в `SubProductController`: Σ `purchasePrice × qty` по `defaultOn=1`-подтоварам). Прямым `UPDATE` пишет результат в `product.purchasePrice` и `product.price` одним значением.
4. Возвращает `{ok, updatedProducts, updatedSubProducts, productPrices}`, где `productPrices` — `id => purchasePrice` после пересчёта. Фронт по этой карте обновляет в локальном стейте поля родительских товаров (а значит — и базовое значение, если та же страница потом откроется заново).

Прямой `UPDATE` (а не сохранение через ActiveRecord) выбран по тем же причинам, что в `SubProductController::recalcProductPrice`: не дёргаются `ProductBase::beforeSave`/`afterSave` со slugify URL, flash-сообщениями и логикой обновления связей.

## Доступ и роли

`AccessControl` в контроллере открывает все экшены ролям `admin` и `contentMaker`. Это та же группа, что имеет доступ к карточке товара и вкладке «Подтовары», то есть прав на массовое редактирование цен не больше, чем на поштучное.

## Связь с другими разделами

- **Подтовары** ([docs/subProducts.md](./subProducts.md)) — детальное редактирование подтоваров (картинки, qty, defaultOn, articul и т.п.). «Управление ценами» — только колонка `purchasePrice`. Бизнес-логика пересчёта `product.purchasePrice` одна и та же.
- **Карточка товара** (`/admin/product/update`) — редактирование всех остальных полей товара, включая `price` для простых товаров. После сохранения карточки `purchasePrice = (int) price` (см. `ProductBase::beforeSave`), поэтому правки в «Управлении ценами» не нужно дублировать в карточке.
- **Витрина** — финальная цена для покупателя считается на лету в `SubProduct::afterFind` / `ProductBase::afterFind` из `purchasePrice` + наценки домена/фабрики (`Format::price`). Колонка `price` — фактически кэш, нигде не читается напрямую, потому что перезаписывается при выборке. Поэтому в админке принципиально править именно `purchasePrice`.

## TODO

- Inline-сохранение по `Enter` / `blur` отдельной строки (сейчас `Enter` — это переход к следующей строке, сохранение — только массовое).
- История изменений цены (отдельная таблица + лента).
- Перевести клиентский фильтр «только с расхождением price ↔ purchasePrice» в серверный — сейчас фильтрует только в пределах загруженной страницы, для аудита всей базы этого мало.
