# teeu — Покупатель и коммерция (Этап 7)

Избранное, корзина в БД, split-checkout по продавцам, статусы заказов, подписка на снижение цены.

## Модель данных

| Таблица | Назначение |
|---|---|
| `favorites` | unique(user, product) — только зарегистрированные (ТЗ §22) |
| `carts` / `cart_items` | корзина в БД (не localStorage), unique(cart, product); персистентна между сессиями/устройствами (§24) |
| `order_groups` | одно оформление = группа (§25.2) |
| `orders` | один заказ на продавца, unique `number`, статус, итог |
| `order_items` | **snapshot** позиций (title/slug/цена/кол-во/изображение) — заказ читаем даже после удаления товара (§25.3) |
| `order_status_history` | from→to, actor (buyer/seller/admin/system), reason (§26.2) |
| `price_drop_subscriptions` | unique(user, product), baseline/last_notified (§23) |

Enums: `OrderStatus` (+матрица переходов), `OrderActorType`.

## Единая eligibility (ТЗ §61)

`ProductPurchaseEligibilityService::check` — один источник правил «можно купить»: опирается на
`Product::isPublic()` (seller active, publication/moderation/category, availability, **feed не blocked**)
+ количество. Используется в add-to-cart, валидации корзины и checkout — правила не дублируются.

## Корзина (§24)

`CartService`: `add` (дубликат → инкремент; eligibility-проверка), `setQuantity`, `detailed`
(товары + живая доступность + **серверная цена**, фронтенд-итогу не доверяем §24.4). Корзина не
стирается при logout, восстанавливается при login (привязка к user).

**Недоступные позиции:** `detailed` складывает в `eligible` и eligibility (удалён/скрыт/нет в наличии),
**и доставку** (`DeliveryScope` — товар не доставляется в текущий город покупателя, см.
[yml-import.md](yml-import.md)). Недоступная позиция во вью — **бледная** (`opacity-60`), её чекбокс
отключён (нельзя отметить в заказ), под названием причина (напр. «Не доставляется в {город}»). У каждой
позиции — **кнопка удаления** (`teeuRemoveCartItem`, fetch DELETE: вся корзина — одна форма чекаута,
вложенные формы недопустимы). У недоступного к доставке товара на карточке/странице скрыты кнопки
«В корзину», «Снижение цены» и счётчик акции.

## Checkout / split (§25)

`CheckoutService::checkout(user, itemIds)` в транзакции: повторная проверка владения item'ами
(§24.5), `lockForUpdate` на товары, повторная eligibility **+ доставка** (`DeliveryScope` отсекает
недоставляемые в текущий город — defense-in-depth), группировка по продавцу → `order_group` + по одному
`order` на продавца + snapshots позиций (цена — серверная). Удаляются **только оформленные** item'ы,
остальные остаются. После commit — событие `OrderCreated` на каждый заказ. Номер `TU-XXXXXXXX`.

## Статусы заказа (§26)

`OrderTransitionService::apply(order, to, actor, ...)`: продавец — по матрице `OrderStatus::sellerTransitions`;
покупатель — только отмена нового/подтверждённого; админ — override с обязательной причиной. Каждый
переход пишется в `order_status_history` + событие `OrderStatusChanged`.

## Кабинеты

- Покупатель `/account`: обзор, заказы (список/деталь с историей и group-контекстом), избранное,
  корзина с выбором чекбоксами и оформлением. Покупатель видит **только свои** заказы (§28, IDOR).
- Продавец `/merchant/orders`: список с фильтром по статусу и поиском по номеру, деталь с контактом
  покупателя и сменой статуса по правилам. Продавец видит **только свои** заказы (§27, IDOR).

## Отследить заказ (публично)

`/track` (`TrackOrderController`) — публичная страница без входа: поиск заказа по **номеру + телефону**
(телефон — второй фактор, сверяется с подтверждёнными `UserPhone` покупателя; единый нейтральный ответ,
чтобы не раскрывать, что именно не совпало). Read-only: статус-бар (`<x-order-status-bar>`), таймлайн
истории, состав. Вошедшего владельца редиректит в `/account/orders/{order}`. throttle `track-order`.
Ссылка в подвале.

## Snapshot и уведомления о цене (§23)

Снижение цены (в `ProductService`/`YmlImportService`) фиксируется в `product_price_history` и рождает
событие `ProductPriceDecreased`. Кнопка «Уведомить о снижении» создаёт подписку. Доставка уведомлений
(in-app/web-push, обновление `last_notified_price`) — Этап 10 (Notifications).

## Тесты (ТЗ §56.8–56.10)

`CommerceTest`: добавление/дубликат, недоступный/blocked-feed товар не добавляется, **split по
продавцам** (1 группа, 2 заказа, верные суммы), оформление только выбранного, **серверная цена**,
чужой cart-item, матрица переходов (валид/невалид), отмена покупателем, **IDOR** (продавец/покупатель
не видят чужой заказ), избранное toggle.

## Напоминание о непрочитанном чате

Письма про каждое сообщение мы не шлём — это верный способ добиться, чтобы письма перестали читать.
Вместо этого раз в 15 минут работает `teeu:chat:remind-unread`: если сообщение висит непрочитанным
дольше **часа**, получателю уходит уведомление в кабинет и письмо.

**Час считается от самого старого непрочитанного, а не от последнего.** Иначе собеседник, который
пишет каждые полчаса, отодвигал бы напоминание бесконечно — и оно не пришло бы никогда.

**Одно письмо на разговор, не чаще раза в сутки** (`conversations.seller_reminded_at` /
`buyer_reminded_at`). Три реплики подряд дают одно напоминание, а не три.

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

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