diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php index c3b7552c..140445d0 100644 --- a/app/app/Http/Controllers/Api/AutopodborController.php +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -675,7 +675,10 @@ class AutopodborController extends Controller return response()->json(['error' => 'has_active_project'], 409); } - $src->delete(); + // Мягкое удаление: прячем в архив, но помним — при повторном сборе источников такой источник, + // если движок найдёт его снова, покажем группой «ранее удалённые — вернуть?» (вдруг передумал). + // Из списков поля/предложений он исчезает (они фильтруют box=field/proposal). + $src->update(['box' => 'archived']); return response()->json(null, 204); } diff --git a/app/app/Http/Resources/Autopodbor/RunResource.php b/app/app/Http/Resources/Autopodbor/RunResource.php index ca5ed358..1007d185 100644 --- a/app/app/Http/Resources/Autopodbor/RunResource.php +++ b/app/app/Http/Resources/Autopodbor/RunResource.php @@ -26,6 +26,7 @@ class RunResource extends JsonResource // Клиенту показать, что идёт работа: место в общей очереди (пока queued) и текущий этап (пока running). 'queue_position' => $this->status === 'queued' ? app(AutopodborQueue::class)->pending() : null, 'progress' => $this->progress, + 'result' => $this->result, '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(), diff --git a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php index a1fdca71..478bc034 100644 --- a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php +++ b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php @@ -90,11 +90,20 @@ class RunAutopodborStudyJob implements ShouldQueue return; } + // Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых, + // а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/ + // предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений). + $newCount = 0; + $refoundDeletedIds = []; foreach ($unique as $s) { $identifier = $s['signal_type'] === 'call' ? $norm->phone($s['identifier']) : $norm->domainHead($s['identifier']); + $existing = AutopodborSource::where('competitor_id', $comp->id) + ->where('dedup_key', $s['dedup_key']) + ->first(); + AutopodborSource::updateOrCreate( [ 'competitor_id' => $comp->id, @@ -114,6 +123,12 @@ class RunAutopodborStudyJob implements ShouldQueue 'confirmations' => $s['confirmations'] ?? 1, ] ); + + if ($existing === null) { + $newCount++; // совсем новый → в предложения + } elseif ($existing->box === 'archived') { + $refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?» + } } $price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'); @@ -121,7 +136,15 @@ class RunAutopodborStudyJob implements ShouldQueue $comp->update(['studied_at' => now(), 'study_run_id' => $run->id]); - $run->update(['status' => 'done', 'finished_at' => now()]); + $run->update([ + 'status' => 'done', + 'finished_at' => now(), + 'result' => [ + 'new_sources' => $newCount, + 'refound_deleted' => count($refoundDeletedIds), + 'refound_deleted_ids' => array_values($refoundDeletedIds), + ], + ]); } catch (\Throwable $e) { $run->update([ 'status' => 'failed', diff --git a/app/app/Models/AutopodborRun.php b/app/app/Models/AutopodborRun.php index 794a3000..fbb78907 100644 --- a/app/app/Models/AutopodborRun.php +++ b/app/app/Models/AutopodborRun.php @@ -24,11 +24,13 @@ class AutopodborRun extends Model 'started_at', 'finished_at', 'progress', + 'result', ]; protected $casts = [ 'params' => 'array', 'progress' => 'array', + 'result' => 'array', 'price_rub_charged' => 'decimal:2', 'started_at' => 'datetime', 'finished_at' => 'datetime', diff --git a/app/app/Services/Autopodbor/AutopodborRunService.php b/app/app/Services/Autopodbor/AutopodborRunService.php index 5213f797..63bc80cb 100644 --- a/app/app/Services/Autopodbor/AutopodborRunService.php +++ b/app/app/Services/Autopodbor/AutopodborRunService.php @@ -78,10 +78,8 @@ final class AutopodborRunService { $comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId); - if ($comp->studied_at !== null) { - return $comp->studyRun; - } - + // Повторный сбор источников разрешён (клиент жмёт «Собрать ещё раз»): жёсткого стопа + // «уже изучали» больше нет. От двойного клика/нахлёста защищает assertNoInFlight ниже. $this->assertNoInFlight($tenantId, 'study'); $this->priceGate($tenantId, 'autopodbor_price_study_rub'); @@ -89,7 +87,8 @@ final class AutopodborRunService 'tenant_id' => $tenantId, 'kind' => 'study', 'status' => 'queued', - 'region_code' => $comp->searchRun?->region_code, + // Регион: у авто-конкурента — из его поиска; у ручного (без searchRun) — из прошлого изучения. + 'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code, 'competitor_id' => $comp->id, 'params' => [], ]); diff --git a/app/database/migrations/2026_07_05_130000_autopodbor_runs_result.php b/app/database/migrations/2026_07_05_130000_autopodbor_runs_result.php new file mode 100644 index 00000000..0e7a3cd7 --- /dev/null +++ b/app/database/migrations/2026_07_05_130000_autopodbor_runs_result.php @@ -0,0 +1,26 @@ +jsonb('result')->nullable(); + }); + } + + public function down(): void + { + Schema::table('autopodbor_runs', function (Blueprint $table): void { + $table->dropColumn('result'); + }); + } +}; diff --git a/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php b/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php index 40fc4066..a1c2b7bd 100644 --- a/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborRunServiceTest.php @@ -6,6 +6,9 @@ use App\Exceptions\Autopodbor\RunInFlightException; use App\Exceptions\Billing\InsufficientBalanceException; use App\Jobs\Autopodbor\RunAutopodborResolveJob; use App\Jobs\Autopodbor\RunAutopodborSearchJob; +use App\Jobs\Autopodbor\RunAutopodborStudyJob; +use App\Models\AutopodborCompetitor; +use App\Models\AutopodborRun; use App\Models\SystemSetting; use App\Models\Tenant; use App\Services\Autopodbor\AutopodborRunService; @@ -28,6 +31,28 @@ it('стартует search, создаёт queued-прогон и ставит Queue::assertPushed(RunAutopodborSearchJob::class); }); +it('startStudy на уже изученном конкуренте создаёт НОВЫЙ прогон (повторный сбор источников)', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']); + + $oldRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'name' => 'Окна', 'dedup_key' => 'okna', 'box' => 'field', + 'studied_at' => now(), 'study_run_id' => $oldRun->id, + ]); + + $run = app(AutopodborRunService::class)->startStudy($tenant->id, $comp->id); + + // Раньше был жёсткий стоп «уже изучали → вернуть старый прогон». Теперь повтор разрешён. + expect($run->id)->not->toBe($oldRun->id) + ->and($run->kind)->toBe('study') + ->and($run->status)->toBe('queued') + ->and($run->competitor_id)->toBe($comp->id); + Queue::assertPushed(RunAutopodborStudyJob::class); +}); + it('не стартует второй in-flight search того же tenant', function () { Queue::fake(); $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); diff --git a/app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php b/app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php index 68f7d3c0..c91aa787 100644 --- a/app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborSourceCrudApiTest.php @@ -100,14 +100,18 @@ it('PATCH sources/{id} — чужой тенант не правит (404)', fun ->assertStatus(404); }); -it('DELETE sources/{id} — удаляет источник без проекта (204)', function () { +it('DELETE sources/{id} — мягко удаляет источник без проекта: прячет в архив, но помнит (204)', function () { [$tenant, $user, $run, $comp] = srcCrudSetup(); $src = makeSource($tenant, $run, $comp); $this->actingAs($user)->deleteJson("/api/autopodbor/sources/{$src->id}") ->assertStatus(204); - expect(AutopodborSource::find($src->id))->toBeNull(); + // Мягкое удаление: строка НЕ стирается (чтобы при повторном сборе показать «ранее удалён»), + // а прячется в архив. В списках поля/предложений его больше нет. + $fresh = AutopodborSource::find($src->id); + expect($fresh)->not->toBeNull() + ->and($fresh->box)->toBe('archived'); }); it('DELETE sources/{id} — блок при активном проекте (409)', function () { diff --git a/app/tests/Feature/Autopodbor/StudyRepeatCollectTest.php b/app/tests/Feature/Autopodbor/StudyRepeatCollectTest.php new file mode 100644 index 00000000..7cbb639f --- /dev/null +++ b/app/tests/Feature/Autopodbor/StudyRepeatCollectTest.php @@ -0,0 +1,98 @@ +bind(CompetitorAgent::class, fn () => new class($siteIdentifiers) implements CompetitorAgent + { + public function __construct(private array $sites) {} + + public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult + { + return new FindCompetitorsResult([]); + } + + public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult + { + return new StudyCompetitorResult(array_map(fn (string $id): array => [ + 'signal_type' => 'site', 'identifier' => $id, 'phone_kind' => null, 'phone_type' => null, + 'provenance_url' => null, 'provenance_label' => 'тест', + ], $this->sites)); + } + + public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult + { + return new ResolveByNameResult([]); + } + }); +} + +it('повторный сбор: новые в предложения, ранее удалённые помечаются «вернуть», известные не тронуты', function () { + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '0', 'type' => 'decimal']); + $norm = app(AutopodborNormalizer::class); + + $oldRun = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, 'name' => 'Окна', 'dedup_key' => 'okna', 'box' => 'field', + 'studied_at' => now(), 'study_run_id' => $oldRun->id, + ]); + // Уже известный источник, взятый в работу (box=field) — трогать нельзя. + $known = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $oldRun->id, 'signal_type' => 'site', + 'identifier' => 'known.ru', 'dedup_key' => $norm->sourceKey('site', 'known.ru'), 'box' => 'field', + ]); + // Ранее удалённый источник (box=archived) — помним. + $deleted = AutopodborSource::create([ + 'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $oldRun->id, 'signal_type' => 'site', + 'identifier' => 'deleted.ru', 'dedup_key' => $norm->sourceKey('site', 'deleted.ru'), 'box' => 'archived', + ]); + + // Движок при повторе находит: известный + ранее удалённый + СОВСЕМ новый. + bindStudyAgentReturning(['known.ru', 'deleted.ru', 'new.ru']); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued', + 'region_code' => 16, 'params' => [], 'competitor_id' => $comp->id, + ]); + app()->call([new RunAutopodborStudyJob($run->id), 'handle']); + + // Новый → в предложения. + $new = AutopodborSource::where('competitor_id', $comp->id)->where('identifier', 'new.ru')->first(); + expect($new)->not->toBeNull()->and($new->box)->toBe('proposal'); + // Ранее удалённый остаётся в архиве, но помечен последним прогоном (для блока «вернуть»). + $deleted->refresh(); + expect($deleted->box)->toBe('archived')->and($deleted->study_run_id)->toBe($run->id); + // Известный (в работе) не тронут. + $known->refresh(); + expect($known->box)->toBe('field'); + // Итог прогона: 1 новый, 1 ранее удалённый (с его id). + expect($run->fresh()->result)->toEqual([ + 'new_sources' => 1, + 'refound_deleted' => 1, + 'refound_deleted_ids' => [$deleted->id], + ]); +});