# Работа с изображениями товаров через CDN и productImageSizes

## Контекст

Раньше изображения товаров отдавались напрямую с веб-сервера: оригинальные файлы лежали в `web/<publicImageDir>/...`, тамбнейлы делались по требованию через `imageResize()` или хранились рядом с суффиксом `_300x200`. Старые методы построены на проверке существования файла на диске и при отсутствии возвращают плейсхолдер.

Параллельно в БД появились таблицы `productImageSizes` и `subProductImageSizes` (миграции `m250822_215420_create_productImageSizes_table` и `m260315_000001_create_subProductImageSizes_table`). В них уже хранятся уменьшенные версии каждого изображения (`sm`/`md`/`lg`/`xl`/`original`) с реальными именами файлов и хешами. Сами файлы заливаются на CDN — для каждой темы свой домен.

Новые методы — это «вторая дорожка» поверх существующего кода. Они тянут URL из `productImageSizes` / `subProductImageSizes`, формируют ссылку на CDN и при любой нештатной ситуации (CDN не задан, нужного размера нет в БД и т.п.) откатываются на старые методы. Старые методы не удалены — они помечены комментарием «Использовалось раньше …» и продолжают работать как fallback и для тех тем, где CDN ещё не настроен.

## Конфигурация CDN

CDN привязан к теме. Имя CDN-домена задаётся в `commands/ThemeController.php` в массиве `$themes`:

```php
'mebelbazar' => [
  'views' => '@app/themes/mebelbazar',
  'publicImageDir' =>  'media',
  'cdn' => 'img.mebelibazar.ru',
  ...
],
```

При выполнении команды

```bash
docker exec php74_mebel php yii theme mebelbazar
```

значение поля `cdn` записывается в `config/params.php` (ключ `"cdn"`). Если поле пустое, новые методы откатываются на старую логику.

CDN отдаёт статический файл по URL вида `https://<cdn>/<filename>`, где `<filename>` — это `productImageSizes.filename` (или `subProductImageSizes.filename`). Никаких подписанных или временных ссылок не предполагается.

## Размеры

В `productImageSizes.sizeAlias` хранятся следующие алиасы: `original`, `sm`, `md`, `lg`, `xl`. Для отображения используются:

| Где | Используемый размер |
|---|---|
| Карточка товара в каталоге (списки, related/similar/collection, hit-блоки) | `sm` |
| Страница товара (главный слайдер, миниатюры, конструктор комплектации) | `lg`, при отсутствии — `md`, при отсутствии — `sm` |

Размеры `original` и `xl` сейчас в шаблонах не используются.

## Методы

### `app\modules\admin\models\ProductImage`

- `getSizes()` — связь `hasMany(ProductImageSizes, productImageId)`. Используется, если нужно достать сразу набор размеров одной картинки.
- `getCdnUrl(string $sizeAlias): ?string` — низкоуровневый метод. Берёт `params['cdn']`, ищет строку в `productImageSizes` по `productImageId` + `sizeAlias`. Возвращает `https://<cdn>/<filename>` либо `null`, если CDN не задан или нужного размера нет.
- `getCatalogImage(): string` — URL для каталога. Делает `getCdnUrl('sm')`, при `null` откатывается на старый `getPublicImageThumb()`.
- `getCardImage(): string` — URL для карточки товара. По очереди пробует `lg`, `md`, `sm`. Если ничего нет в CDN — старый `getPublicImageFull()`.

Старые `getPublicImageThumb()` и `getPublicImageFull()` сохранены и работают как раньше (с проверкой файла на диске и плейсхолдером `via.placeholder.com`).

### `app\modules\admin\models\SubProduct`

Полный аналог `ProductImage`, но через таблицу `subProductImageSizes`:

- `getSizes()` — связь с `SubProductImageSizes`.
- `getCdnUrl($sizeAlias)`.
- `getCatalogImage()` — `sm` через CDN, fallback на старый `getPublicImageThumb()`.
- `getCardImage()` — `lg → md → sm`, fallback на старый `getPublicImageThumb()`.

Старый `getPublicImageThumb()` сохранён.

### `app\modules\admin\models\Product`

Высокоуровневые методы — выбирают «правильную» картинку для всего товара с учётом его типа.

- `getFirstProductImage(): ?ProductImage` (protected) — для `simpleOption` берёт первую картинку preset-комбинации `ppvPrice.images`, для всех остальных — первую активную `ProductImage` товара по `sorting`.
- `getCatalogImage(): string`:
    - для `import-pm` — внешний URL из `product.image` (как и раньше);
    - для всех остальных — `getFirstProductImage()->getCatalogImage()`;
    - если у товара нет `ProductImage` — старый `getPublicImageThumb()`.
- `getCardImage(): string`:
    - для `import-pm` — `product.image`;
    - для всех остальных — `getFirstProductImage()->getCardImage()`;
    - fallback — старый `getPublicImageThumb()`.
- `getPpvPresetImagesCdn(): array` — аналог старого `getPpvPresetImages()`, но возвращает `['image' => $i->getCardImage(), 'title' => $i->title]` для каждой картинки preset-комбинации `simpleOption`.

Старый `getPublicImageThumb()` сохранён.

## API `/api/get-ppv-price`

При смене опции в `simpleOption` фронтенд бьёт в `/api/get-ppv-price?productId=X&ppv=...`. Этот эндпоинт (`controllers/ApiController.php::actionGetPpvPrice`) теперь возвращает картинки через `ProductImage::getCardImage()`, то есть с тем же фоллбэком `lg → md → sm` что и при первом рендере страницы. За счёт этого слайдер `simpleOption` остаётся консистентным после смены опции.

## Что используется в темах

На текущий момент новая логика подключена только в `themes/mebelbazar/`. В каталоге:

- `themes/mebelbazar/views/category/_product.php` — `$model->catalogImage`.
- `themes/mebelbazar/views/category/_product_hit.php` — `$model->catalogImage`.

На странице товара:

- `themes/mebelbazar/views/product/index.php` — основной Owl-слайдер и миниатюры используют `$i->cardImage` (на каждом `ProductImage`).
- `themes/mebelbazar/views/product/simpleOption.php` — Splide-слайдеры берут `$model->ppvPresetImagesCdn`.
- `themes/mebelbazar/views/product/_complectation.php` — фон для каждого `subProduct` — `$i->cardImage`.
- `themes/mebelbazar/views/product/_actions.php` и `_modal-quick-buy.php` — `data-image` кнопок «В корзину»/quick-buy = `$model->catalogImage`. Это значение уезжает в `localStorage.larekCart`, потом — в `Purchase.cart` как снапшот для cart-detail и для письма.

В корзине / cart-detail:

- `controllers/CartController.php::actionIndex` — массив `$products[].image` теперь = `$model->getCatalogImage()`.
- `views/cart/_product.php` — рендер из `$product['image']`.
- `views/cart/_detail_product.php` — снапшот `Purchase.cart`. Если `image` пустой или плейсхолдер (`placehold.jp` / `placeholder.com`), рендер делает дозапрос `Product::findOne($model->id)->getCatalogImage()`. Это нужно для исторических заказов и заказов из устаревшего `localStorage`.

В email о заказе (`mail/purchase.php`, `mail/purchase-admin.php`) URL картинки больше не клеится безусловно: если `image` уже абсолютный (`http(s)://...`, в т.ч. CDN URL), используется как есть; иначе (старый относительный путь) — собирается через домен магазина.

## Поведение fallback

```
getCatalogImage() → CDN sm? → нет? → getPublicImageThumb() (старый)
getCardImage()    → CDN lg? → md? → sm? → нет? → getPublicImageFull() (старый)
                                              (или getPublicImageThumb() для SubProduct/Product)
```

Это значит:

- если CDN-домен не задан в теме — все шаблоны продолжат работать через старую логику без изменений;
- если у конкретного товара нет строк в `productImageSizes` (или нужного `sizeAlias`) — отдаётся старая картинка;
- если у товара нет `ProductImage` вовсе — отдаётся плейсхолдер `placehold.jp`.

## Как добавить CDN другой теме

1. В `commands/ThemeController.php` для нужной темы прописать `'cdn' => 'img.example.ru'`.
2. Запустить `php yii theme <name>` (на проде — внутри докер-контейнера `php74_mebel`).
3. В `config/params.php` появится строка `"cdn" => "img.example.ru"`.
4. В `views` соответствующей темы заменить:
    - `$model->publicImageThumb` → `$model->catalogImage` (каталог);
    - `$i->publicImageFull` / `$i->publicImageThumb` → `$i->cardImage` (страница товара);
    - `$model->ppvPresetImages` → `$model->ppvPresetImagesCdn` (для `simpleOption`).
5. На сервере, где работает CDN, должны быть залиты файлы из `productImageSizes.filename`. Доливка картинок и заполнение `productImageSizes` — отдельная задача (см. `commands/ImageController.php`).

## Что осталось на старой логике

- `Category`, `Factory`, `Page`, `Review` и прочие сущности с собственным полем `image` — к `productImageSizes`/`subProductImageSizes` отношения не имеют, отображаются как раньше (через `web/uploads/<size>/...`).
- Картинки опций (`productPropertyValue.image`, `option.image`) — отдельные превью-файлы, не связаны с CDN-логикой.
- API-эндпоинты `actionProduct` (`/api/product`) и `actionGetProductById` (`/api/get-product-by-id`) на момент миграции из фронтенда не вызываются — оставлены без изменений.
