From 2ef24de3f9fa03ee05a030ec37f2c18723915825 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 14:42:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80):=20API=20=D1=8F=D0=B4=D1=80=D0=BE=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=B5=D1=80,=20=D1=80=D0=BE=D1=83=D1=82=D1=8B,=20=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=83=D1=80=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/AutopodborController.php | 238 ++++++++++++++++++ .../Autopodbor/CompetitorResource.php | 28 +++ .../Http/Resources/Autopodbor/RunResource.php | 31 +++ .../Resources/Autopodbor/SourceResource.php | 25 ++ app/routes/web.php | 11 + .../Feature/Autopodbor/AutopodborApiTest.php | 66 +++++ 6 files changed, 399 insertions(+) create mode 100644 app/app/Http/Controllers/Api/AutopodborController.php create mode 100644 app/app/Http/Resources/Autopodbor/CompetitorResource.php create mode 100644 app/app/Http/Resources/Autopodbor/RunResource.php create mode 100644 app/app/Http/Resources/Autopodbor/SourceResource.php create mode 100644 app/tests/Feature/Autopodbor/AutopodborApiTest.php diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php new file mode 100644 index 00000000..34a16a91 --- /dev/null +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -0,0 +1,238 @@ +user()->tenant_id; + + $runs = AutopodborRun::where('tenant_id', $tenantId) + ->orderByDesc('id') + ->limit(20) + ->get(); + + return response()->json([ + 'enabled' => SystemSettings::bool('autopodbor_enabled'), + 'runs' => RunResource::collection($runs), + 'prices' => [ + 'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'), + 'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'), + ], + ]); + } + + /** GET /api/autopodbor/runs/{run} */ + public function run(Request $request, int $run): JsonResponse + { + $r = AutopodborRun::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($run); + + return response()->json(['data' => new RunResource($r)]); + } + + /** GET /api/autopodbor/competitors/{competitor} */ + public function competitor(Request $request, int $competitor, AutopodborDedup $dedup): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $tenantId) + ->with('sources') + ->findOrFail($competitor); + + $sources = $comp->sources->map(function ($s) use ($dedup) { + $existingProjectId = $s->created_project_id + ?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier); + + return array_merge( + (new SourceResource($s))->resolve(), + ['existing_project_id' => $existingProjectId] + ); + }); + + return response()->json([ + 'data' => new CompetitorResource($comp), + 'sources' => $sources, + ]); + } + + /** POST /api/autopodbor/search */ + public function search(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'region_code' => 'required|integer', + 'examples' => 'array', + 'about_self' => 'array', + 'include_federal' => 'boolean', + ]); + + try { + $run = $svc->startSearch( + $request->user()->tenant_id, + (int) $v['region_code'], + $v['examples'] ?? [], + $v['about_self'] ?? [], + (bool) ($v['include_federal'] ?? false), + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + } + + /** POST /api/autopodbor/study */ + public function study(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'competitor_id' => 'required|integer', + ]); + + try { + $run = $svc->startStudy( + $request->user()->tenant_id, + (int) $v['competitor_id'], + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + } + + /** POST /api/autopodbor/resolve */ + public function resolve(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'name' => 'required|string', + 'region_code' => 'required|integer', + ]); + + try { + $run = $svc->startResolve( + $request->user()->tenant_id, + $v['name'], + (int) $v['region_code'], + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } + } + + /** POST /api/autopodbor/projects */ + public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse + { + $v = $request->validate([ + 'source_ids' => 'required|array', + 'source_ids.*' => 'integer', + 'regions' => 'array', + 'regions.*' => 'integer', + 'daily_limit_target' => 'required|integer', + 'delivery_days_mask' => 'required|integer', + 'launch' => 'boolean', + ]); + + $tenant = $request->user()->tenant; + $launch = (bool) ($v['launch'] ?? false); + + // Балансовый preflight при launch=true + if ($launch) { + $existingLimit = (int) Project::where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->sum('daily_limit_target'); + + $wouldBe = $existingLimit + count($v['source_ids']) * (int) $v['daily_limit_target']; + + $preflight = $this->runPreflight($tenant, $wouldBe); + + if (! $preflight['passes']) { + return response()->json([ + 'error' => 'balance_insufficient', + 'current_balance_rub' => (string) $tenant->balance_rub, + 'current_capacity_leads' => $preflight['capacity_leads'], + 'would_be_required_leads' => $wouldBe, + 'deficit_leads' => $preflight['deficit_leads'], + ], 409); + } + } + + $projects = $creator->createFromSources( + $tenant->id, + $v['source_ids'], + [ + 'regions' => $v['regions'] ?? [], + 'daily_limit_target' => (int) $v['daily_limit_target'], + 'delivery_days_mask' => (int) $v['delivery_days_mask'], + ], + $launch, + ); + + return response()->json([ + 'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name])->all(), + ], 201); + } + + /** + * Копия helper'а из ProjectController — балансовый preflight. + * + * @return array{passes: bool, capacity_leads: int, deficit_leads: int} + */ + private function runPreflight(\App\Models\Tenant $tenant, int $requiredLeads): array + { + $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); + + // Safe fallback: без активных pricing_tiers биллинг не настроен — + // preflight пропускаем (legacy-окружения / тесты). + if ($tiers->isEmpty()) { + return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0]; + } + + $result = (new BalancePreflightService)->evaluate( + balanceRub: (string) $tenant->balance_rub, + deliveredInMonth: (int) $tenant->delivered_in_month, + requiredLeads: $requiredLeads, + tiers: $tiers, + ); + + return [ + 'passes' => $result->passes, + 'capacity_leads' => $result->capacityLeads, + 'deficit_leads' => $result->deficitLeads, + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/CompetitorResource.php b/app/app/Http/Resources/Autopodbor/CompetitorResource.php new file mode 100644 index 00000000..50214f05 --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/CompetitorResource.php @@ -0,0 +1,28 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'is_federal' => $this->is_federal, + 'relevance_pct' => $this->relevance_pct, + 'origin' => $this->origin, + 'site_url' => $this->site_url, + 'directory_urls' => $this->directory_urls, + 'studied_at' => $this->studied_at?->toIso8601String(), + 'study_run_id' => $this->study_run_id, + 'search_run_id' => $this->search_run_id, + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/RunResource.php b/app/app/Http/Resources/Autopodbor/RunResource.php new file mode 100644 index 00000000..29f504a5 --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/RunResource.php @@ -0,0 +1,31 @@ + $this->id, + 'kind' => $this->kind, + 'status' => $this->status, + 'region_code' => $this->region_code, + 'params' => $this->params, + 'price_rub_charged' => $this->price_rub_charged, + 'error_code' => $this->error_code, + 'competitors_count' => AutopodborCompetitor::where('search_run_id', $this->id)->count(), + 'sources_count' => AutopodborSource::where('study_run_id', $this->id)->count(), + 'started_at' => $this->started_at?->toIso8601String(), + 'finished_at' => $this->finished_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/SourceResource.php b/app/app/Http/Resources/Autopodbor/SourceResource.php new file mode 100644 index 00000000..387a531b --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/SourceResource.php @@ -0,0 +1,25 @@ + $this->id, + 'competitor_id' => $this->competitor_id, + 'signal_type' => $this->signal_type, + 'identifier' => $this->identifier, + 'phone_kind' => $this->phone_kind, + 'provenance_url' => $this->provenance_url, + 'provenance_label' => $this->provenance_label, + 'created_project_id' => $this->created_project_id, + ]; + } +} diff --git a/app/routes/web.php b/app/routes/web.php index d0e092f4..dab5e965 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -330,6 +330,17 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/projec Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+'); }); +// Автоподбор конкурентов — клиентский API (Task 17a). +Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopodbor')->group(function () { + Route::get('/state', 'App\Http\Controllers\Api\AutopodborController@state'); + Route::get('/runs/{run}', 'App\Http\Controllers\Api\AutopodborController@run')->where('run', '[0-9]+'); + Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+'); + Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search'); + Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study'); + Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve'); + Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects'); +}); + // Supplier-integration webhook (Plan 2/5, spec §5.1). // Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru. // Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist diff --git a/app/tests/Feature/Autopodbor/AutopodborApiTest.php b/app/tests/Feature/Autopodbor/AutopodborApiTest.php new file mode 100644 index 00000000..fb4a11e5 --- /dev/null +++ b/app/tests/Feature/Autopodbor/AutopodborApiTest.php @@ -0,0 +1,66 @@ + Queue::fake()); + +it('GET /api/autopodbor/state — доступность, прогоны, цены', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + SystemSetting::updateOrCreate(['key' => 'autopodbor_enabled'], ['value' => '1', 'type' => 'bool']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '500', 'type' => 'decimal']); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']); + + $this->actingAs($user)->getJson('/api/autopodbor/state') + ->assertOk() + ->assertJsonStructure(['enabled', 'runs', 'prices' => ['search', 'study']]); +}); + +it('POST /api/autopodbor/search — стартует прогон (201)', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '1', 'type' => 'decimal']); + + $this->actingAs($user)->postJson('/api/autopodbor/search', [ + 'region_code' => 16, 'examples' => ['okna.ru'], 'about_self' => [], 'include_federal' => true, + ])->assertCreated()->assertJsonPath('data.kind', 'search'); +}); + +it('GET /api/autopodbor/competitors/{id} — источники с existing_project_id', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$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']); + 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']); + + $this->actingAs($user)->getJson("/api/autopodbor/competitors/{$comp->id}") + ->assertOk() + ->assertJsonStructure(['data'=>['id','name'], 'sources'=>[['id','signal_type','identifier','existing_project_id']]]); +}); + +it('POST /api/autopodbor/projects — создаёт проекты из источников (201)', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '500000.00']); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement("SET app.current_tenant_id = ".$tenant->id); + $run = AutopodborRun::create(['tenant_id'=>$tenant->id,'kind'=>'study','status'=>'done','region_code'=>16,'params'=>[]]); + $comp = AutopodborCompetitor::create(['tenant_id'=>$tenant->id,'search_run_id'=>$run->id,'name'=>'Окна Комфорт','dedup_key'=>'okna']); + $s1 = 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']); + + $this->actingAs($user)->postJson('/api/autopodbor/projects', [ + 'source_ids'=>[$s1->id], 'regions'=>[16], 'daily_limit_target'=>20, 'delivery_days_mask'=>127, 'launch'=>false, + ])->assertCreated(); + expect(Project::where('tenant_id',$tenant->id)->where('signal_identifier','okna-komfort.ru')->exists())->toBeTrue(); +});