# Магазин: корзина, заказы, оплата, доставка

Модуль `shop` зависит от `catalog`, но не наоборот. Каталог — витрина
и работает сам по себе: сайт с грузовиками, где вместо корзины оставляют
заявку, обязан жить с выключенным магазином. Эта односторонность проверяется
Deptrac'ом — каталог вынесен в собственный слой, которому запрещено видеть
любые другие модули.

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

Способ покупки — свойство **товара**, а не сайта: грузовик в корзину не кладут,
а запчасть к нему кладут, и то и другое бывает в одном каталоге.

| Режим | Что показывает |
|---|---|
| `cart` | кнопку «В корзину» — рисует модуль магазина |
| `request` | заявку в один клик — точка расширения, пока никем не занята |
| `none` | ничего: только описание и цена |

У товара режим может быть пустым — тогда действует умолчание сайта
(`Настройки → Каталог → Как покупать по умолчанию`). Пустое значение хранится
как `NULL` намеренно: смена умолчания сайта обязана доезжать до старых товаров.

Кнопку каталог не рисует сам — он спрашивает реестр `PurchaseActions`:

```php
$this->app->make(PurchaseActions::class)->register(
    PurchaseMode::Cart,
    fn (Product $product): string => view('shop::add-to-cart', compact('product'))->render(),
);
```

Никто не зарегистрировался — товар выводится без кнопки. Это нормальное
состояние витрины, а не ошибка: выключенный магазин не должен ронять каталог.

## Корзина

Хранится в базе, а не в сессии: собранная на телефоне корзина обязана найтись
на компьютере после входа.

```
carts       id, site_id, user_id?, token, comment?, last_activity_at
            UNIQUE (site_id, token)
            UNIQUE (site_id, user_id)
            KEY (site_id, last_activity_at)

cart_items  id, site_id, cart_id, product_id, variant_id NOT NULL DEFAULT 0,
            quantity, price_at_add
            UNIQUE (cart_id, product_id, variant_id)
```

Три решения, которые видно только в схеме:

- **Статуса у корзины нет.** При оформлении позиции копируются в заказ
  снапшотом, корзина очищается, история живёт в заказах. Поэтому у покупателя
  корзина ровно одна — и это выражается обычным уникальным ключом.
  Partial-индексов, которыми пришлось бы городить «одна активная»,
  в MySQL нет.
- **`variant_id` не nullable, ноль вместо NULL.** В MySQL два `NULL`
  в уникальном ключе не конфликтуют, поэтому с nullable-колонкой повторное
  «в корзину» создавало бы вторую строку, и корзина копила бы дубли.
- **`user_id` наоборот nullable** — и по той же причине это работает:
  у всех гостевых корзин он `NULL`, и уникальный ключ им не мешает.

### Гость и покупатель

Владельца два вида: вошедший покупатель и гость с токеном в куке `kz_cart`
(случайные 48 символов, год жизни). Знание токена = доступ к корзине, поэтому
это не идентификатор и ничего предсказуемого.

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

Слияние висит на событиях `Login` / `Logout`, а не внутри контроллера входа:
способов войти несколько (звонок, OAuth, оформление заказа), и корзина обязана
подхватываться при любом. Ядро при этом о магазине не знает.

### Цена в корзине живая

Цена не берётся из корзины. Она разрешается `PriceResolver` на каждый показ,
потому что зависит от города, группы покупателя и персональных цен: человек,
положивший товар гостем и вошедший, обязан увидеть свою цену, а не гостевую.

`price_at_add` хранится ровно ради отметки «цена изменилась с 1200 до 1350».
Фиксируется цена только в заказе.

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

### Ограничения

- количество обрезается по остатку, если включено `shop.limit_by_stock`.
  Нулевой остаток при этом **не** обрезает: сайты торгуют и «под заказ»,
  а каталоги без учёта остатков держат там ноль у всего;
- остаток проверяется и при добавлении, и при оформлении заказа: между тем
  и другим проходят дни, и товар за это время уходит;
- товар обязан принадлежать сайту и быть опубликованным — иначе подстановка
  чужого `product_id` тянула бы товар соседнего арендатора;
- позиция обязана принадлежать корзине запроса: чужой `item_id` даёт 404.

### Блок «Корзина» не кэшируется никогда

У блока есть настройка `cache_ttl`, и ничто не мешает выставить её корзине
или блоку входа — после чего корзину одного покупателя увидят все. Такие типы
содержимого реализуют `PersonalBlockContent`, и рендерер игнорирует у них TTL.
Это не та ошибка, которую администратор сайта должен иметь возможность
совершить.

Блок показывает корзину, но **не заводит** её: `current()`, а не
`currentOrCreate()`. Иначе на 300–500 сайтов таблица наполнялась бы строками
от ботов, зашедших однажды.

### Чистка

```bash
php artisan shop:prune-carts --site=medtehnika
```

Гостевые корзины без активности удаляются по настройке
`shop.cart_lifetime_days` (по умолчанию 90 дней). Корзины покупателей
не трогаются: их владельцы вернутся и найдут своё. В M7 команда уедет
в расписание вместе с остальными фоновыми задачами.

## Страница корзины

`/cart` — адрес модуля, а не раздел дерева. Чтобы страница выглядела как
остальной сайт, ядро даёт `PageShell`: он собирает зоны от указанного раздела
(по умолчанию — главной) и вставляет содержимое в контентную зону. Тем же
механизмом будут жить оформление заказа, личный кабинет и результаты поиска.

Формы работают без JavaScript — обычный POST с редиректом назад. С `Accept:
application/json` те же маршруты отвечают JSON'ом со сводкой корзины.

## Заказы

Заказ — **снимок**, а не ссылка. Название, артикул и цена копируются
в `order_items` при оформлении; `product_id` остаётся, но только для отчётов
и повторного заказа, без каскадного удаления. Через год товар переименован,
подорожал или снят с продажи — заказ обязан остаться прежним. В легаси заказы
«переписывались» задним числом каждой выгрузкой из 1С, и спорить с покупателем
было нечем.

```
number_sequences  site_id, key_name, value
order_statuses    site_id, key_name, name, color,
                  is_default, is_paid, is_cancelled, is_final
orders            site_id, user_id?, number, status_key, customer_*,
                  delivery_*, payment_key, items_total, total, comment,
                  source, snapshot JSON, placed_at, paid_at
                  UNIQUE (site_id, number)
order_items       order_id, product_id?, name, article, price, quantity, sum
order_events      order_id, kind, status_key, comment, admin_id?, user_id?
```

### Номер — персайтовый счётчик

Сквозной `id` сообщал бы покупателю, сколько заказов сделали все сайты
установки, а первый заказ нового сайта был бы №84512. Выдача — одним запросом
без блокировок:

```sql
INSERT INTO number_sequences (site_id, key_name, value) VALUES (?, ?, LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE value = LAST_INSERT_ID(value + 1)
```

Два одновременных оформления получают разные номера, даже придя
в одну миллисекунду.

### Статусы — справочник сайта

Названия придумывает владелец магазина («Ждём оплату», «Собран», «У курьера»),
а код смотрит на флаги: `is_paid` ставит дату оплаты, `is_cancelled`
и `is_final` закрывают заказ. Иначе поведение системы зависело бы от того,
как человек назвал колонку своей воронки.

Стартовый набор из шести статусов заводится при первом обращении: магазин могли
включить на сайте задним числом, и «заказ оформить нельзя, справочник пустой» —
не то состояние, в котором система должна оказываться. Статус, на котором стоят
заказы, из справочника не удаляется.

Смена статуса всегда пишется в `order_events`: без истории на первый же спор
«мне никто не звонил» ответить нечем.

### Оформление

⚠️ **Кнопки «Оформить заказ» в корзине не было вовсе.** Оформление написали
целиком — контроллер, форма, доставка, оплата, подтверждение звонком, —
а в шаблоне корзины на её месте висела заглушка «приедет следующим шагом
M6». Со стороны это выглядит как «корзина не работает»: положить товар
можно, заказать нельзя.

⚠️ **Способ доставки и оплаты показывается всегда, даже когда он один.**
Раньше единственный способ уезжал скрытым полем, и покупатель не видел
ни как повезут, ни чем платить, ни сколько это стоит.

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

### Свои поля формы заказа

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

Всё остальное задаётся в админке (**Магазин → Поля заказа**): «этаж
и подъезд», «удобное время звонка», «ИНН», «как о нас узнали». Тип поля
(строка, текст, число, почта, телефон, список, флажок) — это и вид ввода,
и правило проверки: «почта» без проверки формата была бы просто строкой
с другим placeholder.

```
checkout_fields (site_id, key_name, label, type, placeholder, options,
                 is_required, is_enabled, sort_order)
orders.extra_fields  JSON: [{key, label, value}, …]
```

- **Значения хранятся снимком вместе с подписью.** Поле переименуют или
  выбросят, а заказ обязан читаться через год — то же правило, по которому
  заявка переживает форму ([forms.md](forms.md)).
- **Удаление поля не трогает заказы**: оно исчезает только из формы.
- **Ключ считается из подписи** (`Этаж и подъезд` → `etazh-i-podezd`):
  администратор не обязан придумывать латинские имена. В форме ключ виден,
  но не редактируется — он в снимках уже оформленных заказов.
- **Свои поля проверяются вместе с системными**, а не после: покупатель
  должен увидеть все незаполненные поля сразу, а не по одному за отправку.
  Названия в ошибках человеческие: «Поле extra.inn обязательно» ничего
  не объясняет.
- **Значение списка проверяется по списку**: иначе поле «как о нас узнали»
  принимает что угодно, и отчёт по нему не собрать.
- **Свои поля переживают шаг подтверждения** телефона: иначе после звонка
  их пришлось бы вводить заново.

Значения печатаются в письме владельцу и в карточке заказа в админке —
подписью из самого заказа, а не из справочника.

### Корзина

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

**Счётчик количества — три обычные формы** (минус, ручной ввод, плюс),
а не поле со скриптом: корзина обязана работать там, где скрипты
отключены, и это тот случай, где обойтись без JavaScript ничего не стоит.

`/cart/checkout` — одна форма и две ступени: данные покупателя, затем код
из звонка, если сайт требует подтверждения (`shop.confirm_phone`, по умолчанию
включено). Введённые данные едут скрытыми полями, а не в сессии: шаг
подтверждения не должен зависеть от того, та ли это вкладка.

- **подтверждение включено** — телефон подтверждается звонком, учётная запись
  заводится сама ([auth.md](auth.md)), заказ привязан к ней;
- **выключено** — заказ принимается с `user_id = NULL`. Меньше трения,
  но истории заказов у покупателя нет, а телефон никем не проверен.

Подтверждение идёт **до** того, как поднимается корзина: вход сливает гостевую
корзину в корзину покупателя, и поднятая заранее ссылка указывала бы на уже
удалённую.

Товар без цены останавливает оформление: «цену уточняйте» нельзя молча
превратить в ноль рублей.

### «Купить в один клик»

Режим покупки `request` даёт форму с телефоном прямо в карточке товара. Заказ
получается обычный, отличается только источником (`source = quick`) — так
покупают то, что в корзину не кладут.

### Отказ по делу и сбой — разные вещи

Писатели бросают `ShopException`, а не голый `RuntimeException`, по конкретной
причине: `QueryException` наследует `PDOException`, а тот — `RuntimeException`.
Контроллер, ловивший `RuntimeException`, показывал покупателю сбой базы как
обычную неудачу формы, и настоящая ошибка не попадала ни в лог, ни в обработчик
исключений. Нашлось это живым прогоном, а не в тестах.

### Админка

`Магазин → Заказы`: список с фильтром по статусу и поиском по номеру, имени
и телефону; карточка с составом, историей и сменой статуса; справочник статусов
в модальном окне. Заказ целиком не редактируется — он снимок.

## Оплата

Способ оплаты — справочник сайта (`Магазин → Оплата`). У арендатора обычно
подключена одна система, но ограничения «ровно одна» в схеме нет. Строка
справочника ссылается на провайдера из реестра `PaymentProviders`; пустой
провайдер означает оплату без онлайн-шага — при получении, по счёту, наличными
курьеру. Такой способ заводится сам, если справочник пуст: магазин без единого
способа оплаты не даёт оформить заказ вообще.

Ключи доступа лежат в **настройках сайта** и шифруются, как ключ voicepassword:
установка одна на сотни сайтов, а договор с банком у каждого свой. Провайдер
сам сообщает админке, настроен ли он, — чтобы способ не включали вслепую.

Добавление системы не трогает ни контроллеры, ни схему: класс, реализующий
`PaymentProvider` (два метода — начать оплату и разобрать уведомление), плюс
строка регистрации. Всё остальное — запись платежа, идемпотентность, сверка
суммы, перевод заказа в оплаченный статус — делает `PaymentService` одинаково
для всех.

### Порядок: сначала заказ, потом платёж

Оплата начинается после того, как заказ записан. Платёж без заказа — это
деньги, которые некуда положить.

Записей платежа у заказа может быть несколько: покупатель бросил форму банка
и вернулся позже, сменил способ, оплатил со второго раза.

### Уведомлению не верим

⚠️ Подписи у ЮKassa нет. Из уведомления берётся **только идентификатор
платежа**, а статус и сумма перезапрашиваются у API — иначе любой, кто знает
адрес вебхука, закрыл бы чужой заказ POST'ом с `status: succeeded`. Отдельный
тест на это есть.

Дальше:

- **идемпотентность** — `UNIQUE (site_id, provider, external_id)` плюс ранний
  выход, если платёж уже `succeeded`: банк повторяет уведомление, пока
  не получит 200, и второй раз закрывать заказ нельзя;
- **сверка суммы** — уведомление «оплачено на 10 рублей» не закрывает заказ
  на десять тысяч;
- **фильтр по сайту** — уведомление приходит на домен арендатора и не имеет
  права трогать заказ соседнего;
- **200 почти всегда** — на неизвестный платёж отвечаем успехом и пишем в лог:
  ошибка в ответе превращается в сутки повторов.

CSRF-проверка для `payment/*/callback` отключена в `bootstrap/app.php`: токена
у банка нет, а подделку закрывает перезапрос состояния, а не токен.

Заказ переводится в первый статус с флагом `is_paid` — какой именно и как он
называется, решает владелец сайта.

### Ключ идемпотентности у провайдера

`Idempotence-Key` в запросе к ЮKassa привязан к нашей записи платежа, а не
ко времени: повтор при обрыве связи не должен стоить покупателю двух списаний.

## Доставка

Тоже справочник сайта (`Магазин → Доставка`), тоже с реестром: расчёт вынесен
за интерфейс `DeliveryCalculator`, поэтому СДЭК и Почта подключатся так же,
как ЮKassa. Встроенных расчёта два — фиксированная цена с порогом бесплатной
доставки и самовывоз. Самовывоз заводится сам, если справочник пуст.

⚠️ **Стоимость считается на сервере и никогда не берётся из формы.** Иначе
покупатель пришлёт `delivery_total=0` — ровно как цену товара нельзя брать
из скрытого поля. То, что показано на форме оформления, справочно; в заказ
уедет пересчитанное.

- **цена по городу перекрывает общую** (`delivery_prices`): доставка по своему
  городу и в соседний регион стоит по-разному, а колонками это не выразить —
  в легаси были фиксированные `dostavka1..dostavka5`;
- **порог бесплатной доставки** у города свой, если задан, иначе общий;
- **адрес требует способ, а не форма**: курьеру обязателен, самовывозу
  неуместен. Отсюда флаг `requires_address` у способа;
- **самовывоз — отдельный расчёт**, а не «фиксированная цена = 0»: у него
  другая природа;
- калькулятор из выключенного модуля даёт нулевую доставку, но не роняет
  оформление: менеджер разберётся, покупатель не должен упереться в белую
  страницу.

## Личный кабинет

`/account/orders` и `/account/profile` — те же адреса модуля в оболочке
`PageShell`, что и корзина.

- **гостю кабинет отдаёт 404, а не редирект на вход**: существование чужого
  кабинета — тоже сведения;
- **заказы показываются снимком**: сегодняшняя цена товара в прошлогоднем
  заказе была бы спором с покупателем о том, сколько он заплатил;
- **повтор заказа собирает корзину заново** — по текущим ценам, наличию
  и группам покупателя. Заказ это прошлое, корзина всегда живая. Товар, снятый
  с продажи, пропускается, но о нём говорится: молча уменьшить состав нельзя;
- **телефон в профиле не меняется**. Он ключ входа и подтверждается звонком,
  а не текстовым полем; смена номера — отдельный сценарий с подтверждением.

## Письма о заказе

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

Оба письма уходят **после** транзакции оформления, а сбой отправки идёт
в журнал: заказ уже лежит в базе, и недоступный SMTP не имеет права его
отменить. Покупатель без почты просто не получает письма — телефон обязателен,
почта нет.

### Смена статуса

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

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

### Отправитель

Отправляет `SiteMailer` из ядра: **отправитель — свойство арендатора**, а не
установки. У каждого сайта свой домен, и письмо о заказе с адреса платформы
для покупателя выглядит чужим, а для почтовых служб — подозрительным: SPF
домена отправителя не сойдётся. Свой SMTP тоже персайтовый, по той же причине,
что и провайдер дозвона: у арендаторов разные ящики и разные договоры.
Настройки — раздел «Почта»; не заданы — письма уходят через настройки
установки.

## Чего ещё нет

| Что | Когда |
|---|---|
| Возвраты и частичная оплата | по мере надобности |
| Чеки по 54-ФЗ (фискализация) | до первого живого магазина |
| Уведомления покупателю и менеджеру | нужен почтовый слой |
| Промокоды и скидки | после оплат |
| Резерв остатка под заказ | вместе с импортом (M7): выгрузка 1С перезапишет остатки |
