From 9362cda1487f040588f8be830ad9b7c100b8842e 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: Wed, 8 Jul 2026 17:47:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(autopodbor):=20startStudyBatch=20+=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D1=87=D0=BD=D1=8B=D1=85=20=D1=81=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Autopodbor/BatchBudgetException.php | 15 +++ app/app/Models/AutopodborRun.php | 1 + .../Autopodbor/AutopodborRunService.php | 107 +++++++++++++++-- app/app/Services/Autopodbor/BatchResult.php | 10 ++ .../Autopodbor/StudyBatchEnqueueTest.php | 108 ++++++++++++++++++ 5 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 app/app/Exceptions/Autopodbor/BatchBudgetException.php create mode 100644 app/app/Services/Autopodbor/BatchResult.php create mode 100644 app/tests/Feature/Autopodbor/StudyBatchEnqueueTest.php diff --git a/app/app/Exceptions/Autopodbor/BatchBudgetException.php b/app/app/Exceptions/Autopodbor/BatchBudgetException.php new file mode 100644 index 00000000..08708dd8 --- /dev/null +++ b/app/app/Exceptions/Autopodbor/BatchBudgetException.php @@ -0,0 +1,15 @@ +findOrFail($competitorId); - // Повторный сбор источников разрешён (клиент жмёт «Собрать ещё раз»): жёсткого стопа - // «уже изучали» больше нет. От двойного клика/нахлёста защищает assertNoInFlight ниже. - $this->assertNoInFlight($tenantId, 'study'); + // Идемпотентность двойного клика: если у конкурента УЖЕ есть study-прогон в + // очереди/в работе — вернуть его, второго не создаём. Жёсткий стоп по всему + // тенанту (assertNoInFlight) снят — распорядитель сам держит одну дорожку и + // разруливает нахлёст между конкурентами/тенантами. + $existing = AutopodborRun::where('tenant_id', $tenantId) + ->where('kind', 'study') + ->where('competitor_id', $comp->id) + ->whereIn('status', ['queued', 'running']) + ->first(); + if ($existing !== null) { + return $existing; + } + $this->priceGate($tenantId, 'autopodbor_price_study_rub'); $run = AutopodborRun::create([ @@ -90,14 +106,90 @@ final class AutopodborRunService // Регион: у авто-конкурента — из его поиска; у ручного (без searchRun) — из прошлого изучения. 'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code, 'competitor_id' => $comp->id, + 'batch_id' => (string) Str::uuid(), 'params' => [], ]); - RunAutopodborStudyJob::dispatch($run->id, $tenantId); + // Не диспатчим напрямую — отдаём распорядителю (одна дорожка, round-robin). + $this->scheduler->tick(); return $run; } + /** + * Ставит пакет изучений: N study-прогонов под общим batch_id. + * + * Нормализация: только конкуренты этого тенанта, дубли убираются, конкуренты с + * уже активным (queued/running) study-прогоном отсеиваются. Денежный гейт — + * на ВЕСЬ пакет разом (с резервом под активные проекты): не хватает на все N → + * BatchBudgetException, ни одного прогона не создаём. + * + * @param array $competitorIds + */ + public function startStudyBatch(int $tenantId, array $competitorIds): BatchResult + { + $ids = array_values(array_unique(array_map('intval', $competitorIds))); + + $competitors = AutopodborCompetitor::where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + // Конкуренты с уже активным study-прогоном — не ставим повторно. + $busyCompetitorIds = AutopodborRun::where('tenant_id', $tenantId) + ->where('kind', 'study') + ->whereIn('status', ['queued', 'running']) + ->whereNotNull('competitor_id') + ->pluck('competitor_id') + ->all(); + + $targets = $competitors + ->reject(fn (AutopodborCompetitor $c): bool => in_array($c->id, $busyCompetitorIds, true)) + ->values(); + + if ($targets->isEmpty()) { + return new BatchResult(null, 0); + } + + // Денежный гейт на весь пакет. + $tenant = Tenant::whereKey($tenantId)->firstOrFail(); + $price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'); + $committed = (int) Project::where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->sum('daily_limit_target'); + $tiers = $this->tiers->activeAt(now('Europe/Moscow')); + + $decision = $this->budgetGate->evaluateBatch( + (string) $tenant->balance_rub, + (int) $tenant->delivered_in_month, + $committed, + $tiers, + $targets->count(), + $price, + ); + + if (! $decision->allowed) { + throw new BatchBudgetException($decision->topupRub, $decision->maxAffordable); + } + + $batchId = (string) Str::uuid(); + foreach ($targets as $comp) { + AutopodborRun::create([ + 'tenant_id' => $tenantId, + 'kind' => 'study', + 'status' => 'queued', + 'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code, + 'competitor_id' => $comp->id, + 'batch_id' => $batchId, + 'params' => [], + ]); + } + + $this->scheduler->tick(); + + return new BatchResult($batchId, $targets->count()); + } + /** * Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон * с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион). @@ -106,7 +198,6 @@ final class AutopodborRunService */ public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun { - $this->assertNoInFlight($tenantId, 'study'); $this->priceGate($tenantId, 'autopodbor_price_study_rub'); $comp = AutopodborCompetitor::create([ @@ -126,10 +217,12 @@ final class AutopodborRunService 'status' => 'queued', 'region_code' => $regionCode, 'competitor_id' => $comp->id, + 'batch_id' => (string) Str::uuid(), 'params' => [], ]); - RunAutopodborStudyJob::dispatch($run->id, $tenantId); + // Не диспатчим напрямую — отдаём распорядителю. + $this->scheduler->tick(); return $run; } diff --git a/app/app/Services/Autopodbor/BatchResult.php b/app/app/Services/Autopodbor/BatchResult.php new file mode 100644 index 00000000..0bc537f9 --- /dev/null +++ b/app/app/Services/Autopodbor/BatchResult.php @@ -0,0 +1,10 @@ + $tenantId, + 'name' => $name, + 'origin' => 'manual', + 'dedup_key' => Str::slug($name).'-'.Str::random(6), + 'box' => 'field', + ]); +} + +it('ставит пакет: N queued-прогонов с общим batch_id, распорядитель стартует ровно первого', 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']); + + $c1 = batchCompetitor($tenant->id, 'Окна А'); + $c2 = batchCompetitor($tenant->id, 'Окна Б'); + $c3 = batchCompetitor($tenant->id, 'Окна В'); + + $result = app(AutopodborRunService::class)->startStudyBatch($tenant->id, [$c1->id, $c2->id, $c3->id]); + + expect($result->queued)->toBe(3) + ->and($result->batch_id)->not->toBeNull(); + + $runs = AutopodborRun::where('batch_id', $result->batch_id)->get(); + expect($runs)->toHaveCount(3) + ->and($runs->pluck('status')->unique()->values()->all())->toBe(['queued']) + ->and($runs->pluck('kind')->unique()->values()->all())->toBe(['study']); + + // Джобы не диспатчатся напрямую из сервиса — распорядитель стартует РОВНО первого. + Queue::assertPushed(RunAutopodborStudyJob::class, 1); +}); + +it('нормализация: дубли, чужие конкуренты и уже-в-очереди отсеиваются', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '100000.00']); + $other = 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']); + + $c1 = batchCompetitor($tenant->id, 'Окна А'); + $c2 = batchCompetitor($tenant->id, 'Окна Б'); + $foreign = batchCompetitor($other->id, 'Чужой'); + $busy = batchCompetitor($tenant->id, 'Занятый'); + // У «занятого» уже есть queued study-прогон → должен быть отсеян. + AutopodborRun::create([ + 'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued', + 'competitor_id' => $busy->id, 'region_code' => 1, 'params' => [], + ]); + + $result = app(AutopodborRunService::class)->startStudyBatch( + $tenant->id, + [$c1->id, $c1->id, $c2->id, $foreign->id, $busy->id], + ); + + expect($result->queued)->toBe(2); + + $runs = AutopodborRun::where('batch_id', $result->batch_id)->get(); + expect($runs)->toHaveCount(2) + ->and($runs->pluck('competitor_id')->sort()->values()->all()) + ->toBe(collect([$c1->id, $c2->id])->sort()->values()->all()); +}); + +it('нет баланса на весь пакет → BatchBudgetException, ни одного прогона не создано', function () { + Queue::fake(); + $tenant = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']); + + // Активный проект-обязательство + сетка тарифов — гейт учитывает резерв проектов. + Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10]); + PricingTier::factory()->create([ + 'tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 3000, + 'is_active' => true, 'effective_from' => now()->toDateString(), + ]); + + $c1 = batchCompetitor($tenant->id, 'Окна А'); + $c2 = batchCompetitor($tenant->id, 'Окна Б'); + + expect(fn () => app(AutopodborRunService::class)->startStudyBatch($tenant->id, [$c1->id, $c2->id])) + ->toThrow(BatchBudgetException::class); + + expect(AutopodborRun::where('tenant_id', $tenant->id)->where('kind', 'study')->count())->toBe(0); + Queue::assertNotPushed(RunAutopodborStudyJob::class); +});