# teeu — Продавцы (Этап 4)

Покупатель становится продавцом (ТЗ §6): анкета организации → подтверждение email → активный кабинет.

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

| Таблица | Назначение |
|---|---|
| `sellers` | slug, публичное/юр. название, org_type, ИНН/КПП/ОГРН, адреса, контакты, email+verified, статус, логотип, `description` (approved) + `description_pending` + `description_status`, структурные контакты (website/phone/vk/telegram), кэш рейтинга |
| `seller_memberships` | связь seller↔user с ролью (owner/manager) — расширяемо (ТЗ §4), не завязано на `sellers.user_id` |
| `seller_email_verification_codes` | 4-символьный код (hash), attempts, TTL, one-time |
| `seller_addresses` | адрес + гео (city_id, координаты, статус) — для YML/гео на след. этапах |
| `seller_contact_clicks` | **аналитика лидов**: клик по контакту на карточке (события: `seller_id`, `type` = phone/website/vk/telegram, `user_id?`, `visitor_id?` — cookie-UUID для уникальных, `city_id?` — город клика, `created_at`). Индекс `[seller_id, type, created_at]`. Хранятся как события (не счётчик) — режутся по дате/каналу/посетителю/городу |

Enums: `SellerStatus` (draft/pending_email/active/suspended/blocked), `OrganizationType`,
`MembershipRole`, `ModerationStatus`.

## Регистрация и активация

1. `GET/POST /become-seller` — анкета (`BecomeSellerRequest`: ИНН 10/12, ОГРН 13/15, КПП для ООО,
   согласие). `SellerRegistrationService` создаёт seller (`pending_email`) + owner-membership, slug
   уникален. Стартует email-верификация.
2. **Email (ТЗ §6.3):** `SellerEmailVerificationService` — код из безопасного алфавита (без O/0, I/1),
   только hash, TTL 10 мин, ≤5 попыток, cooldown 60 c, старый код гасится, одноразовость. Письмо —
   `SellerEmailVerificationMail` (очередь `notifications`).
3. Успех → `email_verified_at`, статус `active`, роль `seller` владельцу, событие `SellerActivated`
   → `SendNewSellerAlert` (Telegram админу, ТЗ §6.5).

## Кабинет `/merchant`

Префикс из `config('teeu.panels.seller_prefix')`. Отдельный **тёмный layout** (`layouts/merchant`,
дизайн-кит §06), кнопка «На teeu». Доступ — middleware `seller.active` (нет продавца → become-seller;
`pending_email` → верификация; suspended/blocked → 403). Backend-авторизация, не скрытый URL (ТЗ §47.1).
Разделы: дашборд, **статистика** (клики по контактам), товары, **выгрузки** (YML-фиды и кабинеты Ozon и
Wildberries по ключу — [marketplace-import.md](marketplace-import.md)), **интеграции** (CRM, мессенджеры, приём
оплаты), заказы, отзывы, профиль, **реквизиты** (счета для оплаты по счёту), сообщения. Пункты меню — массив `$nav` в `layouts/merchant`; **у элемента четыре части**
(маршрут, иконка, подпись, счётчик) — вставка из трёх однажды сломала весь layout.
На странице фида в блоке «Настройки» видны **адрес выгрузки** (со ссылкой «Открыть» и кнопкой
«Скопировать» — свериться с ним нужно как раз тогда, когда товары перестали приходить) и
**тематика магазина** ([yml-import.md](yml-import.md)). Карты (адрес склада, зона доставки)
компактные, с кнопкой «Развернуть»; колесо мыши прокручивает страницу, масштаб — только с Ctrl.

Помимо сопоставления категорий и настроек: **зона доставки** (карта — обвести область;
вершины полигона перетаскиваются, клик по вершине удаляет), **адрес склада (откуда доставка)** —
редактируемый в любой момент (`POST merchant.feeds.address`), от него per-feed считается срок доставки
(см. [yml-import.md](yml-import.md)), и русские статусы истории импортов. Пауза/выключение и удаление
фида подтверждаются красивой модалкой `<x-confirm-form>` (переиспользуемый компонент, не native
`confirm()`). Согласие в анкете become-seller ссылается на редактируемую страницу **«Правила для
продавцов»** (`/rules`, см. [catalog.md](catalog.md)).

**Товары продавца** (`/merchant/products`) приходят только из фидов. У фид-товаров продавцу
**запрещено** править название, цену и наличие (они из выгрузки; `ProductRequest` их отбрасывает, в
форме read-only) — редактируются описание, фото, характеристики, акции.

**Категорию менять можно, и этот выбор сильнее автоматики.** Категорию проставляет сопоставление
фида или разбор по названию, и оба ошибаются: на составных папках («Подоконники и часы») и там, где
нужной категории в дереве нет. Поэтому в карточке товара стоит выбор с поиском (тот же компонент,
что в сопоставлении, — список на девять тысяч строк искать глазами невозможно), а выбранное человеком
пишется в `manual_overrides.category_id`. Дальше это соблюдают все, кто мог бы затереть: импорт (он и
раньше трогал только пустую категорию), перенос по сопоставлению и разбор «сборных» папок. Иначе
продавец, поправивший товар, нашёл бы его назавтра там же, откуда забрал.

Товары **без категории** не публикуются, и в общем списке их не найти — для них есть фильтр
**«Без категории»** со счётчиком; категория видна прямо в строке списка. Ручной выбор сразу снимает
ожидание: если товару не хватало только категории, он выходит на витрину, а его значения
характеристик переезжают в индексе фильтров (иначе товар попадает в раздел, но не находится его
фильтрами). В списке у
опубликованных товаров есть иконка «открыть на сайте» — у неопубликованных публичной страницы нет. На публичной карточке товара —
«Актуально на {дата последней успешной синхронизации фида}» (для присутствующих в выгрузке). В корзине
у позиции есть степпер количества −/+ (PATCH через fetch).

### Статистика контактов (`/merchant/stats`, ТЗ §31)

`Seller\StatsController` (через `SellerContext` — только активный магазин) + `SellerContactStatsService`.
Показывает, сколько раз посетители кликали контакты магазина:
- переключатель периода **По дням (30д) / По неделям (12н) / По месяцам (12м)** (query `?period=`);
- **разбивка по каналам** (телефон/сайт/ВК/Telegram) — стек-бар на **Chart.js**;
- плитки: всего кликов, **уникальных посетителей** (по `user_id`, иначе по `visitor_id`-cookie), телефон/сайт;
- **топ городов** (по `city_id`).

Агрегация — **один запрос за окно + бакетирование в PHP** (без raw `DATE()`, портируемо MySQL/sqlite;
та же идея, что в `App\Support\ChartSeries`). Chart.js вынесен в отдельный Vite-entry
`resources/js/stats.js` — грузится только на этой странице. Данные строго по текущему магазину (IDOR).

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

## Профиль и контакты

`SellerProfileRequest`: контакты — только безопасные `http/https` URL (website/vk), Telegram —
`@username`/`t.me/...` (ТЗ §7.2, без `javascript:`). Логотип — `ImageService::storeSellerLogo`
(реальный MIME, EXIF-ориентация, WebP, без upscale, ТЗ §7.3). Описание → `SellerDescriptionService`
(AI-модерация, [ai-moderation.md](ai-moderation.md)).

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

Там же **налоговые данные** — система налогообложения и ставка НДС. Нужны только для приёма оплаты
картой: состав чека по 54-ФЗ передаём мы, и без этих кодов эквайринг платёж не примет
([payments.md](payments.md)).

### Минимальная сумма заказа

Для оптовиков, которые не отгружают одну позицию: «от 5 000 ₽, но не меньше». Поле
`sellers.min_order_amount` (целые рубли, пусто или 0 — без порога) задаёт продавец в профиле,
админ видит и правит его в карточке продавца.

**Порог живёт на продавце, а не на фиде.** Заказ оформляется на продавца целиком
(`CheckoutService` группирует позиции по `seller_id`), из какого бы фида ни пришли товары. Порог на
фиде дал бы в одной корзине два противоречащих числа на один заказ.

Решает всё `MinimumOrderService` — одно место на корзину и оформление:

- сумма — та же, что ляжет в заказ: `price × quantity`, **без доставки**;
- проверяется только продавец, из чьих товаров что-то **выбрано** — невыбранная группа не
  оформляется и остальному заказу не мешает;
- при оформлении недобор проверяется **до** создания заказов и по всем продавцам сразу: одно
  сообщение со всеми недоборами, ни одного частично созданного заказа;
- недобор округляется вверх: «не хватает 0,4 ₽» покупатель прочтёт как «почти», а сервер откажет.

Покупатель видит порог заранее — в карточке товара («Минимальный заказ у продавца») и на витрине
продавца («Заказ от …»), — а в корзине в группе продавца: условие, сколько добавить и зелёную
галочку, когда набрано. Корзина пересчитывает это на лету: степпер количества сообщает форме новое
значение событием `cart-qty`. Кнопка «Оформить» блокируется при недоборе, но окончательное слово за
сервером.

## Публичная карточка `/seller/{slug}`

Только `active` (иначе 404). Layout **на всю ширину** контейнера. Логотип, название, рейтинг/кол-во
отзывов, **только одобренное** описание (ТЗ §7.1), витрина товаров. Контакты — **кнопки с иконками**,
все **трекаются** для статистики лидов (ТЗ §31):
- **Сайт/ВКонтакте/Telegram** — внешние ссылки (target=_blank, rel=nofollow noopener noreferrer) +
  Alpine `@click`-бикон `POST /seller/{slug}/contact-click` (`recordContactClick`, throttle:60/мин),
  ссылка при этом открывается как обычно;
- **«Показать телефон»** — `POST /seller/{slug}/phone` (`revealPhone`, throttle:60/мин) возвращает номер
  как `tel:`; номер **не в исходном HTML** (тянется по клику). По этой же причине Store JSON-LD
  (`Seo::storeJsonLd`) **не печатает `telephone`** — иначе номер уезжал бы в разметку и в выдачу,
  а кнопка и статистика лидов теряли бы смысл. Стережёт `SellerCardContactsTest`.

Общий приватный `recordClick()` пишет событие с `visitor_id` (`VisitorContext`, cookie `teeu_vid`) и
`city_id` (`CityContext`), **не считая клики самого продавца** (аккаунты из `seller_memberships`).
`SellerCardController::show()` заранее ставит cookie `teeu_vid`, чтобы клик нёс стабильный id.

## Лендинг «Стать продавцом» `/sell`

Публичная промо-страница, весь контент правится в панели — в шаблоне не осталось зашитых текстов.

- **Контроллер** `SellLandingController` + вьюха `sell.blade.php`. CTA ведёт на регистрацию →
  `/become-seller`, а действующему продавцу — в кабинет.
- **Тексты блоков** — настройки `sell.*` (реестр и дефолты в `config/teeu.php`), правятся в
  `/developer → Настройки → Лендинг «Стать продавцом»`. Очистка поля возвращает дефолт из конфига.
- **Карточки** — таблица `sell_blocks` (`SellBlock`), `/developer → Контент → Блоки «Стать продавцом»`.
  Одна таблица на четыре списка, колонка `block`: `value` (плитки), `benefit`, `step`, `tool`. Иконка —
  имя из набора Lucide. Номер шага считается от позиции в списке, поэтому контроллер переиндексирует
  группы после `groupBy` (иначе нумерация зависела бы от id).
- **Вопрос-ответ** — общая таблица `faq_items`, галочка `show_on_sell` (см. раздел Korzilla ниже).
  Видимые вопросы дублируются в разметку `FAQPage` (`Seo::faqJsonLd`) — только те, что реально на
  экране, иначе это скрытая разметка и санкции Google.
- **Дефолты и прод.** Тексты и карточки на проде сохранены из панели, поэтому смена дефолтов в
  `config/teeu.php` или `SellBlockSeeder` сама до прода не доходит — нужна правка-сидер (см.
  [ниже](#правка-контента-который-владелец-уже-правил-на-проде)).
- **Лента логотипов** — таблица `integrations` (см. ниже). Автопрокрутка на чистом CSS
  (`.marquee` / `.marquee-track` в `app.css`): трек содержит две одинаковые копии списка, поэтому
  сдвиг на `-50%` замыкается бесшовно. Пауза — по `:hover` **и** `:focus-within` (иначе по уезжающей
  ссылке не попасть с клавиатуры); при `prefers-reduced-motion` анимация выключается и лента
  превращается в обычный горизонтальный скролл.

## Интеграции `/integrations` и `/integrations/{slug}`

Системы, из которых teeu забирает YML-выгрузку (1С, InSales, Битрикс, МойСклад…). Правятся в
`/developer → Контент → Интеграции`: логотип, подпись для ленты, статья-инструкция и SEO-поля.

- **Контроллер** `IntegrationController` (список + статья), модель `Integration`, вьюхи
  `integrations/index.blade.php` и `integrations/show.blade.php`.
- **Логотип** грузится как файл на диск `public` в `integrations/` (PNG/SVG/WebP, без пережатия —
  в отличие от баннеров). Если логотипа нет, и лента, и карточка показывают название текстом.
- **SEO:** `meta_title` / `meta_description` с запасными вариантами из названия и лида,
  canonical, OpenGraph, `BreadcrumbList`. Страницы индексируются и попадают в
  `sitemap-pages.xml` — туда же добавлены сами лендинги (`/`, `/sell`, `/integrations`, `/new`,
  `/deals`) и опубликованные страницы подвала: раньше карта сайта знала только про
  категории, товары и продавцов.
- **Смена `slug`** у опубликованной страницы ломает внешние ссылки — редиректов для этого раздела нет.

Тесты — `tests/Feature/IntegrationPageTest.php` и `SellLandingContentTest.php`.

## Лендинг для клиентов Korzilla `/korzilla-partner`

Непубличная посадочная страница для привлечения продавцов: адрес рассылается в письмах о заказах,
которые уходят с сайтов, сделанных Korzilla. **Ссылок на неё на teeu нет** — попасть можно только по
адресу из письма.

- **Индексация:** `<meta robots noindex, nofollow>` + страницы нет в sitemap. В `robots.txt` она
  **намеренно не упомянута**: файл публичен, и строка `Disallow: /korzilla-partner` сама раскрыла бы
  адрес всем желающим.
- **Контроллер** `PartnerLandingController` + вьюха `partner.blade.php` (обычный публичный layout,
  шапка/подвал на месте). CTA ведёт на регистрацию → `/become-seller` → кабинет.
- **Тексты блоков** — настройки `partner.*` (реестр `config/teeu.php`, дефолты там же в секции
  `partner`), правятся в `/developer → Настройки → Лендинг Korzilla`. Семантика `SettingsService`:
  **очистка поля возвращает дефолт из конфига**, а не пустоту.
- **Вопрос-ответ** — общая таблица `faq_items` (`FaqItem`), правится в
  `/developer → Контент → Вопрос-ответ`: добавить/удалить/скрыть вопрос, порядок — drag-n-drop
  (`sort_order`). Вопросы общие для `/sell` и `/korzilla-partner`; где показывать — галочки
  `show_on_sell` / `show_on_partner` (можно обе), `is_published` — общий выключатель поверх них.
  Дефолтный набор сеется идемпотентно из миграций (`PartnerFaqSeeder`, `SellFaqSeeder` —
  правки админа переживают повторный прогон), т.к. на проде идёт `migrate --force` без `db:seed`.
- **Форма заявки** — код формы Битрикс24 в настройке `partner.form_embed`, выводится как есть
  (`{!! !!}`, доверенный админский HTML — как `site.body_scripts`). Заявки уходят в CRM, на стороне
  teeu не хранятся.

- **Ссылки в ответах** — поле остаётся простым текстом, но `App\Support\Linkify` экранирует его
  **первым** и вплетает `<a>` в уже экранированную строку: вставить HTML из админки невозможно, поэтому
  санитайзер не нужен, а Blade печатает результат через `{{ }}` (`HtmlString`). Линкуются `https://…`,
  `www.…`, голый домен (по списку TLD — чтобы «feed.yml»/«app.js» в прозе не стали ссылками) и e-mail
  (`mailto:`); точка в конце предложения остаётся вне ссылки.
- **Баннер для писем** — [email-banner-korzilla.html](email-banner-korzilla.html): готовый блок с
  инлайн-стилями, который клиенты Korzilla вставляют в свои письма о заказах (таблицы + ghost-таблица
  для Outlook, без картинок — почтовики их блокируют). Ведёт на `/korzilla-partner`.

Тесты — `tests/Feature/PartnerLandingTest.php` (13): рендер, noindex, отсутствие адреса в robots.txt и
sitemap, порядок/скрытие вопросов, тексты из настроек, вставка формы, возврат к дефолту при очистке,
автоссылки/mailto, голый домен с хвостовой пунктуацией, файлы не линкуются, HTML в ответе экранируется.

## Справочный центр `/help`

Разделы и статьи — `HelpCategory` и `HelpArticle` (`/help`, `/help/{раздел}`, `/help/{раздел}/{статья}`),
правятся в `/developer → Контент`. Статья — HTML из RichEditor, рендер общим `<x-prose>`.

- **Картинки** в статьях сжимаются в WebP через `CompressedRichImageProvider`. В Filament v5 у RichEditor
  **нет** метода `fileAttachmentProvider()`: провайдер задаётся на модели (`HasRichContent` +
  `registerRichContent('content')`). Вызов на компоненте роняет модалку редактирования. `json()` не
  включать — RichEditor перейдёт на JSON-документ, а публичная страница печатает HTML.
- **Места под скриншоты** в статьях, которые пишет сидер, — врезки `<blockquote>` «🖼 Скриншот: …»:
  они переживают пересохранение в редакторе, и владелец заменяет их кадрами в панели.

## Правка контента, который владелец уже правил на проде

Статьи справки, тексты и карточки `/sell`, вопросы-ответы на проде отредактированы в панели, в статьи
вставлены скриншоты. Перезаписать их сидером — значит молча стереть чужую работу. Поэтому такие правки
идут **сидерами-ревизиями**, по одному на правку:

| Что | Базовый класс | Правки |
|---|---|---|
| Статьи справки | `HelpArticleRevision` | `HelpRevisionSeeder` (Ozon, оплата, ПВЗ), `HelpWildberriesSeeder` |
| Тексты `sell.*` и карточки `sell_blocks` | `LandingTextRevision` | `LandingRevisionSeeder` (Ozon), `LandingWildberriesSeeder` |
| Вопросы-ответы `faq_items` | `FaqItemRevision` | `FaqRevisionSeeder` (Ozon, оплата), `FaqWildberriesSeeder` |

Правила у всех одни:

- **Фрагменты — из дампа прода**, а не из прежних сидеров: тексты там отредактированы. Порядок работы:
  снять дамп (скрипт на сервере от `teeu`, только чтение), загрузить его в локальную базу, прогнать
  сидер дважды — все строки должны обновиться с первого раза и не измениться со второго, — вернуть
  локальные таблицы из бэкапа.
- **Статья** меняется, только если в ней нашлись все заменяемые фрагменты; разделы дописываются по
  маркеру-заголовку; новые статьи создаются по slug, если их ещё нет. **Настройка, карточка, ответ**
  меняются, только если дословно совпадают с проверенным текстом. Всё, что правили руками, сидер
  пропускает и называет в выводе — сливать правки должен человек.
- **Повторный запуск ничего не меняет**, это проверяют тесты каждой ревизии (`*SeederTest`), в том
  числе цепочкой: следующая ревизия ложится на результат предыдущей.
- **Новые тексты лендинга равны дефолтам** `config/teeu.php` и `SellBlockSeeder` — это проверяет
  `LandingWildberriesSeederTest`, иначе прод и чистая установка разойдутся. Прежняя ревизия хранит свои
  тексты дословно, а не берёт их из конфига: конфиг уходит дальше, а она — шаг истории.
- **Запуск — на проде после деплоя**, вручную, из каталога приложения на сервере (сервер и путь — в
  `bin/deploy.sh`):

```bash
sudo -u teeu php8.3 -d disable_functions= artisan db:seed --class='Database\Seeders\HelpWildberriesSeeder' --force
```

## Тесты (ТЗ §56.2)

`tests/Feature/SellerTest` (стать продавцом, КПП для ООО, доступ до активации, успешная верификация +
роль, неверный/просроченный код, лимит попыток, suspended → 403), `SellerModerationTest` (allow
публикует, block сохраняет старое, недоступность оставляет pending), `AiProviderTest` (JSON+лог,
классификация 5xx, disabled), `SellerCardContactsTest` (кнопки-контакты, номер скрыт до клика,
reveal пишет клик + возвращает номер, 404 для неактивного), `SellerContactStatsTest` (запись
website/vk/telegram, отклонение неизвестного/ненастроенного канала, 404 неактивного, **пропуск кликов
самого продавца**, бакеты день/неделя/месяц, дедуп уникальных, топ городов, **IDOR** — чужие клики не видны).

## Сотрудники магазина

Магазин принадлежит аккаунту, но работать с ним могут несколько человек: `seller_memberships` с
ролью `owner` / `manager` (`App\Enums\MembershipRole`). Раздел «Сотрудники» в кабинете — только у
владельца.

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

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

### Что закрыто от менеджера

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

| Раздел | Почему |
|---|---|
| Банковские реквизиты | счёт, на который придут деньги покупателей |
| Приём оплаты (эквайринг) | ключи от того же счёта |
| Удаление выгрузки | уносит с собой все свои товары |
| Сотрудники | иначе роль перестала бы что-либо ограничивать |

Список живёт в `MembershipRole::ownerOnlyRoutes()`, проверяет его middleware `seller.owner` — **одним
списком, а не строкой в каждом контроллере**: разделов много, добавляются по одному, и забытая
проверка означала бы менеджера с доступом к чужим деньгам. Эквайринг закрыт иначе (раздел интеграций
общий) — проверкой по типу в `IntegrationController`.

Пункты меню владельца менеджеру не показываются, но это лишь удобство: доступ закрывает middleware,
скрытая кнопка защитой не считается (ТЗ §47.1).

Последнего владельца исключить нельзя — магазин остался бы ничьим.

## Удаление магазина (админка)

Магазин иногда заводят дважды — второй экземпляр нужно убрать целиком, а не прятать. Действие
«Удалить магазин» есть в списке магазинов и в карточке (`PurgeSellerAction`, логика —
`SellerPurgeService`).

**Что уходит.** Записи — каскадом самой базы: товары, выгрузки с историей импортов, переписка,
отзывы, интеграции, реквизиты, участники, приглашения. Отдельно, до удаления, чистятся **файлы**:
снимки товаров (`path_large` и `path_preview` у каждой картинки) и логотип. Каскад про файлы ничего
не знает — после него уже не выяснить, какие пути были, поэтому порядок здесь важен.

**Чего удалить нельзя, и это не ограничение интерфейса:**

| Что мешает | Почему |
|---|---|
| Заказы | документ покупателя, он переживает магазин |
| Партнёрские начисления | чужие деньги, о них решают отдельно |

На уровне базы обе связи стоят как `RESTRICT`, то есть удаление и так не прошло бы. Но админу
нельзя показывать SQL-ошибку, поэтому запрет проверяется заранее и объясняется словами: сколько
заказов, что делать вместо удаления (заблокировать магазин).

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

Факт удаления пишется в аудит **до** самого удаления — вместе с названием, slug и ИНН: после него
не останется ни модели, ни связей, а след такого действия нужен обязательно.