# Автоподбор конкурентов — план реализации

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> **Проектный git-safety:** этот проект под стеной «роутер-наставник». Коммиты в субагентах — через `subagent-driven-development` (project-local wrapper, Pravila §15.1). Коммит — только после явного «эскейп» владельца. Тесты с записью через supplier-PDO ОБЯЗАНЫ `uses(SharesSupplierPdo::class)`.

**Goal:** Собрать фичу «Автоподбор конкурентов» целиком (экраны, фоновые платные прогоны, сохранение, дедуп, создание проектов) на заглушке движка, оставив «розетку» для будущего реального ИИ-агента.

**Architecture:** Vue-вкладка → `AutopodborController` → `AutopodborRunService` ставит фоновую джобу → джоба зовёт `CompetitorAgent` (интерфейс; v1 = `FakeCompetitorAgent`), сохраняет результат и при успехе списывает деньги (`AutopodborChargeService`, идемпотентно по run_id). Создание проектов переиспользует существующий `ProjectService`. Цена/вкл-выкл — через `system_settings`.

**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Redis-очереди, Pest 4, Vue 3 + Vuetify 3, Vitest.

**Источник истины:** дизайн-документ `docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md` + прототип `…-prototype.html`.

---

## КОНВЕНЦИИ (сверены с кодом 2026-06-28 — соблюдать во ВСЕХ задачах)

1. **RLS-GUC = `app.current_tenant_id`** (НЕ `app.tenant_id`). Политики:
   `USING (tenant_id = current_setting('app.current_tenant_id')::bigint)`.
2. **Tenant-контекст в джобах/сервисах с записью под RLS:** оборачивать в
   `DB::transaction(function(){ DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); ... })`
   — как в `app/app/Jobs/RouteSupplierLeadJob.php` и middleware `SetTenantContext.php`.
   **Нет** класса `TenantContext::set()` — не использовать.
3. **Тестовая БД:** отдельного соединения `pgsql_testing` НЕТ. Тесты сами идут на
   `liderra_testing` через `phpunit.xml`. Применить новые миграции к тестовой БД:
   `DB_DATABASE=liderra_testing php artisan migrate` (из `app/`). Боевую `liderra` НЕ трогать.
4. **Tenant/User в тестах:** `Tenant::factory()->create()` + `User::factory()->create(['tenant_id'=>$tenant->id])`.
   HTTP — `$this->actingAs($user)->postJson(...)`. Прямые model-тесты — mirror
   `tests/Feature/Billing/ProjectPreflightTest.php` (`set_config('app.current_tenant_id', ...)` в `beforeEach`).
   Хелпера `setTenant()` НЕТ.
5. **Ledger-образец:** мирорить `app/app/Services/Billing/LedgerService.php` + тест
   `tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` (bcmath, `balance_rub`, `balance_rub_after`).
6. **RefreshDatabase глобально ВЫКЛЮЧЕН.** Файлам с записью добавлять `uses(RefreshDatabase::class)`
   или mirror соседний тест. Cross-connection запись — `uses(\Tests\Concerns\SharesSupplierPdo::class)`.
7. **Bootstrap worktree уже сделан контроллером** (vendor/composer, `.env`, `storage/framework/*`,
   `liderra_testing` смигрирована до main). Субагенту vendor ставить НЕ нужно; нужно лишь
   применять СВОИ миграции к `liderra_testing` (п.3) перед своими тестами.

---

## Карта файлов (что создаём / трогаем)

**Backend — создаём:**
- `app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php`
- `app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php`
- `app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php`
- `app/app/Models/AutopodborRun.php`, `AutopodborCompetitor.php`, `AutopodborSource.php`
- `app/app/Services/Autopodbor/Agent/CompetitorAgent.php` (interface)
- `app/app/Services/Autopodbor/Agent/FakeCompetitorAgent.php`
- `app/app/Services/Autopodbor/Agent/Dto/*.php` (6 DTO)
- `app/app/Services/Autopodbor/AutopodborNormalizer.php`
- `app/app/Services/Autopodbor/AutopodborDedup.php`
- `app/app/Services/Autopodbor/AutopodborRunService.php`
- `app/app/Services/Autopodbor/AutopodborChargeService.php`
- `app/app/Services/Autopodbor/AutopodborProjectCreator.php`
- `app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php`
- `app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php`
- `app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php`
- `app/app/Http/Controllers/Api/AutopodborController.php`
- `app/app/Http/Resources/Autopodbor/*.php` (run/competitor/source resources)
- `app/app/Providers/AutopodborServiceProvider.php` (binding интерфейса)

**Backend — модифицируем:**
- `app/app/Models/BalanceTransaction.php` (+ константа типа)
- `app/routes/api.php` (роуты autopodbor)
- `db/schema.sql` + `db/CHANGELOG_schema.md` (3 таблицы, новый тип проводки)
- `app/config/app.php` или `bootstrap/providers.php` (регистрация провайдера)

**Frontend — создаём:**
- `app/resources/js/views/autopodbor/AutopodborView.vue` (контейнер + роутинг экранов)
- `app/resources/js/views/autopodbor/screens/*.vue` (Entry, AutoForm, ManualForm, ResolveConfirm, Loading, CompetitorList, SourceList, CreateProjects, Empty, Done)
- `app/resources/js/api/autopodbor.ts`
- `app/resources/js/stores/autopodborStore.ts`

**Frontend — модифицируем:**
- роутер (`app/resources/js/router/*`) — маршрут вкладки
- левое меню (компонент сайдбара) — пункт «Автоподбор конкурентов» NEW

---

# ФАЗА 1 — Данные

### Task 1: Миграция `autopodbor_runs`

**Files:**
- Create: `app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php`
- Test: `app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php`

- [ ] **Step 1: Написать падающий тест**

```php
<?php
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use function Pest\Laravel\artisan;

it('создаёт таблицу autopodbor_runs с tenant RLS', function () {
    expect(DB::getSchemaBuilder()->hasTable('autopodbor_runs'))->toBeTrue();
    expect(DB::getSchemaBuilder()->hasColumns('autopodbor_runs', [
        'id', 'tenant_id', 'kind', 'status', 'region_code', 'params',
        'competitor_id', 'price_rub_charged', 'balance_transaction_id',
        'error_code', 'created_at', 'started_at', 'finished_at',
    ]))->toBeTrue();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && ./vendor/bin/pest tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php`
Expected: FAIL — таблицы нет.

- [ ] **Step 3: Написать миграцию**

```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('autopodbor_runs', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedBigInteger('tenant_id');
            $table->string('kind', 16);              // search | study | resolve
            $table->string('status', 16)->default('queued'); // queued|running|done|empty|failed
            $table->smallInteger('region_code')->nullable();
            $table->jsonb('params')->default(DB::raw("'{}'::jsonb"));
            $table->unsignedBigInteger('competitor_id')->nullable();
            $table->decimal('price_rub_charged', 12, 2)->nullable();
            $table->unsignedBigInteger('balance_transaction_id')->nullable();
            $table->string('error_code', 64)->nullable();
            $table->timestampTz('created_at')->useCurrent();
            $table->timestampTz('started_at')->nullable();
            $table->timestampTz('finished_at')->nullable();

            $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
            $table->index(['tenant_id', 'status']);
            $table->index(['tenant_id', 'kind', 'status']);
        });

        DB::statement('ALTER TABLE autopodbor_runs ENABLE ROW LEVEL SECURITY');
        DB::statement('ALTER TABLE autopodbor_runs FORCE ROW LEVEL SECURITY');
        DB::statement("CREATE POLICY tenant_isolation ON autopodbor_runs USING (tenant_id = current_setting('app.current_tenant_id')::bigint)");
    }

    public function down(): void
    {
        Schema::dropIfExists('autopodbor_runs');
    }
};
```

- [ ] **Step 4: Прогнать миграции тестовой БД и тест**

Run: `cd app && DB_DATABASE=liderra_testing php artisan migrate && ./vendor/bin/pest tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php`
Expected: PASS.

- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** новую таблицу `autopodbor_runs` (версия схемы +1, дата, RLS-политика). Обновить header-метрики `db/schema.sql` (число таблиц/политик — канон в header).

- [ ] **Step 6: Commit** (после «эскейп»)

```bash
git add app/database/migrations/2026_06_28_100000_create_autopodbor_runs.php app/tests/Feature/Autopodbor/AutopodborRunsSchemaTest.php db/CHANGELOG_schema.md
git commit -m "feat(автоподбор): таблица autopodbor_runs + RLS"
```

### Task 2: Миграция `autopodbor_competitors`

**Files:**
- Create: `app/database/migrations/2026_06_28_100100_create_autopodbor_competitors.php`
- Test: дополнить `AutopodborRunsSchemaTest.php` (или новый `…CompetitorsSchemaTest.php`)

- [ ] **Step 1: Падающий тест**

```php
it('создаёт autopodbor_competitors', function () {
    expect(DB::getSchemaBuilder()->hasColumns('autopodbor_competitors', [
        'id','tenant_id','search_run_id','name','description','is_federal',
        'relevance_pct','origin','site_url','directory_urls','provenance',
        'dedup_key','study_run_id','studied_at','created_at',
    ]))->toBeTrue();
});
```

- [ ] **Step 2: Запустить — FAIL.**

- [ ] **Step 3: Миграция**

```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('autopodbor_competitors', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('search_run_id')->nullable();
            $table->string('name', 255);
            $table->text('description')->nullable();
            $table->boolean('is_federal')->default(false);
            $table->smallInteger('relevance_pct')->nullable();
            $table->string('origin', 16)->default('auto'); // auto|manual|resolve
            $table->string('site_url', 255)->nullable();
            $table->jsonb('directory_urls')->default(DB::raw("'[]'::jsonb"));
            $table->jsonb('provenance')->default(DB::raw("'{}'::jsonb"));
            $table->string('dedup_key', 255);
            $table->unsignedBigInteger('study_run_id')->nullable();
            $table->timestampTz('studied_at')->nullable();
            $table->timestampTz('created_at')->useCurrent();

            $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
            $table->foreign('search_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete();
            $table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete();
            $table->index(['tenant_id', 'search_run_id']);
            // дедуп конкурентов в рамках подбора (search_run_id NULL → пер-tenant для «своих»)
            $table->unique(['tenant_id', 'search_run_id', 'dedup_key'], 'autopodbor_competitor_dedup');
        });

        DB::statement('ALTER TABLE autopodbor_competitors ENABLE ROW LEVEL SECURITY');
        DB::statement('ALTER TABLE autopodbor_competitors FORCE ROW LEVEL SECURITY');
        DB::statement("CREATE POLICY tenant_isolation ON autopodbor_competitors USING (tenant_id = current_setting('app.current_tenant_id')::bigint)");
    }

    public function down(): void
    {
        Schema::dropIfExists('autopodbor_competitors');
    }
};
```

> NB: UNIQUE с NULL в `search_run_id` под Postgres по умолчанию NOT DISTINCT не сработает для нескольких NULL. Для «своих конкурентов» (search_run_id NULL) дедуп обеспечиваем в коде (`AutopodborDedup`, Task 9) — индекс ловит только авто-подбор. Это осознанно.

- [ ] **Step 4: Миграция + тест → PASS.**

- [ ] **Step 5: CHANGELOG_schema.md + header schema.sql.**

- [ ] **Step 6: Commit** «feat(автоподбор): таблица autopodbor_competitors + RLS».

### Task 3: Миграция `autopodbor_sources`

**Files:**
- Create: `app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php`
- Test: `…SourcesSchemaTest.php`

- [ ] **Step 1: Падающий тест** на колонки `id,tenant_id,competitor_id,study_run_id,signal_type,identifier,phone_kind,provenance_url,provenance_label,dedup_key,created_project_id,created_at`.

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Миграция**

```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('autopodbor_sources', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedBigInteger('tenant_id');
            $table->unsignedBigInteger('competitor_id');
            $table->unsignedBigInteger('study_run_id');
            $table->string('signal_type', 8);     // site | call
            $table->string('identifier', 255);    // голова домена / 7xxxxxxxxxx
            $table->string('phone_kind', 12)->nullable(); // real | substitute | null(site)
            $table->string('provenance_url', 500)->nullable();
            $table->string('provenance_label', 255)->nullable();
            $table->string('dedup_key', 255);
            $table->unsignedBigInteger('created_project_id')->nullable();
            $table->timestampTz('created_at')->useCurrent();

            $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
            $table->foreign('competitor_id')->references('id')->on('autopodbor_competitors')->cascadeOnDelete();
            $table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->cascadeOnDelete();
            $table->foreign('created_project_id')->references('id')->on('projects')->nullOnDelete();
            $table->unique(['competitor_id', 'dedup_key'], 'autopodbor_source_dedup');
            $table->index(['tenant_id', 'competitor_id']);
        });

        DB::statement('ALTER TABLE autopodbor_sources ENABLE ROW LEVEL SECURITY');
        DB::statement('ALTER TABLE autopodbor_sources FORCE ROW LEVEL SECURITY');
        DB::statement("CREATE POLICY tenant_isolation ON autopodbor_sources USING (tenant_id = current_setting('app.current_tenant_id')::bigint)");
    }

    public function down(): void
    {
        Schema::dropIfExists('autopodbor_sources');
    }
};
```

- [ ] **Step 4: Миграция + тест → PASS.**
- [ ] **Step 5: CHANGELOG_schema.md + header.**
- [ ] **Step 6: Commit** + прогнать `rls-reviewer` по трём таблицам.

### Task 4: Eloquent-модели

**Files:**
- Create: `app/app/Models/AutopodborRun.php`, `AutopodborCompetitor.php`, `AutopodborSource.php`
- Test: `app/tests/Feature/Autopodbor/AutopodborModelsTest.php`

- [ ] **Step 1: Падающий тест связей**

```php
<?php
use App\Models\{AutopodborRun, AutopodborCompetitor, AutopodborSource, Tenant};
use App\Support\TenantContext; // существующий помощник установки app.tenant_id

it('связывает run → competitors → sources', function () {
    $tenant = Tenant::factory()->create();
    TenantContext::set($tenant->id);

    $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]);
    $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna-komfort','relevance_pct'=>100]);
    $src = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']);

    expect($comp->sources()->count())->toBe(1)
        ->and($comp->searchRun->id)->toBe($run->id)
        ->and($src->competitor->id)->toBe($comp->id);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

> Проверить точное имя помощника установки tenant_id (поиск по коду: `current_setting('app.tenant_id')` / `set_config`). Если в проекте это `TenantContext::set` отсутствует — использовать существующий способ из тестов проектов.

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Модели**

```php
// AutopodborRun.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AutopodborRun extends Model {
    public $timestamps = false;
    protected $fillable = ['tenant_id','kind','status','region_code','params','competitor_id','price_rub_charged','balance_transaction_id','error_code','started_at','finished_at'];
    protected $casts = ['params'=>'array','price_rub_charged'=>'decimal:2','started_at'=>'datetime','finished_at'=>'datetime','created_at'=>'datetime'];
    public function competitors(){ return $this->hasMany(AutopodborCompetitor::class,'search_run_id'); }
}
```

```php
// AutopodborCompetitor.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AutopodborCompetitor extends Model {
    public $timestamps = false;
    protected $fillable = ['tenant_id','search_run_id','name','description','is_federal','relevance_pct','origin','site_url','directory_urls','provenance','dedup_key','study_run_id','studied_at'];
    protected $casts = ['is_federal'=>'bool','directory_urls'=>'array','provenance'=>'array','studied_at'=>'datetime','created_at'=>'datetime'];
    public function sources(){ return $this->hasMany(AutopodborSource::class,'competitor_id'); }
    public function searchRun(){ return $this->belongsTo(AutopodborRun::class,'search_run_id'); }
    public function studyRun(){ return $this->belongsTo(AutopodborRun::class,'study_run_id'); }
}
```

```php
// AutopodborSource.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AutopodborSource extends Model {
    public $timestamps = false;
    protected $fillable = ['tenant_id','competitor_id','study_run_id','signal_type','identifier','phone_kind','provenance_url','provenance_label','dedup_key','created_project_id'];
    protected $casts = ['created_at'=>'datetime'];
    public function competitor(){ return $this->belongsTo(AutopodborCompetitor::class,'competitor_id'); }
    public function project(){ return $this->belongsTo(Project::class,'created_project_id'); }
}
```

- [ ] **Step 4: Тест → PASS.**
- [ ] **Step 5: Commit** «feat(автоподбор): Eloquent-модели run/competitor/source».

### Task 5: Тип проводки + ключи настроек

**Files:**
- Modify: `app/app/Models/BalanceTransaction.php`
- Test: `app/tests/Unit/Autopodbor/BalanceTransactionTypeTest.php`

- [ ] **Step 1: Падающий тест**

```php
it('имеет тип autopodbor_charge', function () {
    expect(\App\Models\BalanceTransaction::TYPE_AUTOPODBOR_CHARGE)->toBe('autopodbor_charge');
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3:** добавить в `BalanceTransaction.php` рядом с остальными константами:

```php
public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge';
```

Если в модели есть валидирующий список типов (массив `TYPES`/правило `in:`) — дописать туда же.

- [ ] **Step 4: Тест → PASS.**

- [ ] **Step 5: Сидер ключей `system_settings`** — миграция-вставка (или сидер по образцу существующих флагов):
  - `autopodbor_enabled` (type=bool, default `0`, description «Автоподбор конкурентов: вкл/выкл вкладку»)
  - `autopodbor_price_search_rub` (type=decimal, value пусто/`0`, description «Цена подбора конкурентов, ₽»)
  - `autopodbor_price_study_rub` (type=decimal, description «Цена изучения конкурента, ₽»)
  - `autopodbor_max_competitors` (type=int, default `15`)

- [ ] **Step 6: Commit** «feat(автоподбор): тип проводки + ключи настроек».

---

# ФАЗА 2 — Розетка движка

### Task 6: DTO контракта агента

**Files:**
- Create: `app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php`, `FindCompetitorsResult.php`, `StudyCompetitorRequest.php`, `StudyCompetitorResult.php`, `ResolveByNameRequest.php`, `ResolveByNameResult.php`
- Test: `app/tests/Unit/Autopodbor/AgentDtoTest.php`

- [ ] **Step 1: Падающий тест**

```php
use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult};

it('конструирует FindCompetitorsRequest/Result', function () {
    $req = new FindCompetitorsRequest(regionCode:16, examples:['okna.ru'], aboutSelf:['description'=>'окна'], includeFederal:true, maxCompetitors:15);
    expect($req->regionCode)->toBe(16)->and($req->examples)->toContain('okna.ru');

    $res = new FindCompetitorsResult(competitors:[[
        'name'=>'Окна Комфорт','description'=>'окна','is_federal'=>false,'relevance_pct'=>100,
        'site_url'=>'okna-komfort.ru','directory_urls'=>['2gis'],'provenance'=>['src'=>'2gis'],
    ]]);
    expect($res->competitors[0]['name'])->toBe('Окна Комфорт');
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3:** реализовать 6 DTO как readonly-классы. Пример двух (остальные по аналогии — поля из §7.2 дизайна):

```php
// FindCompetitorsRequest.php
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsRequest {
    public function __construct(
        public readonly int $regionCode,
        public readonly array $examples,        // string[]
        public readonly array $aboutSelf,       // ['sites'=>[], 'directories'=>[], 'description'=>'']
        public readonly bool $includeFederal,
        public readonly int $maxCompetitors,
    ) {}
}
```

```php
// FindCompetitorsResult.php
<?php
namespace App\Services\Autopodbor\Agent\Dto;
final class FindCompetitorsResult {
    /** @param array<int,array{name:string,description:?string,is_federal:bool,relevance_pct:?int,site_url:?string,directory_urls:array,provenance:array}> $competitors */
    public function __construct(public readonly array $competitors) {}
}
```

Аналогично: `StudyCompetitorRequest(competitor:array, regionCode:int)`,
`StudyCompetitorResult(sources:array)` где source = `{signal_type,identifier,phone_kind?,provenance_url,provenance_label}`,
`ResolveByNameRequest(name:string, regionCode:int)`,
`ResolveByNameResult(candidates:array)`.

- [ ] **Step 4: Тест → PASS.**
- [ ] **Step 5: Commit** «feat(автоподбор): DTO контракта движка».

### Task 7: Интерфейс `CompetitorAgent` + `FakeCompetitorAgent` + binding

**Files:**
- Create: `app/app/Services/Autopodbor/Agent/CompetitorAgent.php`, `FakeCompetitorAgent.php`, `app/app/Providers/AutopodborServiceProvider.php`
- Modify: `app/bootstrap/providers.php` (регистрация)
- Test: `app/tests/Unit/Autopodbor/FakeCompetitorAgentTest.php`

- [ ] **Step 1: Падающий тест**

```php
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, StudyCompetitorRequest, ResolveByNameRequest};

it('заглушка отдаёт конкурентов, источники и кандидатов', function () {
    $agent = app(CompetitorAgent::class);
    $found = $agent->findCompetitors(new FindCompetitorsRequest(16, ['okna.ru'], [], true, 15));
    expect(count($found->competitors))->toBeGreaterThan(0)
        ->and($found->competitors[0])->toHaveKeys(['name','relevance_pct','site_url']);

    $study = $agent->studyCompetitor(new StudyCompetitorRequest(['name'=>'Окна Комфорт','site_url'=>'okna-komfort.ru'], 16));
    expect($study->sources[0])->toHaveKeys(['signal_type','identifier','provenance_url']);

    $resolve = $agent->resolveByName(new ResolveByNameRequest('Окна Комфорт', 16));
    expect($resolve->candidates)->not->toBeEmpty();
});
```

- [ ] **Step 2: FAIL** (binding нет).

- [ ] **Step 3: Интерфейс**

```php
// CompetitorAgent.php
<?php
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult, StudyCompetitorRequest, StudyCompetitorResult, ResolveByNameRequest, ResolveByNameResult};
interface CompetitorAgent {
    public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;
    public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;
    public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;
}
```

- [ ] **Step 4: Заглушка** — возвращает данные как в прототипе (4 конкурента с %, источники с настоящий/подменный, 1 кандидат). Полные данные — из `…-prototype.html` экраны `s-list`/`s-detail`. Добавить `usleep(300_000)` для имитации работы.

```php
// FakeCompetitorAgent.php (сокращённо — наполнить данными прототипа)
<?php
namespace App\Services\Autopodbor\Agent;
use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult, StudyCompetitorRequest, StudyCompetitorResult, ResolveByNameRequest, ResolveByNameResult};
final class FakeCompetitorAgent implements CompetitorAgent {
    public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult {
        return new FindCompetitorsResult([
            ['name'=>'Окна Комфорт','description'=>'Пластиковые окна и остекление балконов под ключ.','is_federal'=>false,'relevance_pct'=>100,'site_url'=>'okna-komfort-kzn.ru','directory_urls'=>['https://2gis.ru/…','https://yandex.ru/maps/…'],'provenance'=>['via'=>'similar-pages']],
            ['name'=>'Пластика Окон','description'=>'Окна ПВХ, лоджии, входные группы.','is_federal'=>false,'relevance_pct'=>96,'site_url'=>'plastika-okon-kzn.ru','directory_urls'=>['https://2gis.ru/…'],'provenance'=>['via'=>'similar-pages']],
            ['name'=>'Фабрика Окон','description'=>'Федеральная сеть окон ПВХ, филиал в регионе.','is_federal'=>true,'relevance_pct'=>84,'site_url'=>'fabrika-okon.ru/kazan','directory_urls'=>['https://2gis.ru/…'],'provenance'=>['via'=>'similar-pages']],
            ['name'=>'Балкон-Сервис 16','description'=>'Остекление балконов; окна частично.','is_federal'=>false,'relevance_pct'=>61,'site_url'=>null,'directory_urls'=>['https://yandex.ru/maps/…','https://2gis.ru/…'],'provenance'=>['via'=>'similar-pages']],
        ]);
    }
    public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult {
        return new StudyCompetitorResult([
            ['signal_type'=>'site','identifier'=>'okna-komfort-kzn.ru','phone_kind'=>null,'provenance_url'=>'https://2gis.ru/…','provenance_label'=>'2ГИС — карточка компании'],
            ['signal_type'=>'site','identifier'=>'okna-komfort.pro','phone_kind'=>null,'provenance_url'=>'https://yandex.ru/maps/…','provenance_label'=>'Яндекс.Карты — сайт в контактах'],
            ['signal_type'=>'call','identifier'=>'78432001122','phone_kind'=>'real','provenance_url'=>'https://2gis.ru/…','provenance_label'=>'2ГИС — карточка компании'],
            ['signal_type'=>'call','identifier'=>'78432009988','phone_kind'=>'substitute','provenance_url'=>'https://okna-komfort-kzn.ru','provenance_label'=>'номер в шапке (коллтрекинг)'],
            ['signal_type'=>'call','identifier'=>'79172001122','phone_kind'=>'real','provenance_url'=>'https://yandex.ru/maps/…','provenance_label'=>'Яндекс.Карты — карточка компании'],
        ]);
    }
    public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult {
        return new ResolveByNameResult([
            ['name'=>$r->name,'description'=>'Найдено по названию (заглушка).','site_url'=>'okna-komfort-kzn.ru','directory_urls'=>['https://2gis.ru/…'],'provenance'=>['via'=>'name-search']],
        ]);
    }
}
```

- [ ] **Step 5: Провайдер + регистрация**

```php
// AutopodborServiceProvider.php
<?php
namespace App\Providers;
use App\Services\Autopodbor\Agent\{CompetitorAgent, FakeCompetitorAgent};
use Illuminate\Support\ServiceProvider;
class AutopodborServiceProvider extends ServiceProvider {
    public function register(): void {
        // v1: заглушка. Реальный движок биндится здесь же, когда будет готов.
        $this->app->bind(CompetitorAgent::class, FakeCompetitorAgent::class);
    }
}
```

Добавить класс в `app/bootstrap/providers.php`.

- [ ] **Step 6: Тест → PASS.** Commit «feat(автоподбор): интерфейс движка + заглушка + binding».

---

# ФАЗА 3 — Нормализация и дедуп

### Task 8: `AutopodborNormalizer`

**Files:**
- Create: `app/app/Services/Autopodbor/AutopodborNormalizer.php`
- Test: `app/tests/Unit/Autopodbor/AutopodborNormalizerTest.php`

- [ ] **Step 1: Падающий тест**

```php
use App\Services\Autopodbor\AutopodborNormalizer;

it('нормализует домен в голову', function () {
    $n = new AutopodborNormalizer();
    expect($n->domainHead('https://www.Okna-Komfort.RU/contacts'))->toBe('okna-komfort.ru')
        ->and($n->domainHead('okna.pro'))->toBe('okna.pro');
});

it('нормализует телефон в 7xxxxxxxxxx', function () {
    $n = new AutopodborNormalizer();
    expect($n->phone('+7 (843) 200-11-22'))->toBe('78432001122')
        ->and($n->phone('8 843 2001122'))->toBe('78432001122');
});

it('строит dedup_key источника', function () {
    $n = new AutopodborNormalizer();
    expect($n->sourceKey('site','https://OKNA.ru/'))->toBe('site:okna.ru')
        ->and($n->sourceKey('call','8-843-200-11-22'))->toBe('call:78432001122');
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация.** Для телефона переиспользовать существующий `PhoneNormalizer` (используется в `StoreProjectRequest`); для домена — срезать схему/www/путь/порт, lower-case.

```php
<?php
namespace App\Services\Autopodbor;
use App\Support\PhoneNormalizer; // проверить точный namespace существующего нормалайзера
final class AutopodborNormalizer {
    public function domainHead(string $raw): string {
        $s = trim(mb_strtolower($raw));
        $s = preg_replace('#^[a-z]+://#', '', $s);   // схема
        $s = preg_replace('#^www\.#', '', $s);        // www
        $s = explode('/', $s)[0];                      // путь
        $s = explode('?', $s)[0];
        $s = explode(':', $s)[0];                      // порт
        return $s;
    }
    public function phone(string $raw): string {
        return PhoneNormalizer::normalize($raw); // вернёт 7xxxxxxxxxx
    }
    public function sourceKey(string $type, string $identifier): string {
        $id = $type === 'call' ? $this->phone($identifier) : $this->domainHead($identifier);
        return $type.':'.$id;
    }
    public function competitorKey(string $name, ?string $siteUrl): string {
        if ($siteUrl) return 'site:'.$this->domainHead($siteUrl);
        return 'name:'.preg_replace('#\s+#',' ', trim(mb_strtolower($name)));
    }
}
```

> Проверить точную сигнатуру `PhoneNormalizer::normalize` (статический ли). Если телефон уже `7xxxxxxxxxx` без `+` — формат совпадает с требованием BG.

- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): нормализация домена/телефона».

### Task 9: `AutopodborDedup`

**Files:**
- Create: `app/app/Services/Autopodbor/AutopodborDedup.php`
- Test: `app/tests/Feature/Autopodbor/AutopodborDedupTest.php`

- [ ] **Step 1: Падающие тесты** (3 уровня дедупа):

```php
use App\Models\{AutopodborCompetitor, AutopodborSource, Project, Tenant};
use App\Services\Autopodbor\AutopodborDedup;

it('помечает источник как уже существующий проект', function () {
    $tenant = Tenant::factory()->create(); setTenant($tenant->id); // helper из проекта
    Project::factory()->create(['tenant_id'=>$tenant->id,'signal_type'=>'site','signal_identifier'=>'okna.ru']);
    $dedup = app(AutopodborDedup::class);
    expect($dedup->existingProjectId($tenant->id,'site','okna.ru'))->not->toBeNull()
        ->and($dedup->existingProjectId($tenant->id,'site','drugoy.ru'))->toBeNull();
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('дедупит источники внутри списка', function () {
    $dedup = app(AutopodborDedup::class);
    $unique = $dedup->dedupSources([
        ['signal_type'=>'call','identifier'=>'+7 843 200-11-22'],
        ['signal_type'=>'call','identifier'=>'88432001122'],   // тот же номер
        ['signal_type'=>'site','identifier'=>'www.okna.ru'],
    ]);
    expect($unique)->toHaveCount(2);
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация** (зависит от `AutopodborNormalizer`):

```php
<?php
namespace App\Services\Autopodbor;
use App\Models\Project;
final class AutopodborDedup {
    public function __construct(private AutopodborNormalizer $norm) {}

    public function existingProjectId(int $tenantId, string $signalType, string $identifier): ?int {
        $needle = $signalType === 'call' ? $this->norm->phone($identifier) : $this->norm->domainHead($identifier);
        return Project::query()
            ->where('tenant_id', $tenantId)
            ->where('signal_type', $signalType)
            ->where('signal_identifier', $needle)
            ->value('id');
    }

    /** @return array<int,array> уникальные источники с проставленным dedup_key */
    public function dedupSources(array $sources): array {
        $seen = []; $out = [];
        foreach ($sources as $s) {
            $key = $this->norm->sourceKey($s['signal_type'], $s['identifier']);
            if (isset($seen[$key])) continue;
            $seen[$key] = true;
            $s['dedup_key'] = $key;
            $out[] = $s;
        }
        return $out;
    }

    /** @return array<int,array> уникальные конкуренты */
    public function dedupCompetitors(array $competitors): array {
        $seen = []; $out = [];
        foreach ($competitors as $c) {
            $key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null);
            if (isset($seen[$key])) continue;
            $seen[$key] = true;
            $c['dedup_key'] = $key;
            $out[] = $c;
        }
        return $out;
    }
}
```

- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): дедуп конкурентов/источников/проектов».

---

# ФАЗА 4 — Деньги

### Task 10: `AutopodborChargeService` (идемпотентное списание только за успех)

**Files:**
- Create: `app/app/Services/Autopodbor/AutopodborChargeService.php`
- Test: `app/tests/Feature/Autopodbor/AutopodborChargeServiceTest.php`

- [ ] **Step 1: Падающие тесты** (money-инварианты):

```php
use App\Models\{AutopodborRun, BalanceTransaction, Tenant};
use App\Services\Autopodbor\AutopodborChargeService;

it('списывает один раз и идемпотентно по run_id', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>1000]); setTenant($tenant->id);
    $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'running','params'=>[]]);
    $svc = app(AutopodborChargeService::class);

    $svc->chargeForRun($run, '300.00');
    $svc->chargeForRun($run->fresh(), '300.00'); // повтор не должен списать второй раз

    $tenant->refresh(); $run->refresh();
    expect((string)$tenant->balance_rub)->toBe('700.00')
        ->and($run->price_rub_charged)->not->toBeNull()
        ->and(BalanceTransaction::where('type','autopodbor_charge')->where('related_id',$run->id)->count())->toBe(1);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('не списывает при нехватке баланса (бросает)', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>100]); setTenant($tenant->id);
    $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'running','params'=>[]]);
    expect(fn() => app(AutopodborChargeService::class)->chargeForRun($run, '300.00'))
        ->toThrow(\App\Exceptions\InsufficientBalanceException::class);
    expect((string)$tenant->fresh()->balance_rub)->toBe('100.00');
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация** — по образцу `LedgerService::chargeForDelivery`: транзакция + `lockForUpdate` по tenant, bcmath, идемпотентность по `balance_transaction_id`.

```php
<?php
namespace App\Services\Autopodbor;
use App\Exceptions\InsufficientBalanceException;
use App\Models\{AutopodborRun, BalanceTransaction, Tenant};
use Illuminate\Support\Facades\DB;
final class AutopodborChargeService {
    public function chargeForRun(AutopodborRun $run, string $priceRub): void {
        DB::transaction(function () use ($run, $priceRub) {
            // идемпотентность: блокируем строку прогона
            $locked = AutopodborRun::whereKey($run->id)->lockForUpdate()->first();
            if ($locked->balance_transaction_id !== null) {
                return; // уже списано
            }
            $tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->first();
            if (bccomp((string)$tenant->balance_rub, $priceRub, 2) < 0) {
                throw new InsufficientBalanceException();
            }
            $newBalance = bcsub((string)$tenant->balance_rub, $priceRub, 2);
            $tenant->balance_rub = $newBalance;
            $tenant->save();

            $tx = BalanceTransaction::create([
                'tenant_id' => $tenant->id,
                'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE,
                'amount_rub' => '-'.$priceRub,
                'balance_rub_after' => $newBalance,
                'related_type' => AutopodborRun::class,
                'related_id' => $locked->id,
            ]);
            $locked->balance_transaction_id = $tx->id;
            $locked->price_rub_charged = $priceRub;
            $locked->save();
        });
    }
}
```

> Сверить точные имена колонок `tenants.balance_rub` и поля `BalanceTransaction` (`amount_rub`, `balance_rub_after`, `related_type`, `related_id`) по моделям. Сверить класс `InsufficientBalanceException` (используется `LedgerService`).

- [ ] **Step 4: Тесты → PASS.**
- [ ] **Step 5: billing-audit** по этому сервису (money-инвариант: сумма проводок = изменение баланса; идемпотентность; bcmath, без float). Зафиксировать вывод в конце.
- [ ] **Step 6: Commit** «feat(автоподбор): идемпотентное списание за прогон».

---

# ФАЗА 5 — Сервис прогонов и джобы

### Task 11: `AutopodborRunService` (старт, гейт баланса, один in-flight)

**Files:**
- Create: `app/app/Services/Autopodbor/AutopodborRunService.php`
- Test: `app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php`

- [ ] **Step 1: Падающие тесты**

```php
use App\Models\{AutopodborRun, Tenant};
use App\Services\Autopodbor\AutopodborRunService;
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
use Illuminate\Support\Facades\Queue;

it('стартует search и ставит джобу', function () {
    Queue::fake();
    $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id);
    $run = app(AutopodborRunService::class)->startSearch($tenant->id, 16, ['okna.ru'], [], true);
    expect($run->kind)->toBe('search')->and($run->status)->toBe('queued');
    Queue::assertPushed(RunAutopodborSearchJob::class);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('не стартует второй in-flight search того же tenant', function () {
    Queue::fake();
    $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id);
    $svc = app(AutopodborRunService::class);
    $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true);
    expect(fn() => $svc->startSearch($tenant->id, 16, ['okna.ru'], [], true))
        ->toThrow(\App\Exceptions\Autopodbor\RunInFlightException::class);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('гейтит по балансу до старта (не хватает на цену)', function () {
    Queue::fake();
    \App\Support\SystemSettings::shouldReceiveOrSet('autopodbor_price_search_rub','500'); // см. helper ниже
    $tenant = Tenant::factory()->create(['balance_rub'=>100]); setTenant($tenant->id);
    expect(fn() => app(AutopodborRunService::class)->startSearch($tenant->id,16,['okna.ru'],[],true))
        ->toThrow(\App\Exceptions\InsufficientBalanceException::class);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

> Цену в тестах ставить через реальную запись в `system_settings` (не мок), если в проекте нет удобного фейка: `SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'500','type'=>'decimal'])`. Заменить псевдо-`shouldReceiveOrSet` на это.

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Исключения** — создать `app/app/Exceptions/Autopodbor/RunInFlightException.php` (наследник базового), переиспользовать существующий `InsufficientBalanceException`.

- [ ] **Step 4: Реализация**

```php
<?php
namespace App\Services\Autopodbor;
use App\Exceptions\InsufficientBalanceException;
use App\Exceptions\Autopodbor\RunInFlightException;
use App\Jobs\Autopodbor\{RunAutopodborSearchJob, RunAutopodborStudyJob, RunAutopodborResolveJob};
use App\Models\{AutopodborRun, AutopodborCompetitor, Tenant};
use App\Support\SystemSettings;
final class AutopodborRunService {
    private function assertNoInFlight(int $tenantId, string $kind): void {
        $exists = AutopodborRun::where('tenant_id',$tenantId)->where('kind',$kind)
            ->whereIn('status',['queued','running'])->exists();
        if ($exists) throw new RunInFlightException();
    }
    private function priceGate(int $tenantId, string $key): string {
        $price = (string)(SystemSettings::get($key) ?? '0');
        $balance = (string)Tenant::whereKey($tenantId)->value('balance_rub');
        if (bccomp($balance, $price, 2) < 0) throw new InsufficientBalanceException();
        return $price;
    }
    public function startSearch(int $tenantId, int $regionCode, array $examples, array $aboutSelf, bool $includeFederal): AutopodborRun {
        $this->assertNoInFlight($tenantId,'search');
        $this->priceGate($tenantId,'autopodbor_price_search_rub');
        $run = AutopodborRun::create(['tenant_id'=>$tenantId,'kind'=>'search','status'=>'queued','region_code'=>$regionCode,
            'params'=>['examples'=>$examples,'about_self'=>$aboutSelf,'include_federal'=>$includeFederal]]);
        RunAutopodborSearchJob::dispatch($run->id);
        return $run;
    }
    public function startStudy(int $tenantId, int $competitorId): AutopodborRun {
        $comp = AutopodborCompetitor::where('tenant_id',$tenantId)->findOrFail($competitorId);
        if ($comp->studied_at !== null) return $comp->studyRun; // уже изучен — бесплатно
        $this->assertNoInFlight($tenantId,'study');
        $this->priceGate($tenantId,'autopodbor_price_study_rub');
        $run = AutopodborRun::create(['tenant_id'=>$tenantId,'kind'=>'study','status'=>'queued',
            'region_code'=>$comp->searchRun->region_code ?? null,'competitor_id'=>$comp->id,'params'=>[]]);
        RunAutopodborStudyJob::dispatch($run->id);
        return $run;
    }
    public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun {
        $this->assertNoInFlight($tenantId,'resolve');
        // resolve бесплатный — без priceGate
        $run = AutopodborRun::create(['tenant_id'=>$tenantId,'kind'=>'resolve','status'=>'queued',
            'region_code'=>$regionCode,'params'=>['name'=>$name]]);
        RunAutopodborResolveJob::dispatch($run->id);
        return $run;
    }
}
```

- [ ] **Step 5: Тесты → PASS.** Commit «feat(автоподбор): RunService — старт, гейт баланса, один in-flight».

### Task 12: `RunAutopodborSearchJob`

**Files:**
- Create: `app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php`
- Test: `app/tests/Feature/Autopodbor/RunAutopodborSearchJobTest.php`

- [ ] **Step 1: Падающие тесты**

```php
use App\Models\{AutopodborRun, AutopodborCompetitor, BalanceTransaction, Tenant};
use App\Jobs\Autopodbor\RunAutopodborSearchJob;

it('сохраняет конкурентов, ставит done и списывает', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id);
    \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'500','type'=>'decimal']);
    $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'queued','region_code'=>16,'params'=>['examples'=>['okna.ru'],'about_self'=>[],'include_federal'=>true]]);

    (new RunAutopodborSearchJob($run->id))->handle(app(\App\Services\Autopodbor\CompetitorAgentRunner::class)); // см. ниже про DI

    $run->refresh();
    expect($run->status)->toBe('done')->and($run->price_rub_charged)->toBe('500.00')
        ->and(AutopodborCompetitor::where('search_run_id',$run->id)->count())->toBeGreaterThan(0)
        ->and(AutopodborCompetitor::where('search_run_id',$run->id)->orderByDesc('relevance_pct')->first()->relevance_pct)->toBe(100);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('пустой результат → empty, без списания', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id);
    \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'500','type'=>'decimal']);
    $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'queued','region_code'=>16,'params'=>['examples'=>[],'about_self'=>[],'include_federal'=>false]]);
    // подменяем агента на пустого
    app()->bind(\App\Services\Autopodbor\Agent\CompetitorAgent::class, \Tests\Doubles\EmptyCompetitorAgent::class);

    (new RunAutopodborSearchJob($run->id))->handle(app(\App\Services\Autopodbor\Agent\CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), app(\App\Services\Autopodbor\AutopodborChargeService::class), new \App\Support\SystemSettings());

    $run->refresh();
    expect($run->status)->toBe('empty')->and($run->price_rub_charged)->toBeNull()
        ->and((string)$tenant->fresh()->balance_rub)->toBe('100000.00');
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

> Создать тест-дубль `tests/Doubles/EmptyCompetitorAgent.php` (реализует интерфейс, отдаёт пустые массивы). Привести сигнатуру `handle()` к единой DI-форме (см. Step 3) и использовать её в обоих тестах.

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация**

```php
<?php
namespace App\Jobs\Autopodbor;
use App\Models\{AutopodborRun, AutopodborCompetitor};
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\{AutopodborDedup, AutopodborChargeService};
use App\Support\SystemSettings;
use App\Support\TenantContext; // установка app.tenant_id внутри джобы
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RunAutopodborSearchJob implements ShouldQueue {
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    public int $tries = 3;
    public array $backoff = [15, 60, 300];
    public function __construct(public int $runId) {}

    public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, SystemSettings $settings): void {
        $run = AutopodborRun::findOrFail($this->runId);
        TenantContext::set($run->tenant_id);
        $run->update(['status'=>'running','started_at'=>now()]);
        try {
            $p = $run->params;
            $max = (int)($settings->get('autopodbor_max_competitors') ?? 15);
            $res = $agent->findCompetitors(new FindCompetitorsRequest(
                $run->region_code, $p['examples'] ?? [], $p['about_self'] ?? [], (bool)($p['include_federal'] ?? false), $max));
            $unique = $dedup->dedupCompetitors($res->competitors);
            if (empty($unique)) { $run->update(['status'=>'empty','finished_at'=>now()]); return; }
            foreach (array_slice($unique, 0, $max) as $c) {
                AutopodborCompetitor::create([
                    'tenant_id'=>$run->tenant_id,'search_run_id'=>$run->id,'name'=>$c['name'],
                    'description'=>$c['description'] ?? null,'is_federal'=>(bool)($c['is_federal'] ?? false),
                    'relevance_pct'=>$c['relevance_pct'] ?? null,'origin'=>'auto','site_url'=>$c['site_url'] ?? null,
                    'directory_urls'=>$c['directory_urls'] ?? [],'provenance'=>$c['provenance'] ?? [],'dedup_key'=>$c['dedup_key'],
                ]);
            }
            $price = (string)($settings->get('autopodbor_price_search_rub') ?? '0');
            $charge->chargeForRun($run, $price);
            $run->update(['status'=>'done','finished_at'=>now()]);
        } catch (\Throwable $e) {
            $run->update(['status'=>'failed','error_code'=>substr($e->getMessage(),0,64),'finished_at'=>now()]);
            throw $e; // отдать на ретрай
        }
    }
}
```

> Сверить способ установки tenant в джобах по существующим (`SyncSupplierProjectsJob` / supplier-джобы): как именно ставят `app.tenant_id` (через `TenantContext`/`set_config`/`pgsql_supplier`). Привести к проектному паттерну.

- [ ] **Step 4: Тесты → PASS.** Commit «feat(автоподбор): джоба шага 1 (подбор)».

### Task 13: `RunAutopodborStudyJob`

**Files:**
- Create: `app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php`
- Test: `app/tests/Feature/Autopodbor/RunAutopodborStudyJobTest.php`

- [ ] **Step 1: Падающие тесты**

```php
it('сохраняет источники, помечает конкурента изученным, списывает', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id);
    \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_study_rub'],['value'=>'900','type'=>'decimal']);
    $searchRun = \App\Models\AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'search','status'=>'done','region_code'=>16,'params'=>[]]);
    $comp = \App\Models\AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$searchRun->id,'name'=>'Окна Комфорт','dedup_key'=>'okna','site_url'=>'okna-komfort.ru']);
    $run = \App\Models\AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'queued','competitor_id'=>$comp->id,'region_code'=>16,'params'=>[]]);

    (new \App\Jobs\Autopodbor\RunAutopodborStudyJob($run->id))->handle(app(\App\Services\Autopodbor\Agent\CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), app(\App\Services\Autopodbor\AutopodborChargeService::class), new \App\Support\SystemSettings());

    $run->refresh(); $comp->refresh();
    expect($run->status)->toBe('done')->and($run->price_rub_charged)->toBe('900.00')
        ->and($comp->studied_at)->not->toBeNull()
        ->and(\App\Models\AutopodborSource::where('competitor_id',$comp->id)->count())->toBeGreaterThan(0);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация** (аналогична Task 12, но `studyCompetitor`, источники, `dedupSources`, проставить `studied_at`/`study_run_id` конкуренту):

```php
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, SystemSettings $settings): void {
    $run = AutopodborRun::findOrFail($this->runId); TenantContext::set($run->tenant_id);
    $run->update(['status'=>'running','started_at'=>now()]);
    try {
        $comp = AutopodborCompetitor::findOrFail($run->competitor_id);
        $res = $agent->studyCompetitor(new StudyCompetitorRequest(
            ['name'=>$comp->name,'site_url'=>$comp->site_url,'directory_urls'=>$comp->directory_urls], $run->region_code));
        $unique = $dedup->dedupSources($res->sources);
        if (empty($unique)) { $run->update(['status'=>'empty','finished_at'=>now()]); return; }
        foreach ($unique as $s) {
            AutopodborSource::create([
                'tenant_id'=>$run->tenant_id,'competitor_id'=>$comp->id,'study_run_id'=>$run->id,
                'signal_type'=>$s['signal_type'],'identifier'=>$this->normIdentifier($s),'phone_kind'=>$s['phone_kind'] ?? null,
                'provenance_url'=>$s['provenance_url'] ?? null,'provenance_label'=>$s['provenance_label'] ?? null,'dedup_key'=>$s['dedup_key'],
            ]);
        }
        $price = (string)($settings->get('autopodbor_price_study_rub') ?? '0');
        $charge->chargeForRun($run, $price);
        $comp->update(['studied_at'=>now(),'study_run_id'=>$run->id]);
        $run->update(['status'=>'done','finished_at'=>now()]);
    } catch (\Throwable $e) {
        $run->update(['status'=>'failed','error_code'=>substr($e->getMessage(),0,64),'finished_at'=>now()]); throw $e;
    }
}
```

> `normIdentifier` — приватный хелпер: вызвать `AutopodborNormalizer` через DI (добавить в конструктор/`handle`) или нормализовать в `dedupSources` (он уже строит dedup_key; пусть кладёт ещё и нормализованный `identifier`). Привести к одному месту нормализации, чтобы `identifier` в БД был уже головой домена / `7xxxxxxxxxx`.

- [ ] **Step 4: Тесты → PASS.** Commit «feat(автоподбор): джоба шага 2 (изучение)».

### Task 14: `RunAutopodborResolveJob`

**Files:**
- Create: `app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php`
- Test: `app/tests/Feature/Autopodbor/RunAutopodborResolveJobTest.php`

- [ ] **Step 1: Падающий тест** — резолв по названию создаёт `autopodbor_competitors` с `origin='resolve'`, run `done`, БЕЗ списания (`price_rub_charged` null). Несколько кандидатов → несколько строк.

```php
it('резолв по названию — кандидаты без списания', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>100000]); setTenant($tenant->id);
    $run = \App\Models\AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'resolve','status'=>'queued','region_code'=>16,'params'=>['name'=>'Окна Комфорт']]);
    (new \App\Jobs\Autopodbor\RunAutopodborResolveJob($run->id))->handle(app(\App\Services\Autopodbor\Agent\CompetitorAgent::class), app(\App\Services\Autopodbor\AutopodborDedup::class), new \App\Support\SystemSettings());
    $run->refresh();
    expect($run->status)->toBe('done')->and($run->price_rub_charged)->toBeNull()
        ->and(\App\Models\AutopodborCompetitor::where('search_run_id',$run->id)->where('origin','resolve')->count())->toBeGreaterThan(0);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** — как Task 12, но `resolveByName`, сохранять кандидатов с `origin='resolve'`, `search_run_id=run->id`, `relevance_pct=null`; БЕЗ `chargeForRun`. Пусто → `empty`.
- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): джоба резолва по названию».

---

# ФАЗА 6 — Создание проектов из источников

### Task 15: `AutopodborProjectCreator` (батч + уникальное имя + strip эмодзи)

**Files:**
- Create: `app/app/Services/Autopodbor/AutopodborProjectCreator.php`
- Test: `app/tests/Feature/Autopodbor/AutopodborProjectCreatorTest.php`

- [ ] **Step 1: Падающие тесты**

```php
use App\Models\{AutopodborCompetitor, AutopodborSource, Project, Tenant};
use App\Services\Autopodbor\AutopodborProjectCreator;

it('создаёт проекты из выбранных источников с общими настройками', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>500000]); setTenant($tenant->id);
    $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']);
    $s1 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'site','identifier'=>'okna-komfort.ru','dedup_key'=>'site:okna-komfort.ru']);
    $s2 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'call','identifier'=>'78432001122','phone_kind'=>'real','dedup_key'=>'call:78432001122']);

    $creator = app(AutopodborProjectCreator::class);
    $projects = $creator->createFromSources($tenant->id, [$s1->id, $s2->id], [
        'regions'=>[16],'daily_limit_target'=>20,'delivery_days_mask'=>127,
    ], launch:false);

    expect($projects)->toHaveCount(2)
        ->and(Project::where('tenant_id',$tenant->id)->where('signal_identifier','okna-komfort.ru')->exists())->toBeTrue();
    expect($s1->fresh()->created_project_id)->not->toBeNull();
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('разруливает коллизию одинаковых имён суффиксом', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>500000]); setTenant($tenant->id);
    $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']);
    $s1 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'site','identifier'=>'a.ru','dedup_key'=>'site:a.ru']);
    $s2 = AutopodborSource::create(['tenant_id'=>$tenant->id,'competitor_id'=>$comp->id,'study_run_id'=>1,'signal_type'=>'site','identifier'=>'b.ru','dedup_key'=>'site:b.ru']);
    $projects = app(AutopodborProjectCreator::class)->createFromSources($tenant->id,[$s1->id,$s2->id],['regions'=>[16],'daily_limit_target'=>20,'delivery_days_mask'=>127], false);
    $names = Project::where('tenant_id',$tenant->id)->pluck('name')->all();
    expect($names)->toContain('Окна Комфорт')->and(collect($names)->unique()->count())->toBe(2); // второе с суффиксом
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Реализация** — переиспользовать `ProjectService::create`; имя = имя конкурента + значок (✓ для real-phone, 🎭 для substitute, без значка для site); при коллизии `assertNameUnique` ловить и добавлять суффикс « 2», « 3». Привязать `created_project_id` к источнику.

```php
<?php
namespace App\Services\Autopodbor;
use App\Models\{AutopodborSource, Project};
use App\Services\Project\ProjectService;
final class AutopodborProjectCreator {
    public function __construct(private ProjectService $projects) {}

    /** @return Project[] */
    public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array {
        $tenant = \App\Models\Tenant::findOrFail($tenantId);
        $sources = AutopodborSource::where('tenant_id',$tenantId)->whereIn('id',$sourceIds)->with('competitor')->get();
        $created = [];
        foreach ($sources as $src) {
            $base = $this->displayName($src);
            $name = $this->uniqueName($tenantId, $base);
            $project = $this->projects->create($tenant, [
                'name'=>$name,
                'signal_type'=>$src->signal_type,
                'signal_identifier'=>$src->identifier,
                'daily_limit_target'=>$common['daily_limit_target'],
                'regions'=>$common['regions'],
                'delivery_days_mask'=>$common['delivery_days_mask'],
                'force_save_blocked'=> $launch ? false : true, // без запуска — допускаем блок-без-баланса? см. NB
            ]);
            $src->update(['created_project_id'=>$project->id]);
            $created[] = $project;
        }
        return $created;
    }

    private function displayName(AutopodborSource $s): string {
        $n = $s->competitor->name;
        if ($s->signal_type === 'call' && $s->phone_kind === 'real') return $n.' ✓';
        if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') return $n.' 🎭';
        return $n;
    }

    private function uniqueName(int $tenantId, string $base): string {
        $name = $base; $i = 1;
        while (Project::where('tenant_id',$tenantId)->where('name',$name)->exists()) {
            $i++; $name = $base.' '.$i;
        }
        return $name;
    }
}
```

> NB по `force_save_blocked`: «Создать без запуска» и «Создать и запустить» — разное поведение префлайта. Решить в Task 17 (контроллер): «запустить» гоняет префлайт и при нехватке отдаёт 409; «без запуска» создаёт (возможно с `preflight_blocked_at`). Не дублировать логику здесь — пробрасывать флаг. Сверить, как `ProjectService::create` трактует `force_save_blocked`.

- [ ] **Step 4: Тесты → PASS.** Commit «feat(автоподбор): создание проектов из источников + уникальное имя».

### Task 16: Strip эмодзи в DTO поставщику

**Files:**
- Modify: `app/app/Services/Supplier/Dto/SupplierProjectDto.php` (или место сборки `name` для синка)
- Test: `app/tests/Unit/Autopodbor/SupplierNameStripTest.php`

- [ ] **Step 1: Падающий тест**

```php
it('очищает значок из имени для поставщика', function () {
    $clean = \App\Services\Supplier\Dto\SupplierProjectDto::stripBadge('Окна Комфорт 🎭');
    expect($clean)->toBe('Окна Комфорт');
    expect(\App\Services\Supplier\Dto\SupplierProjectDto::stripBadge('Окна ✓'))->toBe('Окна');
});
```

- [ ] **Step 2: FAIL.**

- [ ] **Step 3:** добавить статический `stripBadge(string): string` (срезать хвостовые ` ✓`/` 🎭` и любые эмодзи) и применить при сборке `name` для отправки поставщику (там, где `dto.name` формируется перед `manage-project.js`).

```php
public static function stripBadge(string $name): string {
    // убрать хвостовые значки и эмодзи, схлопнуть пробелы
    $n = preg_replace('/[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}\x{2705}]/u', '', $name);
    return trim(preg_replace('/\s+/u', ' ', $n));
}
```

- [ ] **Step 4: Тест → PASS.**
- [ ] **Step 5: Хвост §9 дизайна** — проверить вручную/в задаче, принимает ли BG юникод в имени; если да, strip можно ослабить. Пока strip по умолчанию.
- [ ] **Step 6: Commit** «feat(автоподбор): очистка значка имени для поставщика».

---

# ФАЗА 7 — API

### Task 17: `AutopodborController` + роуты + Resources

**Files:**
- Create: `app/app/Http/Controllers/Api/AutopodborController.php`, `app/app/Http/Resources/Autopodbor/{RunResource,CompetitorResource,SourceResource}.php`
- Modify: `app/routes/api.php`
- Test: `app/tests/Feature/Autopodbor/AutopodborApiTest.php`

- [ ] **Step 1: Падающие тесты** (через HTTP, аутентифицированный клиент):

```php
it('GET /api/autopodbor/state отдаёт историю и доступность', function () {
    $tenant = Tenant::factory()->create(); $user = userFor($tenant); // существующий helper логина
    \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_enabled'],['value'=>'1','type'=>'bool']);
    $this->actingAs($user)->getJson('/api/autopodbor/state')
        ->assertOk()->assertJsonStructure(['enabled','runs','prices'=>['search','study']]);
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('POST /api/autopodbor/search стартует прогон', function () {
    $tenant = Tenant::factory()->create(['balance_rub'=>100000]); $user = userFor($tenant);
    \App\Models\SystemSetting::updateOrCreate(['key'=>'autopodbor_price_search_rub'],['value'=>'1','type'=>'decimal']);
    \Illuminate\Support\Facades\Queue::fake();
    $this->actingAs($user)->postJson('/api/autopodbor/search',[
        'region_code'=>16,'examples'=>['okna.ru'],'about_self'=>[],'include_federal'=>true,
    ])->assertCreated()->assertJsonPath('data.kind','search');
})->uses(\Tests\Concerns\SharesSupplierPdo::class);

it('POST /api/autopodbor/projects создаёт проекты из источников', function () {
    // подготовить competitor+sources, выбрать 2, ожидать 5xx? нет — 201 и проекты
    // ... (по образцу Task 15) → assertCreated, projects exist
})->uses(\Tests\Concerns\SharesSupplierPdo::class);
```

> Узнать точные helper'ы тестов аутентификации клиента (как в `tests/Feature` для projects). Привести `userFor`/`actingAs` к проектному стилю.

- [ ] **Step 2: FAIL.**

- [ ] **Step 3: Роуты** в `routes/api.php` (под middleware tenant-аутентификации клиента, рядом с `projects`):

```php
Route::prefix('autopodbor')->group(function () {
    Route::get('state', [AutopodborController::class, 'state']);
    Route::get('runs/{run}', [AutopodborController::class, 'run']);
    Route::get('competitors/{competitor}', [AutopodborController::class, 'competitor']); // источники + живое «уже есть проект»
    Route::post('search', [AutopodborController::class, 'search']);
    Route::post('study', [AutopodborController::class, 'study']);       // {competitor_id}
    Route::post('resolve', [AutopodborController::class, 'resolve']);   // {name, region_code}
    Route::post('manual-study', [AutopodborController::class, 'manualStudy']); // {site_url|directory|competitor_id, region_code}
    Route::post('sources/manual', [AutopodborController::class, 'addManualSource']); // {competitor_id, raw}
    Route::post('projects', [AutopodborController::class, 'createProjects']); // {source_ids[], regions, daily_limit_target, delivery_days_mask, launch}
});
```

- [ ] **Step 4: Контроллер** — тонкий: валидация FormRequest/inline, вызовы `AutopodborRunService` / `AutopodborProjectCreator`, маппинг исключений:
  - `RunInFlightException` → 409 `run_in_flight`.
  - `InsufficientBalanceException` (старт прогона) → 409 `balance_insufficient` (для прогона; в UI — «недостаточно средств»).
  - создание «и запустить» с нехваткой лимита → существующий 409 `balance_insufficient` payload (переиспользовать `ProjectController::runPreflight` логику или вынести в общий сервис).
  - `competitor`-эндпоинт отдаёт источники + по каждому `existing_project_id` (через `AutopodborDedup::existingProjectId`) для живого состояния.

```php
// фрагмент метода competitor()
public function competitor(int $competitor, AutopodborDedup $dedup) {
    $comp = AutopodborCompetitor::where('tenant_id', tenant_id())->with('sources')->findOrFail($competitor);
    $sources = $comp->sources->map(function ($s) use ($dedup) {
        $existing = $s->created_project_id ?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier);
        return (new SourceResource($s))->additional(['existing_project_id'=>$existing]);
    });
    return CompetitorResource::make($comp)->additional(['sources'=>$sources]);
}
```

- [ ] **Step 5: Resources** — `RunResource` (id,kind,status,region_code,counts), `CompetitorResource` (поля + studied + relevance), `SourceResource` (signal_type,identifier,phone_kind,provenance_*,created_project_id). ПДн-маска: телефоны источников показываем целиком (это публичные номера конкурентов, не ПДн лидов) — подтвердить с pdn-152fz при ревью; имена/описания не маскируем.

- [ ] **Step 6: Тесты → PASS.** Commit «feat(автоподбор): API контроллер + роуты + ресурсы».

---

# ФАЗА 8 — Frontend

> Экраны 1:1 по прототипу `…-prototype.html` в стиле Forest. Каждый экран — отдельный `.vue` в `views/autopodbor/screens/`. Состояние — Pinia store. После каждого экрана — vitest на ключевую логику (не пиксели).

### Task 18: API-клиент + store

**Files:**
- Create: `app/resources/js/api/autopodbor.ts`, `app/resources/js/stores/autopodborStore.ts`
- Test: `app/resources/js/__tests__/autopodborStore.spec.ts`

- [ ] **Step 1: Падающий тест** (store: загрузка state, старт search переводит в loading, опрос до done).

```ts
import { setActivePinia, createPinia } from 'pinia';
import { useAutopodborStore } from '../stores/autopodborStore';
import { vi, it, expect, beforeEach } from 'vitest';
vi.mock('../api/autopodbor', () => ({
  fetchState: vi.fn().mockResolvedValue({ enabled:true, runs:[], prices:{search:'500',study:'900'} }),
  startSearch: vi.fn().mockResolvedValue({ id:1, kind:'search', status:'queued' }),
  fetchRun: vi.fn().mockResolvedValue({ id:1, status:'done' }),
}));
beforeEach(()=>setActivePinia(createPinia()));
it('loads state', async () => {
  const s = useAutopodborStore(); await s.loadState();
  expect(s.enabled).toBe(true); expect(s.prices.search).toBe('500');
});
```

- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализация** `autopodbor.ts` (обёртки axios над эндпоинтами Task 17) и `autopodborStore.ts` (state: enabled, prices, runs, currentRun, competitors, sources; actions: loadState, startSearch/Study/Resolve, pollRun(id), loadCompetitor(id), createProjects). Опрос — `setInterval` ~2500ms до терминального статуса.
- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): api-клиент + store».

### Task 19: Контейнер `AutopodborView` + маршрут + пункт меню

**Files:**
- Create: `app/resources/js/views/autopodbor/AutopodborView.vue`
- Modify: роутер, компонент сайдбара
- Test: smoke-рендер `app/resources/js/__tests__/AutopodborView.spec.ts`

- [ ] **Step 1: Падающий smoke-тест** — монтируется, при `enabled=false` показывает «скоро»/прячет; при enabled показывает экран входа.
- [ ] **Step 2: FAIL.**
- [ ] **Step 3:** `AutopodborView` — держит текущий экран (`entry|autoform|manualform|resolve|loading|list|detail|create|empty|done`) и переключает по событиям store (зеркало `show()` из прототипа). Добавить маршрут `/autopodbor` и пункт меню «Автоподбор конкурентов» с меткой NEW в группе «Работа» (под «Проекты»), видимость = `enabled`.
- [ ] **Step 4: Тест → PASS.** Commit «feat(автоподбор): контейнер вкладки + маршрут + пункт меню».

### Task 20: Экран входа (Entry) + «Продолжить начатое»

**Files:** `screens/EntryScreen.vue` + тест.
- [ ] Падающий тест: при наличии `runs` рендерит блок «Продолжить начатое»; пусто — только две двери. Клик по двери эмитит переход.
- [ ] Реализация по `s-entry`. Commit.

### Task 21: Форма авто-подбора (AutoForm)

**Files:** `screens/AutoFormScreen.vue` + тест.
- [ ] Падающий тест: добавление строк конкурентов; обязательность региона; сабмит зовёт store.startSearch с собранным payload; перед стартом — окно подтверждения цены (из store.prices.search).
- [ ] Реализация по `s-autoform` (тон деловой, секции «О вашей компании», галка федеральных). Commit.

### Task 22: Форма «свой конкурент» + подтверждение по названию (ManualForm + ResolveConfirm)

**Files:** `screens/ManualFormScreen.vue`, `screens/ResolveConfirmScreen.vue` + тесты.
- [ ] Падающий тест: дан сайт → сразу manual-study (платно, после подтверждения цены); дано только имя → resolve (бесплатно) → экран ResolveConfirm со списком кандидатов → выбор → study (платно). «Не та» → назад без списания.
- [ ] Реализация по `s-manualform` + новый ResolveConfirm (🟡 §3.3 дизайна). Commit.

### Task 23: Загрузка / Пусто / Готово (Loading, Empty, Done)

**Files:** `screens/LoadingScreen.vue`, `screens/EmptyScreen.vue`, `screens/DoneScreen.vue` + тесты.
- [ ] Падающий тест: Loading опрашивает store.pollRun; на `done`→переход к результату, `empty/failed`→Empty. Empty показывает «деньги не списаны». Done показывает счётчик проектов.
- [ ] Реализация по `s-loading`/`s-done` + новый `s-empty` (🟡). Commit.

### Task 24: Список конкурентов (CompetitorList)

**Files:** `screens/CompetitorListScreen.vue` + тест.
- [ ] Падающий тест: сортировка по `relevance_pct` (100 первым); изученный → «Открыть источники» (бесплатно), неизученный → «Изучить подробнее» (платно, подтверждение цены); кнопка «указать вручную».
- [ ] Реализация по `s-list`. Commit.

### Task 25: Источники конкурента (SourceList) + живое состояние + «Изменить проект»

**Files:** `screens/SourceListScreen.vue` + тест.
- [ ] Падающий тест: источник с `existing_project_id` → заблокирован, метка «✓ проект создан», кнопка «Изменить проект»; секции Сайты/Телефоны; метки настоящий ✓/подменный 🎭; «добавить источник вручную»; нижняя панель «Выбрано X из K».
- [ ] Реализация по `s-detail`. «Изменить проект» открывает существующий `EditProjectDialog`/`NewProjectDialog` в режиме edit (переиспользовать) ИЛИ отдельный экран `s-editproject` через `PATCH /api/projects/{id}`. Предпочесть переиспользование `NewProjectDialog.vue` (mode='edit'). Commit.

### Task 26: Создание проектов (CreateProjects)

**Files:** `screens/CreateProjectsScreen.vue` + тест.
- [ ] Падающий тест: список «Будет создано N»; редактируемые имена (предзаполнены имя+значок); общие настройки (регион/лимит/дни); «Создать» vs «Создать и запустить»; при 409 `balance_insufficient` от запуска — переиспользовать `ProjectLimitOverloadDialog`.
- [ ] Реализация по `s-create`. Сабмит → `POST /api/autopodbor/projects` с `launch` флагом → Done. Commit.

---

# ФАЗА 9 — Админка и интеграция

### Task 27: Админ-настройки цены/вкл (переиспользование)

**Files:** проверить, что ключи `autopodbor_*` видны и редактируемы в существующем `SystemSettingEditDialog.vue` / списке `AdminSystemSettingsController::index`.
- [ ] Если список настроек фильтрованный/whitelisted — добавить ключи `autopodbor_enabled`, `autopodbor_price_search_rub`, `autopodbor_price_study_rub`, `autopodbor_max_competitors` в whitelist/описания. Тест: PUT по ключу `autopodbor_price_search_rub` обновляет значение + пишет аудит. Commit.

### Task 28: Сквозной UI-smoke на заглушке (Playwright)

**Files:** `app/playwright/smoke-autopodbor.js` (или существующий формат smoke).
- [ ] Прокликать на заглушке: вход → авто-форма → loading → список → изучить → источники → создать 2 проекта → Done. Сверить, что проекты появились в «Проектах» и источники помечены «проект создан» при повторном заходе. Зафиксировать скриншоты. (Не коммитить скриншоты-LOCAL по правилу gitignore.)

### Task 29: Финальная проверка и нормативка

- [ ] `/regression full` — зелёный (Pest --parallel, Larastan, Vitest, build, lychee, gitleaks).
- [ ] `rls-reviewer` по трём таблицам — без находок.
- [ ] `billing-audit` по `AutopodborChargeService` — money-инвариант подтверждён.
- [ ] `db/CHANGELOG_schema.md` + header `db/schema.sql` обновлены (3 таблицы, тип проводки).
- [ ] Self-review (§8 CLAUDE.md): schema, narrative-версии, cross-ref.
- [ ] Память: записать прогресс фичи (coverage `direct:memory-sync`).
- [ ] Commit/мерж ветки — через `finishing-a-development-branch`, после «эскейп».

---

## Self-review плана (выполнено при написании)

- **Покрытие спеки:** §3 экраны → Tasks 18–26; §4 данные → Tasks 1–4,9; §4.4 дедуп → Task 9 + 17(competitor); §4.5 имя/эмодзи → Tasks 15,16; §5 деньги → Tasks 5,10,11; §5.4 настройки → Tasks 5,27; §6 объём → Task 5(ключ)+12; §7 розетка → Tasks 6,7; §8 швы → Tasks 11(in-flight),20(история),25(живое); §3.3 resolve → Tasks 14,22; §3.7 empty → Task 23; §10 тесты → во всех тасках + Task 29. Пробелов нет.
- **Плейсхолдеры:** конкретные суммы цены намеренно НЕ заданы (бизнес-решение владельца, задаются в админке) — это не плейсхолдер кода, а конфиг. Помечено явно.
- **Согласованность типов:** методы `findCompetitors/studyCompetitor/resolveByName`, `chargeForRun`, `existingProjectId/dedupSources/dedupCompetitors`, `createFromSources`, `stripBadge` — имена едины между тасками.
- **Зоны проверки руками** (помечены «Сверить…» в шагах): точные namespace `PhoneNormalizer`, способ установки `app.tenant_id` в джобах, helper'ы тестовой аутентификации/tenant, имена колонок `tenants.balance_rub`/`BalanceTransaction`, трактовка `force_save_blocked` в `ProjectService::create`. Исполнитель сверяет по коду перед реализацией шага.
