From c94de99c3276fa5625533aedebc031ad16114a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 28 Jun 2026 12:17:59 +0300 Subject: [PATCH] =?UTF-8?q?docs(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80):=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD-?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20+=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дизайн и пошаговый план фичи «Автоподбор конкурентов» (ИИ-агент находит конкурентов и их источники). Движок — отдельной сессией, здесь розетка+заглушка. План сверен с кодом: RLS app.current_tenant_id, tenant-контекст SET LOCAL, тестовая БД liderra_testing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-28-avtopodbor-konkurentov.md | 1432 +++++++++++++++++ ...026-06-28-autopodbor-konkurentov-design.md | 421 +++++ 2 files changed, 1853 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md create mode 100644 docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md diff --git a/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md b/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md new file mode 100644 index 00000000..8cd06df2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-avtopodbor-konkurentov.md @@ -0,0 +1,1432 @@ +# Автоподбор конкурентов — план реализации + +> **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 +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 +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 +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 +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 +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 +'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 +'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 +'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 +[], 'directories'=>[], 'description'=>''] + public readonly bool $includeFederal, + public readonly int $maxCompetitors, + ) {} +} +``` + +```php +// FindCompetitorsResult.php + $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 +'Окна Комфорт','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 +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 +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 +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 уникальные источники с проставленным 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 уникальные конкуренты */ + 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 +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 +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 +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 +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`. Исполнитель сверяет по коду перед реализацией шага. diff --git a/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md b/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md new file mode 100644 index 00000000..2bba711b --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-autopodbor-konkurentov-design.md @@ -0,0 +1,421 @@ +# Автоподбор конкурентов — дизайн-документ + +> **Дата:** 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` +(план реализации). Код до утверждённого плана не пишу.