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:
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user