diff --git a/app/app/Services/Billing/BillingTopupService.php b/app/app/Services/Billing/BillingTopupService.php index 382c618f..17b3d51e 100644 --- a/app/app/Services/Billing/BillingTopupService.php +++ b/app/app/Services/Billing/BillingTopupService.php @@ -25,6 +25,10 @@ use App\Models\Tenant; */ final class BillingTopupService { + public function __construct( + private readonly ProjectBlockReleaseService $blockRelease = new ProjectBlockReleaseService, + ) {} + /** * Пополнить рублёвый баланс тенанта. * @@ -48,7 +52,7 @@ final class BillingTopupService $tenant->balance_rub = $newBalanceRub; $tenant->save(); - return BalanceTransaction::create([ + $tx = BalanceTransaction::create([ 'tenant_id' => $tenant->id, 'type' => BalanceTransaction::TYPE_TOPUP, 'amount_rub' => $amountRub, @@ -59,5 +63,11 @@ final class BillingTopupService 'user_id' => $userId, 'created_at' => now(), ]); + + // E (балансовый блок): после зачисления — авто-снятие preflight_blocked_at по + // политике «всё-или-ничего» (хватает на весь заказ → снять блок со всех + синк). + $this->blockRelease->releaseForTenant($tenant->id); + + return $tx; } } diff --git a/app/app/Services/Billing/ProjectBlockReleaseService.php b/app/app/Services/Billing/ProjectBlockReleaseService.php new file mode 100644 index 00000000..e929f6ba --- /dev/null +++ b/app/app/Services/Billing/ProjectBlockReleaseService.php @@ -0,0 +1,98 @@ + 0, 'passes' => true, 'required_leads' => 0, 'capacity_leads' => 0, 'deficit_leads' => 0]; + + $tiers = PricingTier::query()->where('is_active', true)->get(); + if ($tiers->isEmpty()) { + return $none; // биллинг не настроен — нечего пересчитывать (зеркалит runPreflight). + } + + $tenant = Tenant::find($tenantId); + if ($tenant === null) { + return $none; + } + + $blocked = Project::where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereNotNull('preflight_blocked_at') + ->get(['id']); + + if ($blocked->isEmpty()) { + return $none; // нечего снимать. + } + + // required = суммарный дневной лимит ВСЕХ активных проектов (вкл. заблокированные) — + // «весь заказ» из политики «всё-или-ничего». + $required = (int) Project::where('tenant_id', $tenantId) + ->where('is_active', true) + ->sum('daily_limit_target'); + + $result = $this->preflight->evaluate( + balanceRub: (string) $tenant->balance_rub, + deliveredInMonth: (int) $tenant->delivered_in_month, + requiredLeads: $required, + tiers: $tiers, + ); + + if (! $result->passes) { + // Не хватает на весь заказ — не снимаем никого (всё-или-ничего). + return [ + 'released' => 0, + 'passes' => false, + 'required_leads' => $required, + 'capacity_leads' => $result->capacityLeads, + 'deficit_leads' => $result->deficitLeads, + ]; + } + + // Хватает → снять блок со всех заблокированных + синк к поставщику. + $releasedIds = []; + foreach ($blocked as $p) { + Project::where('id', $p->id)->update(['preflight_blocked_at' => null]); + $releasedIds[] = (int) $p->id; + } + foreach ($releasedIds as $id) { + SyncSupplierProjectJob::dispatch($id); + } + + return [ + 'released' => count($releasedIds), + 'passes' => true, + 'required_leads' => $required, + 'capacity_leads' => $result->capacityLeads, + 'deficit_leads' => 0, + ]; + } +} diff --git a/app/tests/Feature/Billing/ProjectBlockReleaseOnTopupTest.php b/app/tests/Feature/Billing/ProjectBlockReleaseOnTopupTest.php new file mode 100644 index 00000000..b8933f60 --- /dev/null +++ b/app/tests/Feature/Billing/ProjectBlockReleaseOnTopupTest.php @@ -0,0 +1,96 @@ +create([ + 'tier_no' => 1, + 'leads_in_tier' => 100, + 'price_per_lead_kopecks' => 5000, // 50₽/лид — capacity = balance/50 + 'is_active' => true, + 'effective_from' => now(), + ]); +}); + +// E (балансовый блок): авто-снятие preflight_blocked_at при пополнении. +// Политика «всё-или-ничего»: хватает на суммарный дневной лимит ВСЕХ активных +// проектов (вкл. заблокированные) → снять блок со всех + синк; не хватает → никого. + +it('releases blocked project when topup covers the full order', function () { + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '0.00']); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'daily_limit_target' => 30, + 'preflight_blocked_at' => now(), + ]); + + // 2000₽ / 50 = 40 capacity >= 30 → снять блок + синк. + app(BillingTopupService::class)->topup($tenant->id, '2000.00', null); + + expect($project->fresh()->preflight_blocked_at)->toBeNull(); + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +it('keeps blocked when topup still insufficient', function () { + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '0.00']); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'daily_limit_target' => 30, + 'preflight_blocked_at' => now(), + ]); + + // 500₽ / 50 = 10 capacity < 30 → остаётся заблокированным, синка нет. + app(BillingTopupService::class)->topup($tenant->id, '500.00', null); + + expect($project->fresh()->preflight_blocked_at)->not->toBeNull(); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); + +it('releases all-or-nothing across multiple blocked projects', function () { + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '0.00']); + $p1 = Project::factory()->for($tenant)->create([ + 'is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now(), + ]); + $p2 = Project::factory()->for($tenant)->create([ + 'is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now(), + ]); + + // required = 20+20 = 40. 1500₽/50 = 30 capacity < 40 → НЕ снимать никого. + app(BillingTopupService::class)->topup($tenant->id, '1500.00', null); + expect($p1->fresh()->preflight_blocked_at)->not->toBeNull(); + expect($p2->fresh()->preflight_blocked_at)->not->toBeNull(); + + // дополнили до 2000 → capacity 40 >= 40 → снять блок с ОБОИХ. + app(BillingTopupService::class)->topup($tenant->id, '500.00', null); + expect($p1->fresh()->preflight_blocked_at)->toBeNull(); + expect($p2->fresh()->preflight_blocked_at)->toBeNull(); +}); + +it('does nothing when tenant has no blocked projects', function () { + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '0.00']); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'daily_limit_target' => 10, + 'preflight_blocked_at' => null, + ]); + + app(BillingTopupService::class)->topup($tenant->id, '2000.00', null); + + expect($project->fresh()->preflight_blocked_at)->toBeNull(); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 481c393d..e56579a2 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-23T06:06:59.331Z +Last updated: 2026-06-23T06:17:26.628Z | Контролёр | Состояние | Детали | |---|---|---| @@ -142,8 +142,8 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 14232 | msedge | 5.72ч | 0.0ч | -| 3440 | MsMpEng | 5.19ч | NaNч | +| 14232 | msedge | 5.72ч | NaNч | +| 3440 | MsMpEng | 5.23ч | NaNч | | 1212 | svchost | 1.14ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.