feat(autopodbor): startStudyBatch + перевод одиночных сборов на распорядителя
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Autopodbor;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class BatchBudgetException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $topupRub, public readonly int $maxAffordable)
|
||||
{
|
||||
parent::__construct('insufficient_balance_for_batch');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class AutopodborRun extends Model
|
||||
'region_code',
|
||||
'params',
|
||||
'competitor_id',
|
||||
'batch_id',
|
||||
'price_rub_charged',
|
||||
'balance_transaction_id',
|
||||
'error_code',
|
||||
|
||||
@@ -4,20 +4,26 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Autopodbor\BatchBudgetException;
|
||||
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\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AutopodborRunService
|
||||
{
|
||||
public function __construct(
|
||||
private AutopodborNormalizer $normalizer = new AutopodborNormalizer,
|
||||
private AutopodborBudgetGate $budgetGate = new AutopodborBudgetGate,
|
||||
private AutopodborStudyScheduler $scheduler = new AutopodborStudyScheduler,
|
||||
private PricingTierRepository $tiers = new PricingTierRepository,
|
||||
) {}
|
||||
|
||||
private function assertNoInFlight(int $tenantId, string $kind): void
|
||||
@@ -78,9 +84,19 @@ final class AutopodborRunService
|
||||
{
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->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<int, int> $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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
final readonly class BatchResult
|
||||
{
|
||||
public function __construct(public ?string $batch_id, public int $queued) {}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Autopodbor\BatchBudgetException;
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Autopodbor\AutopodborRunService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
function batchCompetitor(int $tenantId, string $name): AutopodborCompetitor
|
||||
{
|
||||
return AutopodborCompetitor::create([
|
||||
'tenant_id' => $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);
|
||||
});
|
||||
Reference in New Issue
Block a user