feat(autopodbor): повторный сбор источников — бэкенд

Клиент может собрать источники по конкуренту ещё раз. Новые → в предложения,
ранее удалённые (найдены снова) → помечаются для возврата, уже известные не
задваиваются и не трогаются.

- удаление источника стало мягким (box=archived) — помним, чтобы показать «ранее удалён»
- снят жёсткий стоп «уже изучали» в startStudy (повтор разрешён; от нахлёста — assertNoInFlight)
- колонка result (jsonb) на прогоне + cast + RunResource
- джоба изучения классифицирует источники и пишет итог {new_sources, refound_deleted, ids}
- тесты: мягкое удаление, повтор, классификация

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-05 06:08:12 +03:00
parent c033265135
commit 78a37978bc
9 changed files with 190 additions and 9 deletions
@@ -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);
}
@@ -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(),
@@ -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',
+2
View File
@@ -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',
@@ -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' => [],
]);
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Итог прогона для показа клиенту. Для study (сбор источников): сколько НОВЫХ источников,
// сколько РАНЕЕ УДАЛЁННЫХ снова нашли (и их id для блока «вернуть?»).
Schema::table('autopodbor_runs', function (Blueprint $table): void {
$table->jsonb('result')->nullable();
});
}
public function down(): void
{
Schema::table('autopodbor_runs', function (Blueprint $table): void {
$table->dropColumn('result');
});
}
};
@@ -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']);
@@ -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 () {
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
use App\Services\Autopodbor\AutopodborNormalizer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/** Агент отдаёт при изучении фиксированные сайты (site-источники). */
function bindStudyAgentReturning(array $siteIdentifiers): void
{
app()->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],
]);
});