# teeu — Архитектура и принятые решения

Документ фиксирует зафиксированные версии, стек и архитектурные решения. Обновляется по мере
развития проекта. См. также [database.md](database.md), [design-inventory.md](design-inventory.md),
[placeo-reference-audit.md](placeo-reference-audit.md).

## Версии (зафиксировано на Этапе 0)

| Компонент | Версия | Примечание |
|---|---|---|
| PHP | **8.3.30** (ZTS) | Целевая конфигурация ТЗ достижима; fallback на PHP 8.2 / Laravel 12 **не требуется** |
| Laravel | **13.19.0** | Основная цель ТЗ |
| MySQL | 8.4.3 | InnoDB, utf8mb4, БД `teeu` |
| Node | 22.22 | сборка Vite |
| Composer | 2.9.4 | |

Целевое окружение — Laragon (Windows). PHP-расширения, проверены как включённые: `gd`, `exif`,
`fileinfo`, `xmlreader`, `simplexml`, `libxml`, `dom`, `curl`, `openssl`, `pdo_mysql`, `mbstring`,
`intl`, `bcmath`, `sodium`, `zip` (включён вручную для Filament/openspout). **imagick отсутствует** →
image pipeline работает на `gd` через `intervention/image`. **Redis отсутствует** → очереди и кэш на
`database` (ТЗ это допускает; архитектура очередей не привязана к Redis).

## Стек

- **Backend:** Laravel 13, PHP 8.3.
- **Frontend (публичный сайт + кабинеты):** Blade (SSR, SEO-friendly, не SPA) + **Tailwind CSS v4** +
  **Vite 8** + **Alpine.js 3**. Иконки — **Lucide**. Карты — **Leaflet** + OpenStreetMap/Nominatim.
  Шрифты — **Manrope** (текст) + **JetBrains Mono** (мета/ID), самохостятся через
  `laravel-vite-plugin/fonts` (bunny) — без внешнего CDN (важно для CSP и PWA).
- **Admin-панель (`/developer`):** **Filament 5** (решение зафиксировано — см. ниже). Отдельная тема,
  primary-цвет teeu `#1877FF`.
- **Пакеты домена:** `spatie/laravel-permission` (роли/права), `kalnoy/nestedset` (дерево категорий —
  оценивается; ТЗ допускает adjacency list, nestedset даёт готовый materialized path),
  `intervention/image` (image pipeline), `laravel/socialite` + `socialiteproviders/yandex` (OAuth).

## Ключевые архитектурные решения

1. **Greenfield.** Каталог `teeu` был пуст (только `tmp/` с ТЗ и дизайном). Существующей архитектуры
   для сохранения нет — строим с нуля.
2. **Admin = Filament 5** (не кастомный Blade). Причина: объём ТЗ по админке (§31–38, §58) огромен
   (дашборд, продавцы, юзеры, заказы, YML-детали, дерево категорий, диалоги, настройки, operational
   screens); Filament даёт CRUD/таблицы/фильтры/графики/policies из коробки и совпадает со стеком
   Placeo (можно переносить подход). Публичный сайт и кабинеты покупателя/продавца — **кастомный
   Blade по дизайн-киту** (Filament там не используется).
3. **Разделение зон** (ТЗ §3): публичный маркетплейс (`/`, `/catalog`, `/product`, `/seller`),
   кабинет покупателя (`/account`), кабинет продавца (`/merchant`, префикс из `SELLER_PANEL_PREFIX`),
   admin (`/developer`, префикс из `ADMIN_PANEL_PREFIX`). Секретный slug **не** считается защитой —
   доступ через auth + роль + policies + аудит.
4. **Phone-centric аккаунты** (ТЗ §5). Основной идентификатор — подтверждённый телефон в формате
   `+7XXXXXXXXXX`. Обычной email+password регистрации нет. Драйвер дозвона: `manual` (dev) + боевой
   провайдер, портируемый из Placeo, за единым интерфейсом.
5. **Единый AI-слой** (ТЗ §12): `AiProviderInterface` → `DeepSeekAiProvider`, поверх — доменные
   сервисы модерации/классификации. Модель и ключ — из настроек (БД, шифрованно), env — bootstrap
   fallback. Модель нигде не хардкодится.
6. **Переиспользование Placeo:** auth (дозвон/Яндекс), AI-клиент, Telegram, image pipeline, гео —
   адаптируем проверенный код Placeo под домен teeu. YML-импорт, чаты, отзывы, корзину, split-checkout
   строим заново по (более строгой) модели ТЗ, используя Placeo как ориентир. Детали —
   [placeo-reference-audit.md](placeo-reference-audit.md).
7. **Очереди** — логически разделённые (`yml-download`, `yml-processing`, `ai`, `images`,
   `notifications`, `default`) даже на драйвере `database`; без привязки бизнес-логики к Redis, с
   возможностью позднего перехода на Redis/Horizon. На проде они разведены и **по процессам**:
   список очередей у воркера — это приоритет, а не набор, и одна длинная очередь останавливает
   остальные ([queues.md](queues.md)).
8. **Деньги идут мимо площадки.** Оплату картой принимает эквайринг самого продавца
   (`Services\Payments\YooKassaProvider`), счёт для юрлиц выписывается с его реквизитов. У нас
   ни счёта, ни ключей — только `PaymentService` как единственная точка смены статуса оплаты
   ([payments.md](payments.md)).
9. **Настройка фида не догоняет уже импортированные товары.** Сигнатура оффера считается по данным
   выгрузки, поэтому импорт пропускает неизменившиеся позиции, и переключатель сам по себе ничего
   не меняет. Под каждую такую настройку есть свой пересчёт: `FeedMarkupReapplier` (цены),
   `FeedCategoryReassigner` (категории), `FeedAvailabilityReapplier` (наличие). Забытый пересчёт
   выглядит как «галочка не работает» — так и случилось дважды.

## Порядок реализации

Следуем этапам ТЗ §73: Этап 0 (аудит) → 1 (Foundation) → 2 (Auth) → 3 (Geo/Categories) →
4 (Seller) → 5 (Products) → 6 (YML) → 7 (Buyer commerce) → 8 (Chats) → 9 (Reviews) →
10 (Notifications) → 11 (Admin) → 12 (SEO) → 13 (PWA) → 14 (Hardening).

## Заметки по окружению (dev)

- npm/vite нужно запускать с `node` в PATH. В git bash PATH не всегда подхватывается — сборку удобнее
  запускать через PowerShell: `$env:Path = "d:\laragon\bin\nodejs\node-v22;" + $env:Path`, затем
  `npm run build` / `npm run dev`.
- Composer: `php d:\laragon\bin\composer\composer.phar`.

## Соцсети teeu: анонс нового магазина

После первой успешной выгрузки магазин попадает в соцсети teeu отдельным постом: короткий текст,
ссылка на витрину и логотип (там, где площадка его пускает). Площадок две — канал @teeu_ru в
Telegram и сообщество vk.com/teeu_ru. Настраивается в админке, раздел **«Анонсы магазинов в
соцсетях»**; по умолчанию **выключено** — публикация необратима, и включать её должен человек, а не
выкладка кода.

| Настройка | Смысл |
|---|---|
| `channel.min_products` | сколько товаров должно быть на витрине (по умолчанию 10) |
| `channel.min_interval_minutes` | пауза между постами (30), считается по каждой площадке отдельно |
| `channel.prompt` | что именно писать модели — текст один на все площадки |
| `channel.hashtags` | постоянные хештеги в конце каждого поста (`#teeu #маркетплейс`) |
| `telegram.channel.enabled` | выключатель канала в Telegram |
| `telegram.channel.chat_id` | `@teeu_ru` — id канала выяснять не нужно, Telegram принимает имя |
| `vk.channel.enabled` | выключатель сообщества ВКонтакте |
| `vk.channel.token` | ключ доступа сообщества (секрет) |
| `vk.channel.group_id` | числовой id сообщества, без минуса |

В Telegram постит **тот же бот, что шлёт админ-алерты** (`telegram.bot_token`); от владельца
требуется добавить его администратором канала с правом публикации.

Устройство: `YmlImportSucceeded` → `AnnounceSellerAfterImport` → `AnnounceSellerInChannelJob` →
`ChannelAnnouncer` → площадки (`ChannelPublisher`). Решения, которые видно только в коде:

**Текст один на все площадки, отметки — разные.** Пост о магазине не зависит от того, где его
читают, а лишний вызов модели стоит денег; поэтому `compose()` вызывается один раз за задание.
Отметка о публикации у каждой площадки своя (`sellers.channel_announced_at` /
`sellers.vk_announced_at`): ВКонтакте подключили позже, и магазины, давно вышедшие в Telegram, для
него новые — общая отметка означала бы, что их там не будет никогда. Отсюда же и порядок
подключения площадки: сначала `teeu:channel:mark-announced --platform=vk`, чтобы провести черту,
и только потом включать.

**Отказ одной площадки не отменяет другую.** Задание отмечает то, что вышло, и повторяется ради
оставшегося — иначе сбой ВКонтакте стирал бы удачный пост в Telegram.

**Логотип во ВКонтакте — карточкой ссылки, а не файлом.** Ключ сообщества даёт `wall.post`, но не
`photos.*`: они отвечают «method is unavailable with group auth». Поэтому ссылка на витрину уходит
вложением (`attachments`), и картинку ВКонтакте берёт сам — из og-разметки страницы магазина (jpeg
1200×630, см. следующий раздел). Одной ссылки в тексте мало: окно публикации строит карточку по
тексту, а API нет, и до сентября 2026 посты выходили голым текстом.

У магазина без логотипа og:image нет, и ВКонтакте отказывает: «link_photo_sizing_rule. No photo
given». Тогда пост уходит вторым заходом, без вложения. Второй заход — **только после отказа**:
отказ значит, что записи нет, а после обрыва связи она могла и выйти (`VkResult` различает эти
случаи). Тем же ключом недоступны `wall.get` и `wall.delete` — свой пост мы не прочитаем и не
удалим, убирать неудачный или повторный придётся руками в сообществе.

**Точка — импорт, а не одобрение магазина.** Пока товары не встали на витрину, ссылка из поста вела
бы в пустоту. У нас это уже случалось: 885 позиций лежали невидимыми из-за галочки «Под заказ».

**Текст пишет только модель** (`AiPurpose::ChannelPost`). Своей заготовки нет намеренно — десять
постов «Новый магазин N, смотрите товары» подряд читаются как спам. Молчит модель — поста нет,
задание повторится через полчаса.

**Пост = текст, ссылка, хештеги — в таком порядке** (`ChannelPost::message()`, одна сборка на все
площадки). Теги: постоянные из `channel.hashtags`, затем 3–5 от модели — в том же вызове, что и
текст (`{"text", "tags"}`), отдельный запрос ради тегов не нужен, — затем город магазина.
Модельных не больше пяти, всего не больше десяти: пост, где тегов больше, чем слов, читается как
спам. Теги чистятся — на пробеле и дефисе площадки их обрывают, поэтому многословное склеивается
(«Ростов-на-Дону» → `#РостовНаДону`), а тег из одних цифр выбрасывается. Не прислала модель
тегов — пост всё равно выходит: теги не повод ждать. При нехватке места режется только текст:
без ссылки пост бессмысленен, а обрубок «#запч…» не найдёт никто. Пустое поле в админке
возвращает значение по умолчанию, как у всех настроек, — «совсем без постоянных тегов» не
бывает.

**Дата анонса ставится только после удавшейся отправки.** Пока её нет, магазин снова попадёт в
очередь на следующем импорте: обрыв связи не должен означать, что магазин молча остался без поста
навсегда. Обратная сторона — `ShouldBeUnique` по магазину, иначе расписание успело бы поставить
несколько заданий на один магазин.

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

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

## og:image: карточки ссылок в соцсетях

Все наши картинки хранятся в webp — он вдвое легче и на витрине уместен. Но og-разметку читают не
браузеры, а парсеры соцсетей, и webp понимают не все: ВКонтакте на попытку приложить такую ссылку
к посту отвечает «link_photo_sizing_rule. No photo given», у мессенджеров карточка выходит без
картинки.

Поэтому в `og:image` идёт не сам файл, а адрес его jpeg-версии: `App\Support\OgImage::from()`
подменяет `…/storage/sellers/logos/uuid.webp` на `/og/sellers/logos/uuid.jpg`. Файл изготавливается
по первому обращению (`OgImageController`) и остаётся на диске — держать вторую копию каждой
картинки заранее незачем, их сотни тысяч, а читают разметку единицы. Имена содержат uuid, так что
однажды сделанный файл не протухает.

Холст фиксированный, 1200×630 на белом фоне: соцсети требуют от картинки в карточке минимальных
размеров, и квадратный логотип 512×512 их не проходит.

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