# Автоподбор конкурентов — дизайн-документ

> **Дата:** 2026-06-28. **Статус:** черновик на ревью владельца.
> **Первоисточники брейншторма** (не пересказ — читать вместе с этим документом):
> - `2026-06-28-autopodbor-konkurentov-brainstorm-notes.md` — все решения и хвосты.
> - `2026-06-28-autopodbor-konkurentov-prototype.html` — согласованный UX «под ключ».
> - `2026-06-28-autopodbor-konkurentov-NEXT-SESSION-PROMPT.md` — сжатый контекст.
>
> **Граница этой работы (решение владельца 2026-06-28):** сам ИИ-движок (как реально
> ходим в интернет) делается в ОТДЕЛЬНОЙ сессии. Здесь проектируется и собирается
> ВСЁ остальное так, чтобы готовый движок вставился в одну заранее описанную «розетку»
> (§7) без переделки экранов, данных и денег. На v1 за движок работает заглушка.

---

## 0. Для владельца (без программистских слов)

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

Что в этой работе НЕ делаем: «мозг», который реально ищет в интернете. Его точите
отдельно. Мы готовим всё вокруг и оставляем «розетку», куда мозг потом вставится.
Пока розетка занята заглушкой (выдаёт заготовленные данные), чтобы экраны, деньги и
сохранение можно было собрать и проверить целиком уже сейчас.

**Что я решил по умолчанию за тебя** (помечено ниже значком 🟡 — поправь на ревью):
- 🟡 Режим «свой конкурент по названию» — добавлен экран подтверждения «мы нашли вот
  эту компанию — она?» ДО списания денег (§3.3).
- 🟡 Пустой результат — экран «ничего не нашли», деньги не списываем (§3.7, §5).
- 🟡 Источники — только сайт + телефон, SMS не трогаем (§4.2).
- 🟡 Объём: шаг 1 — до 15 конкурентов, шаг 2 — без потолка но с дедупом; всё
  настраивается в админке (§6).
- 🟡 Цена — задаётся в админке, пока не задана и фича не включена — вкладка скрыта.
  Я не выдумываю сумму (§5.4).

---

## 1. Объём (scope) и принципы

**Входит в v1:**
1. Вкладка «Автоподбор конкурентов» (пункт меню в группе «Работа», метка NEW).
2. Экран входа с блоком «Продолжить начатое» + две двери (авто-подбор / свой конкурент).
3. Форма авто-подбора (примеры конкурентов, «о себе», регион, федеральные).
4. Форма «свой конкурент» (+ экран подтверждения найденной по названию компании).
5. Фоновые платные прогоны: «подбор» (шаг 1) и «изучить» (шаг 2), с сохранением
   состояния, идемпотентностью и списанием «только за успех».
6. Экран списка конкурентов с % похожести и поштучным прогрессом.
7. Экран источников конкурента (живое состояние, дедуп, «проект создан», «Изменить проект»).
8. Экран создания проектов (общие настройки + префлайт баланса) и экран «Готово».
9. Админ-настройки: вкл/выкл фичи и цены за оба шага.
10. «Розетка» движка: интерфейс агента + заглушка (`Fake`) на время отсутствия мозга.

**НЕ входит в v1 (явно):**
- Реальный ИИ-движок с выходом в интернет (отдельная сессия).
- Уведомления по почте (только в портале — значок/тост; решение владельца).
- Рекомендатель источников поставщика BG (владелец велел не использовать).
- SMS как тип источника фичи (только сайт/звонок).
- Черновик частично заполненной формы создания (источники не теряются — этого хватает).

**Сквозные принципы:**
- Деньги — чувствительная зона: списываем атомарно, идемпотентно, только за успех
  (§5). Любая правда о деньгах сверяется billing-audit.
- Никаких дублей на трёх уровнях: конкуренты, источники, проекты клиента (§4.4).
- Все результаты агента — кандидаты; клиент проверяет по ссылкам-провенансам (§7.4).
- RLS: все новые таблицы — tenant-isolated (§4.1).

---

## 2. Архитектура (карта частей)

```
Кабинет клиента (Vue)                Backend (Laravel)                Движок (розетка)
─────────────────────                ─────────────────                ────────────────
AutopodborView.vue  ───POST───►  AutopodborController
  ├ вход + история                   ├ старт прогона ──► AutopodborRunService
  ├ формы (2 режима)                 │     (создаёт run, ставит job)
  ├ список конкурентов               │
  ├ источники                        ├ чтение run/result (опрос статуса)
  └ создание проектов ──POST──►      └ создание проектов ──► ProjectService (как сейчас)

         Очередь (Redis)                       CompetitorAgent (interface)  ← §7
              │                                   ├ FakeCompetitorAgent (v1)
   RunAutopodborSearchJob   ──вызывает──►         └ RealCompetitorAgent  (потом)
   RunAutopodborStudyJob    ──вызывает──►
              │
        пишет результат + при успехе списывает деньги (LedgerService-путь, §5)
```

**Единицы и их ответственность (для изоляции и тестируемости):**

| Единица | Что делает | Зависит от |
|---|---|---|
| `AutopodborController` | HTTP: старт прогонов, чтение состояния, запуск создания проектов | RunService, ProjectService |
| `AutopodborRunService` | жизненный цикл прогона: создать run, гейт баланса, поставить job, идемпотентность (один in-flight на tenant) | models, BalancePreflightService |
| `RunAutopodborSearchJob` | фоновый шаг 1: дернуть агента, сохранить конкурентов, при успехе списать | CompetitorAgent, ChargeService |
| `RunAutopodborStudyJob` | фоновый шаг 2 (и manual-study): дернуть агента, сохранить источники, при успехе списать | CompetitorAgent, ChargeService |
| `CompetitorAgent` (iface) | «розетка»: поиск конкурентов + изучение одного + резолв по названию | — |
| `FakeCompetitorAgent` | v1-заглушка: заготовленные данные, без интернета | — |
| `AutopodborChargeService` | списание за прогон: атомарно, идемпотентно по run_id, новый тип проводки | LedgerService-паттерн |
| `AutopodborDedup` | нормализация и дедуп конкурентов/источников; «уже есть проект» | ProjectService::assertSourceUnique-логика |
| `ProjectService` (есть) | создание проектов из источников — БЕЗ изменений в ядре | — |

Каждую единицу можно понять и протестировать отдельно; контракт между ними —
типизированные DTO (§7.2).

---

## 3. Экраны и потоки (по прототипу)

Нумерация экранов = id из прототипа. UX согласован; ниже — поведение и привязка к данным.

### 3.1. Вход (`s-entry`)
- Блок **«Продолжить начатое»** — если есть незавершённые/оплаченные прогоны (см. §4.1
  `autopodbor_runs`). Каждая строка: регион, дата, найдено N, изучено M, проектов K,
  «оплачено». Клик «Открыть» → в нужную точку (список или источники), БЕЗ повторной оплаты.
  Если истории нет — блок не показываем.
- Две двери: «Подобрать конкурентов» (→ `s-autoform`), «Указать своего конкурента»
  (→ `s-manualform`). Обе — первоклассные, не «запасной выход».

### 3.2. Форма авто-подбора (`s-autoform`)
- **Ваши конкуренты** (обязательно, ≥1; подсказка «чем больше — тем точнее»): строки
  «сайт или ссылка на справочник», кнопка «＋ добавить конкурента».
- **О вашей компании** (по желанию): несколько сайтов, несколько справочников, описание
  деятельности (textarea с подсказкой что описывать). Тон — деловой, уважительный.
- **Регион** — обязателен, ровно один (не мультивыбор).
- Галка **«включать федеральных игроков»** (работают в регионе клиента И в других).
- Кнопка «Подобрать конкурентов» → окно подтверждения цены (§5.3) → фоновый шаг 1
  (`s-loading` → `s-list`).

### 3.3. Форма «свой конкурент» (`s-manualform`) + 🟡 подтверждение по названию
- Ввод: сайт/ссылка на справочник ИЛИ только название; регион (один, обязателен).
- Кнопка «Собрать источники» → подтверждение цены (§5.3).
- **Развилка (🟡 новое, дыра прототипа):**
  - дан сайт/справочник → агент копает сразу → `s-loading` → `s-detail`.
  - дано только название → СНАЧАЛА дешёвый резолв (`CompetitorAgent::resolveByName`,
    бесплатно/в рамках того же прогона) → экран **«Мы нашли вот эту компанию — она?»**
    (название, описание, сайт/справочник, провенанс) → клиент жмёт «Да, изучить» → ТОЛЬКО
    тогда списываем за тяжёлый шаг и копаем источники. «Не та» → назад, без списания.
  - Если по названию найдено несколько кандидатов — показываем список на выбор.
  - Если не найдено никого — экран «не нашли» (§3.7), без списания.

### 3.4. Список конкурентов (`s-list`)
- Заголовок «Найдено N конкурентов» + recap-пиллы (регион, федеральные, по скольким примерам).
- Сортировка: 100% → ниже (релевантность = похожесть на ПРИМЕРЫ клиента, не на регион).
- Карточка: название, метки регион/«федеральный», описание, ссылки (Сайт + Справочники),
  число % похожести.
  - **Изученный** конкурент → метка «✓ изучен» + «Открыть источники» (БЕСПЛАТНО).
  - **Неизученный** → «Изучить подробнее» (платно) → подтверждение цены → шаг 2.
- Внизу: «Не нашли нужного? Указать вручную» → `s-manualform`.

### 3.5. Источники конкурента (`s-detail`)
- Шапка: имя + метка региона + % похожести; «Изучено DD.MM · найдено K источников».
- Пояснение про настоящий/подменный + что состояние живое.
- Секции: **🌐 Сайты** (только головы доменов) и **📞 Телефоны** (метка
  `настоящий` ✓ / `подменный · с сайта` 🎭). У каждого источника — чекбокс + провенанс-ссылка
  «Где нашли».
- **Живое состояние (§4.4):** источник, по которому уже есть проект клиента → строка
  заблокирована, метка «✓ проект создан», кнопка «Изменить проект» (→ `s-editproject`).
- «Чего-то не хватает? Добавить источник вручную» (сайт/номер).
- Нижняя панель: «Выбрано X из K», «Создать проекты →» (→ `s-create`).

### 3.6. Создание проектов (`s-create`) и «Изменить проект» (`s-editproject`)
- Список «Будет создано N проектов»: слева тип+идентификатор источника (со значком ✓/🎭
  у телефонов), справа редактируемое поле **Название проекта** (автоимя = имя конкурента,
  значок запекается в текст — см. §4.5).
- Блок «Настройки проектов»: общий регион (из подбора), общий лимит/день, дни приёма.
  Применяются ко всем; после создания каждый правится в «Проектах».
- Прикидка стоимости запуска + строка баланса «хватает ✓».
- Кнопки: «Создать (без запуска)» и «Создать и запустить →» (с префлайтом баланса, §5.5).
- `s-editproject`: правка одного существующего проекта (имя/регион/лимит/дни) прямо из
  источников, через тот же `PATCH /api/projects/{id}`.

### 3.7. Состояния-исходы
- **Загрузка** (`s-loading`): спиннер + «можно закрыть вкладку, сохраним результат,
  деньги — только за успех». Опрос статуса прогона по `GET` (§4.3).
- **Готово** (`s-done`): «N проектов создано/запущено», ссылки в «Проекты» и «В начало».
- 🟡 **Пусто/ошибка** (новый экран `s-empty`): «Ничего не нашли по этому запросу. Деньги
  не списаны». Кнопки «Изменить запрос» / «В начало». Показывается, когда прогон
  завершился `empty` или `failed` (§5.2).

---

## 4. Данные

### 4.1. Новые таблицы (все RLS tenant-isolated; миграции + запись в `db/CHANGELOG_schema.md`)

**`autopodbor_runs`** — один платный прогон (или резолв по названию).
| колонка | тип | смысл |
|---|---|---|
| id | bigint PK | |
| tenant_id | bigint FK | RLS |
| kind | enum(`search`,`study`,`resolve`) | шаг 1 / шаг 2 / резолв по названию |
| status | enum(`queued`,`running`,`done`,`empty`,`failed`) | жизненный цикл |
| region_code | int (1..89) | регион подбора |
| params | jsonb | вход: примеры, «о себе», federal, имя/сайт конкурента |
| competitor_id | bigint FK null | для `study` — какого конкурента изучаем |
| price_rub_charged | numeric(12,2) null | сколько реально списали (только при `done`) |
| balance_transaction_id | bigint FK null | проводка списания (§5) |
| error_code | varchar null | для `failed` |
| created_at / started_at / finished_at | timestamptz | |

**`autopodbor_competitors`** — конкуренты из прогона (авто-подбор + manual + resolve).
| колонка | тип | смысл |
|---|---|---|
| id, tenant_id | | RLS |
| search_run_id | bigint FK null | из какого подбора (null для «свой конкурент») |
| name | varchar | |
| description | text | |
| is_federal | bool | метка «федеральный» |
| relevance_pct | smallint null | похожесть на примеры (null для manual/resolve) |
| origin | enum(`auto`,`manual`,`resolve`) | как попал |
| site_url | varchar null | голова домена |
| directory_urls | jsonb | ссылки на 2ГИС/Яндекс.Карты |
| provenance | jsonb | где нашли (на уровне конкурента) |
| dedup_key | varchar | нормализованный ключ (для дедупа конкурентов) |
| study_run_id | bigint FK null | прогон изучения (если изучен) |
| studied_at | timestamptz null | изучен → открывается бесплатно |
| created_at | | |

**`autopodbor_sources`** — источники конкурента (результат шага 2).
| колонка | тип | смысл |
|---|---|---|
| id, tenant_id | | RLS |
| competitor_id | bigint FK | |
| study_run_id | bigint FK | |
| signal_type | enum(`site`,`call`) | SMS не используется |
| identifier | varchar | нормализован: голова домена / `7xxxxxxxxxx` |
| phone_kind | enum(`real`,`substitute`) null | для телефона; null для сайта |
| provenance_url | varchar | ссылка «где нашли» |
| provenance_label | varchar | человекочитаемо («2ГИС — карточка», «футер сайта») |
| dedup_key | varchar | `signal_type` + нормализованный `identifier` |
| created_project_id | bigint FK null | если по источнику создан проект (живое состояние) |
| created_at | | |

Дедуп внутри прогона — UNIQUE `(competitor_id, dedup_key)`; конкуренты —
UNIQUE `(tenant_id, COALESCE(search_run_id,0), dedup_key)`.

### 4.2. Источники = только сайт + телефон (🟡)
SMS как тип источника фичи не поддерживаем (notes 6,7). `signal_type` источника ∈
{`site`,`call`}. На создании проектов это маппится на существующие site/call.

### 4.3. Опрос статуса
`GET /api/autopodbor/runs/{id}` → `{status, ...}`. Фронт на `s-loading` опрашивает раз в
~2–3 c до `done`/`empty`/`failed`. Возврат на вкладку позже — состояние из БД, не из памяти.

### 4.4. Дедуп на трёх уровнях (жёсткое требование, заострено дважды)
1. **Конкуренты:** `AutopodborDedup` нормализует имя/домен → `dedup_key`; не показываем
   дубль и не показываем уже изученного/заведённого повторно.
2. **Источники:** нормализация (голова домена; телефон `7xxxxxxxxxx`) → дедуп внутри
   конкурента и между конкурентами одного прогона.
3. **Проекты клиента (живая проверка при КАЖДОМ показе `s-detail`):** для каждого источника
   запрос к `projects` по `(tenant_id, signal_type, signal_identifier)` — та же логика,
   что в `ProjectService::assertSourceUnique()`. Есть проект → метка «✓ проект создан»,
   выбор заблокирован, кнопка «Изменить проект». Это не stale-снимок: проверяется на лету.

### 4.5. Имя проекта и значок (notes 6k)
- Автоимя проекта = **имя конкурента** (без источника в названии).
- Значок типа телефона запекается в текст имени: «Окна Комфорт ✓» / «Окна Комфорт 🎭».
  Для сайта значка нет.
- **Уникальность имени** (`assertNameUnique`, 422 при дубле): несколько источников одного
  конкурента дадут одинаковые имена. Решение: при создании пачки добавлять тихий
  различитель — числовой суффикс « 2», « 3»… по факту коллизии (проверять перед вставкой,
  не ломая UX). Клиент потом переименует.
- **Эмодзи поставщику:** имя реально уходит в `SupplierProjectDto.name` →
  `manage-project.js` (field `name`). Эмодзи может не пройти кодировку BG. Поэтому:
  значок держим ТОЛЬКО на стороне Лидерры (показ в «Проектах»), а в DTO имени —
  `strip` эмодзи перед отправкой. Хвост-проверка: принимает ли BG юникод (§9).

---

## 5. Деньги и биллинг (зона billing-audit)

### 5.1. Модель «деньги только за успех»
Платные прогоны — только `search` (шаг 1) и `study` (шаг 2/manual-study). Прогон
`resolve` (резолв конкурента по названию) — **бесплатный** (`price_rub_charged` всегда
null); деньги за изучение берём отдельным `study`-прогоном уже после подтверждения
найденной компании (§3.3). В коде НЕТ механизма резерва/hold (есть только прямое
списание `LedgerService::chargeForDelivery`). Поэтому для платных прогонов:
- При старте прогона деньги **НЕ списываются**. Прогон создаётся в статусе
  `queued`→`running`; для клиента это «в работе» (UX-«резерв»).
- **Гейт перед стартом:** проверяем, что баланса хватает на цену шага (read-only). Не
  хватает → не стартуем, показываем «недостаточно средств» (без списания).
- **Списание — атомарно и только при `done`** (есть непустой результат): внутри
  `DB::transaction` с `lockForUpdate` по tenant — по образцу `LedgerService`:
  пишем `BalanceTransaction` нового типа + проставляем `autopodbor_runs.price_rub_charged`
  и `balance_transaction_id`.
- `empty`/`failed`/timeout → **ничего не списываем** (UX-«резерв» снят). Без «возвратов».

### 5.2. Идемпотентность (защита от двойного списания)
- **Один in-flight прогон на tenant** (notes 6b.3): `AutopodborRunService` не стартует
  второй `queued/running` прогон того же `kind` для tenant; кнопка на фронте заблокирована;
  refresh не плодит второй прогон.
- Списание идемпотентно **по `run_id`**: если у прогона уже есть `balance_transaction_id`
  — повторное списание невозможно (проверка внутри транзакции). Это исключает гонку и
  двойной debit при ретрае джобы.
- Поскольку in-flight один и debit — в конце, риск конкурентного овердрафта ограничен
  единственным прогоном. (Альтернатива — настоящий hold-резерв в леджере — отложена;
  при росте нагрузки вернуться. Помечено для billing-audit.)

### 5.3. Новый тип проводки
`BalanceTransaction` получает тип `TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'`
(`amount_rub` отрицательный, `related_type`/`related_id` → `autopodbor_runs`). Добавить в
константы модели + в любые отчёты/фильтры по типам. Запись в `db/CHANGELOG_schema.md`,
ревью billing-audit на money-инварианты (сумма проводок = изменение баланса).

### 5.4. Цена и вкл/выкл — через `system_settings` (🟡)
Паттерн как у `billing_yookassa_enabled`. Ключи:
- `autopodbor_enabled` (bool, default `false`) — пока выключено, вкладка/пункт меню скрыты.
- `autopodbor_price_search_rub` (decimal) — цена шага 1.
- `autopodbor_price_study_rub` (decimal) — цена шага 2 (и manual-study).
Чтение — `SystemSettings::get/bool`. Редактирование — существующий
`AdminSystemSettingsController` + `SystemSettingEditDialog.vue` (с reason ≥30 симв. и
аудит-логом). Конкретные суммы задаёт владелец в админке — в документе НЕ выдумываем.
Окно подтверждения цены (§3.2/§3.3) показывает актуальную цену из настроек.

### 5.5. Создание проектов и префлайт
Создание самих проектов — **бесплатно** (как сейчас). «Создать и запустить» гоняет
существующий `BalancePreflightService`; при нехватке — 409 `balance_insufficient` и
существующий `ProjectLimitOverloadDialog` (переиспользуем из `NewProjectDialog.vue`).

---

## 6. Объём и настройки агента (🟡)
- Шаг 1: до **15** конкурентов на выдаче (настраиваемо: `autopodbor_max_competitors`).
- Шаг 2: без жёсткого потолка источников, но с дедупом; разумный технический предел в
  реализации движка.
- Эти числа — параметры запроса к агенту (§7.2), не зашиты в UI.

---

## 7. «Розетка» движка (контракт для будущего мозга)

### 7.1. Интерфейс
```php
interface CompetitorAgent
{
    public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;   // шаг 1
    public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;   // шаг 2
    public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;         // manual по названию
}
```
Привязка в контейнере: v1 → `FakeCompetitorAgent`, потом → `RealCompetitorAgent`
(HTTP API + ключ). Джобы и сервисы зависят ТОЛЬКО от интерфейса — смена движка не трогает
экраны, данные и деньги.

### 7.2. DTO (контракт вход/выход) — кратко
- `FindCompetitorsRequest`: `region_code`, `examples[]` (сайт/справочник),
  `about_self{sites[],directories[],description}`, `include_federal`, `max_competitors`.
- `FindCompetitorsResult`: `competitors[]{name, description, is_federal, relevance_pct,
  site_url, directory_urls[], provenance}`.
- `StudyCompetitorRequest`: `competitor{name, site_url?, directory_urls[]?}`, `region_code`.
- `StudyCompetitorResult`: `sources[]{signal_type, identifier, phone_kind?, provenance_url,
  provenance_label}`.
- `ResolveByNameRequest`: `name`, `region_code`.
- `ResolveByNameResult`: `candidates[]{name, description, site_url?, directory_urls[]?,
  provenance}` (0, 1 или несколько).

Нормализация (голова домена, телефон `7xxxxxxxxxx`, различение настоящий/подменный по
методу из notes 6f) — на стороне backend (`AutopodborDedup`/нормалайзер), НЕ внутри
движка, чтобы заглушка и реальный движок были взаимозаменяемы и единообразны.

### 7.3. Заглушка `FakeCompetitorAgent` (v1)
Возвращает заготовленные данные (как в прототипе: Окна Комфорт 100%, Пластика Окон 96% и
т.д.), с искусственной задержкой, чтобы прогнать весь поток, деньги, сохранение и UI
по-настоящему. Под фиче-флагом/конфигом выбора реализации.

### 7.4. Доверие и риск «выдумал»
Результаты — кандидаты. Защита: каждый конкурент и источник несёт провенанс-ссылку
«где нашли»; клиент проверяет и сам выбирает; «уже есть проект» помечается; ничего не
создаётся без явного выбора клиента. Никаких авто-действий от имени агента.

---

## 8. Швы (отвлёкся / закрыл / вернулся) — по notes 6b/6i/6j
- Фоновый прогон не прерывается закрытием вкладки; возврат → «идёт…» или готовый результат.
- История в `autopodbor_runs`/`competitors`/`sources` → «Продолжить начатое» (§3.1).
- Поштучный прогресс: изученный конкурент открывается БЕЗ оплаты; неизученный — платно.
- Живое состояние источников при каждом заходе (§4.4); «Изменить проект» прямо оттуда.
- Уведомления — только в портале (значок/тост), без почты.

---

## 9. Хвосты реализации (трекать в плане)
1. **Уникальность имени проекта** при пачке одинаковых имён → тихий числовой суффикс (§4.5).
2. **Эмодзи поставщику** → strip перед `SupplierProjectDto.name`; проверить, принимает ли
   BG юникод (если принимает — можно не strip-ать). До проверки — strip по умолчанию.
3. **Реальный движок** — отдельная сессия; вставляется в розетку §7 без изменений вокруг.
4. **Настоящий hold-резерв в леджере** — отложен; вернуться при росте нагрузки (§5.2).
5. **`manage-project.js` GAPS** (workdays/regions игнор) — существующее ограничение синка,
   фича его не усугубляет; учесть при ожиданиях «дни/регионы у поставщика».

---

## 10. Тестирование
- **Backend (Pest):** RunService (идемпотентность — один in-flight, нет двойного debit);
  ChargeService (списание только при `done`, атомарность, новый тип проводки, money-инвариант);
  дедуп (конкуренты/источники/«уже есть проект»); нормализация домена/телефона; маппинг
  источник→ProjectService::create; суффикс уникального имени; strip эмодзи в DTO.
  Все тесты с записью через supplier-PDO — `uses(SharesSupplierPdo::class)` (memory-правило).
- **Контракт движка:** `FakeCompetitorAgent` + тесты, что поток целиком работает на заглушке.
- **RLS:** новые таблицы — `rls-reviewer` + проверки cross-tenant изоляции.
- **Frontend (Vitest):** состояния `s-loading`/`empty`/`done`; блокировка «уже есть проект»;
  подтверждение по названию (🟡); подтверждение цены; префлайт-409 переиспользование.
- **Деньги:** прогон billing-audit перед мержем.
- **UI-smoke:** прокликать поток на заглушке (Playwright), сверить с прототипом.

---

## 11. Открытые вопросы к владельцу (помечены 🟡 в тексте)
1. Подтверждение найденной по названию компании ДО оплаты (§3.3) — ок?
2. Экран «ничего не нашли», деньги не списываем (§3.7) — ок?
3. Источники только сайт+телефон, без SMS (§4.2) — ок?
4. Объём шага 1 = до 15 конкурентов, настраиваемо (§6) — ок?
5. Цена/вкл-выкл через админку, без выдуманных сумм (§5.4) — ок?

После «ок» по этим пятёрке и по документу в целом — перехожу к skill `writing-plans`
(план реализации). Код до утверждённого плана не пишу.
