diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php index e447fa43..2f9c6144 100644 --- a/app/app/Http/Controllers/Api/AutopodborController.php +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -15,13 +15,12 @@ use App\Models\AutopodborRun; use App\Models\AutopodborSource; use App\Models\Project; use App\Models\Tenant; -use App\Repositories\PricingTierRepository; use App\Services\Autopodbor\AutopodborDedup; use App\Services\Autopodbor\AutopodborNormalizer; use App\Services\Autopodbor\AutopodborProjectCreator; use App\Services\Autopodbor\AutopodborRunService; use App\Services\Autopodbor\ProposalClassifier; -use App\Services\Billing\BalancePreflightService; +use App\Services\Requisites\RequisitesService; use App\Support\SystemSettings; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -636,7 +635,7 @@ class AutopodborController extends Controller 'source_ids.*' => 'integer', 'regions' => 'array', 'regions.*' => 'integer', - 'daily_limit_target' => 'required|integer', + 'daily_limit_target' => 'required|integer|min:1|max:10000', 'delivery_days_mask' => 'required|integer', 'launch' => 'boolean', ]); @@ -644,26 +643,10 @@ class AutopodborController extends Controller $tenant = $request->user()->tenant; $launch = (bool) ($v['launch'] ?? false); - // Балансовый preflight при launch=true - if ($launch) { - $existingLimit = (int) Project::where('tenant_id', $tenant->id) - ->where('is_active', true) - ->whereNull('preflight_blocked_at') - ->sum('daily_limit_target'); - - $wouldBe = $existingLimit + count($v['source_ids']) * (int) $v['daily_limit_target']; - - $preflight = $this->runPreflight($tenant, $wouldBe); - - if (! $preflight['passes']) { - return response()->json([ - 'error' => 'balance_insufficient', - 'current_balance_rub' => (string) $tenant->balance_rub, - 'current_capacity_leads' => $preflight['capacity_leads'], - 'would_be_required_leads' => $wouldBe, - 'deficit_leads' => $preflight['deficit_leads'], - ], 409); - } + // Гейт реквизитов первого проекта — как в ProjectController@store. + if (Project::where('tenant_id', $tenant->id)->count() === 0 + && ! app(RequisitesService::class)->isLightComplete($tenant)) { + return response()->json(['error' => 'requisites_required'], 422); } $projects = $creator->createFromSources( @@ -677,37 +660,22 @@ class AutopodborController extends Controller $launch, ); + $launched = collect($projects)->filter(fn ($p) => $p->is_active)->count(); + $deferred = count($projects) - $launched; + // payload баланса — от первого удержанного (для сообщения «пополните ~X ₽»). + $held = collect($projects)->first(fn ($p) => ($p->launch_deferred ?? false)); + return response()->json([ - 'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name])->all(), + 'data' => collect($projects)->map(fn ($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'is_active' => (bool) $p->is_active, + ])->all(), + 'launch' => [ + 'launched' => $launched, + 'deferred' => $deferred, + 'balance' => $held?->gate_payload, + ], ], 201); } - - /** - * Копия helper'а из ProjectController — балансовый preflight. - * - * @return array{passes: bool, capacity_leads: int, deficit_leads: int} - */ - private function runPreflight(Tenant $tenant, int $requiredLeads): array - { - $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); - - // Safe fallback: без активных pricing_tiers биллинг не настроен — - // preflight пропускаем (legacy-окружения / тесты). - if ($tiers->isEmpty()) { - return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0]; - } - - $result = (new BalancePreflightService)->evaluate( - balanceRub: (string) $tenant->balance_rub, - deliveredInMonth: (int) $tenant->delivered_in_month, - requiredLeads: $requiredLeads, - tiers: $tiers, - ); - - return [ - 'passes' => $result->passes, - 'capacity_leads' => $result->capacityLeads, - 'deficit_leads' => $result->deficitLeads, - ]; - } } diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index ef5c43d1..6be613fc 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -9,11 +9,8 @@ use App\Http\Requests\BulkProjectActionRequest; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; -use App\Jobs\SyncSupplierProjectJob; use App\Models\Project; -use App\Models\Tenant; -use App\Repositories\PricingTierRepository; -use App\Services\Billing\BalancePreflightService; +use App\Services\Billing\LaunchBalanceGate; use App\Services\Project\ProjectService; use App\Services\Requisites\RequisitesService; use Illuminate\Http\JsonResponse; @@ -134,35 +131,18 @@ class ProjectController extends Controller return response()->json(['error' => 'requisites_required'], 422); } - $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); - unset($validated['force_save_blocked']); + unset($validated['force_save_blocked']); // больше не блокируем создание - // Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные. - $existingLimit = (int) Project::where('tenant_id', $tenant->id) - ->where('is_active', true) - ->whereNull('preflight_blocked_at') - ->sum('daily_limit_target'); - $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; + $project = $this->projects->create($tenant, $validated, launch: true); - $preflight = $this->runPreflight($tenant, $wouldBeRequired); - - if (! $preflight['passes'] && ! $forceSaveBlocked) { - return response()->json([ - 'error' => 'balance_insufficient', - 'current_balance_rub' => (string) $tenant->balance_rub, - 'current_capacity_leads' => $preflight['capacity_leads'], - 'would_be_required_leads' => $wouldBeRequired, - 'deficit_leads' => $preflight['deficit_leads'], - ], 409); - } - - if (! $preflight['passes'] && $forceSaveBlocked) { - $validated['preflight_blocked_at'] = now(); - } - - $project = $this->projects->create($tenant, $validated); - - return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201); + return response()->json([ + 'data' => new ProjectResource($project->loadCount('supplierProjects')), + 'launch' => [ + 'launched' => $project->launch_deferred ? 0 : 1, + 'deferred' => $project->launch_deferred ? 1 : 0, + 'balance' => $project->gate_payload, + ], + ], 201); } /** PATCH /api/projects/{id} */ @@ -171,33 +151,15 @@ class ProjectController extends Controller $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); $validated = $request->validated(); $tenant = $request->user()->tenant; - $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); unset($validated['force_save_blocked']); - // Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО - // проекта + лимиты остальных активных не-blocked. - if (array_key_exists('daily_limit_target', $validated)) { - $existingLimit = (int) Project::where('tenant_id', $tenant->id) - ->where('id', '!=', $project->id) - ->where('is_active', true) - ->whereNull('preflight_blocked_at') - ->sum('daily_limit_target'); - $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; - - $preflight = $this->runPreflight($tenant, $wouldBeRequired); - - if (! $preflight['passes'] && ! $forceSaveBlocked) { - return response()->json([ - 'error' => 'balance_insufficient', - 'current_balance_rub' => (string) $tenant->balance_rub, - 'current_capacity_leads' => $preflight['capacity_leads'], - 'would_be_required_leads' => $wouldBeRequired, - 'deficit_leads' => $preflight['deficit_leads'], - ], 409); - } - - if (! $preflight['passes'] && $forceSaveBlocked) { - $validated['preflight_blocked_at'] = now(); + if (array_key_exists('daily_limit_target', $validated) + && (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target + && $project->is_active && $project->preflight_blocked_at === null) { + $newLimit = (int) $validated['daily_limit_target']; + $gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]); + if (! $gate->passes) { + return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409); } } @@ -206,34 +168,6 @@ class ProjectController extends Controller return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]); } - /** - * @return array{passes: bool, capacity_leads: int, deficit_leads: int} - */ - private function runPreflight(Tenant $tenant, int $requiredLeads): array - { - // Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому». - $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); - - // Safe fallback: без активных pricing_tiers биллинг не настроен — - // преfflight не имеет смысла, пропускаем (legacy-окружения / тесты). - if ($tiers->isEmpty()) { - return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0]; - } - - $result = (new BalancePreflightService)->evaluate( - balanceRub: (string) $tenant->balance_rub, - deliveredInMonth: (int) $tenant->delivered_in_month, - requiredLeads: $requiredLeads, - tiers: $tiers, - ); - - return [ - 'passes' => $result->passes, - 'capacity_leads' => $result->capacityLeads, - 'deficit_leads' => $result->deficitLeads, - ]; - } - /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { @@ -269,23 +203,13 @@ class ProjectController extends Controller $request->validate(['is_active' => ['required', 'boolean']]); $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11). - // paused_at — anchor для SupplierSnapshotGuard grace-расчёта. - $newActive = $request->boolean('is_active'); - $project->update([ - 'is_active' => $newActive, - 'paused_at' => $newActive ? null : now(), - ]); + $result = $this->projects->setActive($project, $request->boolean('is_active')); - // #10: pause/resume must reach the supplier. The job's group recompute pushes - // status=paused when no active project of the group remains (resume → active). - // G (балансовый блок): заблокированный за нехваткой баланса проект не - // возобновляется/синхронизируется у поставщика (зеркалит create-гард). - if ($project->preflight_blocked_at === null) { - SyncSupplierProjectJob::dispatch($project->id); + if ($result->activate_deferred ?? false) { + return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409); } - return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]); + return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]); } /** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */ diff --git a/app/app/Services/Autopodbor/AutopodborProjectCreator.php b/app/app/Services/Autopodbor/AutopodborProjectCreator.php index 0de8edcd..e33d4644 100644 --- a/app/app/Services/Autopodbor/AutopodborProjectCreator.php +++ b/app/app/Services/Autopodbor/AutopodborProjectCreator.php @@ -8,6 +8,7 @@ use App\Models\AutopodborSource; use App\Models\Project; use App\Models\Tenant; use App\Services\Project\ProjectService; +use Illuminate\Support\Facades\DB; final class AutopodborProjectCreator { @@ -20,30 +21,30 @@ final class AutopodborProjectCreator */ public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array { - $tenant = Tenant::findOrFail($tenantId); - $sources = AutopodborSource::where('tenant_id', $tenantId) - ->whereIn('id', $sourceIds)->with('competitor')->get(); + return DB::transaction(function () use ($tenantId, $sourceIds, $common, $launch) { + $sources = AutopodborSource::where('tenant_id', $tenantId) + ->whereIn('id', $sourceIds)->with('competitor')->get(); - $created = []; - foreach ($sources as $src) { - $name = $this->uniqueName($tenantId, $this->displayName($src)); - $project = $this->projects->create($tenant, [ - 'name' => $name, - 'signal_type' => $src->signal_type, - 'signal_identifier' => $src->identifier, - 'daily_limit_target' => $common['daily_limit_target'], - 'regions' => $common['regions'], - 'delivery_days_mask' => $common['delivery_days_mask'], - ]); - if (! $launch) { - $project->update(['is_active' => false, 'paused_at' => now()]); - $project = $project->fresh(); + $created = []; + foreach ($sources as $src) { + $name = $this->uniqueName($tenantId, $this->displayName($src)); + // Каждый раз свежий tenant — чтобы кумулятивный гейт внутри + // ProjectService::create видел уже запущенные проекты из предыдущих итераций. + $tenant = Tenant::findOrFail($tenantId); + $project = $this->projects->create($tenant, [ + 'name' => $name, + 'signal_type' => $src->signal_type, + 'signal_identifier' => $src->identifier, + 'daily_limit_target' => $common['daily_limit_target'], + 'regions' => $common['regions'], + 'delivery_days_mask' => $common['delivery_days_mask'], + ], $launch); + $src->update(['created_project_id' => $project->id]); + $created[] = $project; } - $src->update(['created_project_id' => $project->id]); - $created[] = $project; - } - return $created; + return $created; + }); } private function displayName(AutopodborSource $s): string diff --git a/app/app/Services/Billing/GateResult.php b/app/app/Services/Billing/GateResult.php new file mode 100644 index 00000000..ea0fe0a9 --- /dev/null +++ b/app/app/Services/Billing/GateResult.php @@ -0,0 +1,30 @@ + $this->balanceRub, + 'current_capacity_leads' => $this->capacityLeads, + 'would_be_required_leads' => $this->requiredLeads, + 'deficit_leads' => $this->deficitLeads, + 'topup_rub' => $this->topupRub, + ]; + } +} diff --git a/app/app/Services/Billing/LaunchBalanceGate.php b/app/app/Services/Billing/LaunchBalanceGate.php new file mode 100644 index 00000000..d0d67842 --- /dev/null +++ b/app/app/Services/Billing/LaunchBalanceGate.php @@ -0,0 +1,72 @@ +balance_rub; + $deliveredInMonth = (int) $tenant->delivered_in_month; + $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); + + $committed = (int) Project::where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->when($excludeProjectIds !== [], fn ($q) => $q->whereNotIn('id', $excludeProjectIds)) + ->sum('daily_limit_target'); + + $required = $committed + max(0, $additionalLeads); + + if ($tiers->isEmpty()) { + $failClosed = (bool) config('billing.launch_requires_active_tiers', false); + + return new GateResult( + passes: ! $failClosed, + capacityLeads: $failClosed ? 0 : PHP_INT_MAX, + committedLeads: $committed, + requiredLeads: $required, + deficitLeads: $failClosed ? $required : 0, + topupRub: '0.00', + balanceRub: $balanceRub, + ); + } + + $result = $this->preflight->evaluate($balanceRub, $deliveredInMonth, $required, $tiers); + + return new GateResult( + passes: $result->passes, + capacityLeads: $result->capacityLeads, + committedLeads: $committed, + requiredLeads: $required, + deficitLeads: $result->deficitLeads, + topupRub: $this->topupRub($balanceRub, $deliveredInMonth, $tiers, $result->deficitLeads), + balanceRub: $balanceRub, + ); + } + + /** Сколько ₽ пополнить, чтобы дефицит-лиды поместились (по цене текущей ступени). */ + private function topupRub(string $balanceRub, int $deliveredInMonth, $tiers, int $deficitLeads): string + { + if ($deficitLeads <= 0) { + return '0.00'; + } + $priceRub = $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['current_tier']['price_rub'] ?? '0.00'; + + return bcmul($priceRub, (string) $deficitLeads, 2); + } +} diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index 7619d238..b864ac43 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -12,6 +12,7 @@ use App\Models\Tenant; use App\Repositories\PricingTierRepository; use App\Services\Audit\OperationsLogger; use App\Services\Billing\BalancePreflightService; +use App\Services\Billing\LaunchBalanceGate; use App\Services\NotificationService; use App\Services\Supplier\SupplierProjectGrouping; use Illuminate\Http\Exceptions\HttpResponseException; @@ -282,6 +283,47 @@ class ProjectService } } + public function setActive(Project $project, bool $active): Project + { + return DB::transaction(function () use ($project, $active) { + Tenant::whereKey($project->tenant_id)->lockForUpdate()->firstOrFail(); + + if (! $active) { + $project->update(['is_active' => false, 'paused_at' => now()]); + SyncSupplierProjectJob::dispatch($project->id); + $fresh = $project->fresh(); + $fresh->activate_deferred = false; + $fresh->gate_payload = null; + $fresh->syncOriginal(); + + return $fresh; + } + + $tenant = Tenant::findOrFail($project->tenant_id); + $gate = app(LaunchBalanceGate::class) + ->evaluate($tenant, (int) $project->daily_limit_target, [$project->id]); + + if (! $gate->passes) { + $project->update(['preflight_blocked_at' => now()]); // остаётся на паузе + $fresh = $project->fresh(); + $fresh->activate_deferred = true; + $fresh->gate_payload = $gate->toBalancePayload(); + $fresh->syncOriginal(); + + return $fresh; + } + + $project->update(['is_active' => true, 'paused_at' => null, 'preflight_blocked_at' => null]); + SyncSupplierProjectJob::dispatch($project->id); + $fresh = $project->fresh(); + $fresh->activate_deferred = false; + $fresh->gate_payload = null; + $fresh->syncOriginal(); + + return $fresh; + }); + } + public function triggerSync(Project $project): void { // G (балансовый блок): ручная «Синхронизировать» не отправляет заблокированный проект. @@ -354,25 +396,38 @@ class ProjectService /** * Pause/resume + supplier sync per affected project (#10). * - * Without the dispatch, pause never reached the supplier (status stayed active). - * The job's group recompute then pushes status=paused when no active project of - * the group remains, or rebalances the order when some siblings are still active. + * Pause: mass-update (без гейта) + синк per id. + * Resume: по одному через setActive (кумулятивный гейт «сколько влезло»). + * paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ + * триггерит model events, поэтому для паузы пишем явно в одном UPDATE. */ private function bulkPauseResume($query, bool $isActive): array { - $ids = (clone $query)->pluck('id')->all(); - // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11). - // paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ - // триггерит model events, поэтому пишем явно в одном UPDATE. - $updated = $query->update([ - 'is_active' => $isActive, - 'paused_at' => $isActive ? null : DB::raw('NOW()'), - ]); - foreach ($ids as $id) { - SyncSupplierProjectJob::dispatch((int) $id); + if (! $isActive) { + // Пауза — без гейта, как раньше (mass-update + синк per id). + $ids = (clone $query)->pluck('id')->all(); + $updated = $query->update(['is_active' => false, 'paused_at' => DB::raw('NOW()')]); + foreach ($ids as $id) { + SyncSupplierProjectJob::dispatch((int) $id); + } + + return ['updated' => $updated, 'skipped' => [], 'warnings' => []]; } - return ['updated' => $updated, 'skipped' => [], 'warnings' => []]; + // Возобновление — «сколько влезло»: по одному через setActive (кумулятивный гейт). + $projects = (clone $query)->get(); + $updated = 0; + $skipped = []; + foreach ($projects as $project) { + $r = $this->setActive($project, true); + if ($r->is_active) { + $updated++; + } else { + $skipped[] = ['id' => $project->id, 'reason' => 'balance_insufficient']; + } + } + + return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []]; } private function bulkSimpleUpdate($query, array $update): array @@ -617,13 +672,12 @@ class ProjectService } } - public function create(Tenant $tenant, array $data): Project + public function create(Tenant $tenant, array $data, bool $launch = true): Project { // Лимита по числу проектов нет — ограничение только по балансу/заказанным - // лидам (балансовый префлайт в ProjectController::store). Прежний гейт + // лидам (балансовый гейт на запуске). Прежний гейт // tenants.limits['max_projects'] убран как противоречащий правилу продукта. $data['tenant_id'] = $tenant->id; - $data['is_active'] = true; $data['regions'] = $data['regions'] ?? []; // Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для // PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей. @@ -633,29 +687,55 @@ class ProjectService $this->assertNameUnique($tenant->id, (string) $data['name']); $this->assertSourceUnique($tenant->id, $data); - $project = Project::create($data); + return DB::transaction(function () use ($tenant, $data, $launch) { + /** @var Tenant $lockedTenant */ + $lockedTenant = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); - $this->ops->record( - tenantId: $project->tenant_id, - userId: auth()->id(), - entityType: 'project', - entityId: $project->id, - event: 'project.created', - payloadBefore: null, - payloadAfter: $project->toArray(), - ip: request()->ip(), - userAgent: request()->userAgent(), - ); + $gate = null; + if ($launch) { + $gate = app(LaunchBalanceGate::class) + ->evaluate($lockedTenant, (int) $data['daily_limit_target']); + } + $launched = $launch && ($gate !== null && $gate->passes); - // Заблокированный по балансу проект (preflight_blocked_at, Spec C §3.4) НЕ - // заказываем у поставщика — зеркалит фильтр - // BalancePreflightSweepJob::dispatchSupplierSyncIfOnline (->whereNull('preflight_blocked_at')). - // Без гарда продавленный force_save_blocked-проект всё равно уезжал к поставщику - // полным daily_limit_target, хотя лидов он не получает (слепок его исключает). - if ($project->preflight_blocked_at === null) { - SyncSupplierProjectJob::dispatch($project->id); - } + $data['is_active'] = $launched; + // Не запущен из-за баланса → durable-метка «удержан» (не заказываем). + // Черновик (launch=false) → обычная пауза, без метки. + $data['preflight_blocked_at'] = ($launch && ! $launched) ? now() : null; + if (! $launched) { + $data['paused_at'] = now(); + } - return $project->fresh(); + $project = Project::create($data); + + $this->ops->record( + tenantId: $project->tenant_id, + userId: auth()->id(), + entityType: 'project', + entityId: $project->id, + event: 'project.created', + payloadBefore: null, + payloadAfter: $project->toArray(), + ip: request()->ip(), + userAgent: request()->userAgent(), + ); + + // Заказ поставщику — только реально активный и не-удержанный проект. + // Зеркалит фильтр BalancePreflightSweepJob::dispatchSupplierSyncIfOnline. + if ($project->is_active && $project->preflight_blocked_at === null) { + SyncSupplierProjectJob::dispatch($project->id); + } + + $fresh = $project->fresh(); + $fresh->launch_deferred = $launch && ! $launched; + $fresh->gate_payload = ($launch && ! $launched && $gate !== null) + ? $gate->toBalancePayload() + : null; + // syncOriginal(): помечаем transient-поля как «оригинальные», чтобы Eloquent + // не пытался их персистировать при последующем update() на том же объекте. + $fresh->syncOriginal(); + + return $fresh; + }); } } diff --git a/app/config/billing.php b/app/config/billing.php new file mode 100644 index 00000000..87f9b668 --- /dev/null +++ b/app/config/billing.php @@ -0,0 +1,9 @@ + (bool) env('BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS', false), +]; diff --git a/app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php b/app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php index d21882bf..e27a5624 100644 --- a/app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php +++ b/app/database/migrations/2026_06_29_120000_add_box_to_autopodbor.php @@ -2,22 +2,35 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; /** * «Конкурентное поле» — два ящика (предложение / в поле) на конкурентах и источниках. * Approach A (спек §14.1): не плодим таблицы — добавляем пометку-состояние к существующим. + * + * Идемпотентен: safe для squashed-схемы (box уже присутствует в schema-dump). */ return new class extends Migration { public function up(): void { - DB::statement("ALTER TABLE autopodbor_competitors ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'"); - DB::statement("ALTER TABLE autopodbor_competitors ADD CONSTRAINT autopodbor_competitors_box_chk CHECK (box IN ('proposal', 'field'))"); - DB::statement('CREATE INDEX autopodbor_competitors_tenant_box_idx ON autopodbor_competitors (tenant_id, box)'); + // --- autopodbor_competitors --- + if (! Schema::hasColumn('autopodbor_competitors', 'box')) { + DB::statement("ALTER TABLE autopodbor_competitors ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'"); + } - DB::statement("ALTER TABLE autopodbor_sources ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'"); + DB::statement('ALTER TABLE autopodbor_competitors DROP CONSTRAINT IF EXISTS autopodbor_competitors_box_chk'); + DB::statement("ALTER TABLE autopodbor_competitors ADD CONSTRAINT autopodbor_competitors_box_chk CHECK (box IN ('proposal', 'field'))"); + DB::statement('CREATE INDEX IF NOT EXISTS autopodbor_competitors_tenant_box_idx ON autopodbor_competitors (tenant_id, box)'); + + // --- autopodbor_sources --- + if (! Schema::hasColumn('autopodbor_sources', 'box')) { + DB::statement("ALTER TABLE autopodbor_sources ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'"); + } + + DB::statement('ALTER TABLE autopodbor_sources DROP CONSTRAINT IF EXISTS autopodbor_sources_box_chk'); DB::statement("ALTER TABLE autopodbor_sources ADD CONSTRAINT autopodbor_sources_box_chk CHECK (box IN ('proposal', 'field'))"); - DB::statement('CREATE INDEX autopodbor_sources_competitor_box_idx ON autopodbor_sources (competitor_id, box)'); + DB::statement('CREATE INDEX IF NOT EXISTS autopodbor_sources_competitor_box_idx ON autopodbor_sources (competitor_id, box)'); } public function down(): void diff --git a/app/database/migrations/2026_06_29_120100_add_phone_type_to_autopodbor_sources.php b/app/database/migrations/2026_06_29_120100_add_phone_type_to_autopodbor_sources.php index d6041a52..9d463bdb 100644 --- a/app/database/migrations/2026_06_29_120100_add_phone_type_to_autopodbor_sources.php +++ b/app/database/migrations/2026_06_29_120100_add_phone_type_to_autopodbor_sources.php @@ -2,17 +2,24 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; /** * Тип номера телефона (городской/мобильный/8-800) — то, что даёт определитель (DaData). * Спек §14.5, вариант «и тип, и коллтрекинг»: phone_type ДОПОЛНЯЕТ phone_kind * (настоящий/подменный, ✓/🎭), не заменяет его. Для сайтов phone_type = NULL. + * + * Идемпотентен: safe для squashed-схемы (phone_type уже присутствует в schema-dump v8.59+). */ return new class extends Migration { public function up(): void { - DB::statement('ALTER TABLE autopodbor_sources ADD COLUMN phone_type VARCHAR(12)'); + if (! Schema::hasColumn('autopodbor_sources', 'phone_type')) { + DB::statement('ALTER TABLE autopodbor_sources ADD COLUMN phone_type VARCHAR(12)'); + } + + DB::statement('ALTER TABLE autopodbor_sources DROP CONSTRAINT IF EXISTS autopodbor_sources_phone_type_chk'); DB::statement("ALTER TABLE autopodbor_sources ADD CONSTRAINT autopodbor_sources_phone_type_chk CHECK (phone_type IS NULL OR phone_type IN ('city', 'mobile', 'tollfree'))"); } diff --git a/app/tests/Feature/Autopodbor/AutopodborApiTest.php b/app/tests/Feature/Autopodbor/AutopodborApiTest.php index 2007ec3e..65e60ab0 100644 --- a/app/tests/Feature/Autopodbor/AutopodborApiTest.php +++ b/app/tests/Feature/Autopodbor/AutopodborApiTest.php @@ -53,7 +53,9 @@ it('GET /api/autopodbor/competitors/{id} — источники с existing_proj }); it('POST /api/autopodbor/projects — создаёт проекты из источников (201)', function () { - $tenant = Tenant::factory()->create(['balance_rub' => '500000.00']); + // withRequisites: гейт реквизитов первого проекта (паритет с ProjectController@store, + // добавлен для автоподбора в launch-gate-balance Task 11) иначе вернёт 422. + $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '500000.00']); $user = User::factory()->create(['tenant_id' => $tenant->id]); DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); diff --git a/app/tests/Feature/Autopodbor/CreateFromSourcesGateTest.php b/app/tests/Feature/Autopodbor/CreateFromSourcesGateTest.php new file mode 100644 index 00000000..61c2c4f3 --- /dev/null +++ b/app/tests/Feature/Autopodbor/CreateFromSourcesGateTest.php @@ -0,0 +1,97 @@ + 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]); +}); + +/** + * Создаёт tenant + run + competitor + 2 источника типа site. + * + * @return array{0: Tenant, 1: AutopodborSource, 2: AutopodborSource} + */ +function seedTwoAutopodborSources(Tenant $tenant): array +{ + DB::statement('SET app.current_tenant_id = '.$tenant->id); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'done', + 'region_code' => 16, + 'params' => [], + ]); + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, + 'search_run_id' => $run->id, + 'study_run_id' => $run->id, + 'studied_at' => now(), + 'name' => 'Тест-конкурент', + 'box' => 'field', + 'site_url' => 'rival1.ru', + 'dedup_key' => 'site:rival1.ru', + ]); + + $s1 = AutopodborSource::create([ + 'tenant_id' => $tenant->id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => 'site', + 'identifier' => 'rival1.ru', + 'box' => 'field', + 'dedup_key' => 'site:rival1.ru', + ]); + + $s2 = AutopodborSource::create([ + 'tenant_id' => $tenant->id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => 'site', + 'identifier' => 'rival2.ru', + 'box' => 'field', + 'dedup_key' => 'site:rival2.ru', + ]); + + return [$tenant, $s1, $s2]; +} + +it('launch=true partial capacity → some launched, rest held, no premature sync', function () { + Queue::fake(); + + $t = Tenant::factory()->create(['balance_rub' => '600.00', 'delivered_in_month' => 0]); // 6 лидов ёмкость + [, $s1, $s2] = seedTwoAutopodborSources($t); + + $projects = app(AutopodborProjectCreator::class)->createFromSources( + $t->id, + [$s1->id, $s2->id], + ['regions' => [], 'daily_limit_target' => 5, 'delivery_days_mask' => 127], + launch: true, + ); + + $active = collect($projects)->filter(fn ($p) => $p->is_active); + + expect($active)->toHaveCount(1); // 5 влезло, второй (5+5=10 > 6) → удержан + Queue::assertPushed(SyncSupplierProjectJob::class, 1); // только за запущенный +}); diff --git a/app/tests/Feature/Autopodbor/CreateProjectsGateTest.php b/app/tests/Feature/Autopodbor/CreateProjectsGateTest.php new file mode 100644 index 00000000..fd991577 --- /dev/null +++ b/app/tests/Feature/Autopodbor/CreateProjectsGateTest.php @@ -0,0 +1,129 @@ + 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]); +}); + +/** + * Создаёт tenant + user + run + competitor + 2 источника типа site. + * Возвращает [$tenant, $user, $s1, $s2]. + */ +function setupGateScene(array $tenantAttrs = []): array +{ + $tenant = Tenant::factory()->create($tenantAttrs); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + DB::statement('SET app.current_tenant_id = '.$tenant->id); + + $run = AutopodborRun::create([ + 'tenant_id' => $tenant->id, + 'kind' => 'search', + 'status' => 'done', + 'region_code' => 16, + 'params' => [], + ]); + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenant->id, + 'search_run_id' => $run->id, + 'study_run_id' => $run->id, + 'studied_at' => now(), + 'name' => 'Gate-конкурент', + 'box' => 'field', + 'site_url' => 'gatetest1.ru', + 'dedup_key' => 'site:gatetest1.ru', + ]); + + $s1 = AutopodborSource::create([ + 'tenant_id' => $tenant->id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => 'site', + 'identifier' => 'gatetest1.ru', + 'box' => 'field', + 'dedup_key' => 'site:gatetest1.ru', + ]); + + $s2 = AutopodborSource::create([ + 'tenant_id' => $tenant->id, + 'competitor_id' => $comp->id, + 'study_run_id' => $run->id, + 'signal_type' => 'site', + 'identifier' => 'gatetest2.ru', + 'box' => 'field', + 'dedup_key' => 'site:gatetest2.ru', + ]); + + return [$tenant, $user, $s1, $s2]; +} + +it('creates all held with summary when balance insufficient (201)', function () { + Queue::fake(); + + // Баланс на 1 лид, запрашиваем 5 лидов × 2 источника — оба удержаны + [$tenant, $user, $s1, $s2] = setupGateScene(['balance_rub' => '100.00', 'delivered_in_month' => 0]); + + // Реквизиты чтобы пройти гейт реквизитов (tenant новый, проектов 0, нужны реквизиты) + TenantRequisites::create([ + 'tenant_id' => $tenant->id, + 'subject_type' => 'individual', + 'contact_name' => 'Тест Тестов', + 'contact_phone' => '+79991234567', + ]); + + $resp = $this->actingAs($user)->postJson('/api/autopodbor/projects', [ + 'source_ids' => [$s1->id, $s2->id], + 'daily_limit_target' => 5, + 'delivery_days_mask' => 127, + 'launch' => true, + ]); + + $resp->assertCreated(); + $resp->assertJsonPath('launch.launched', 0); + $resp->assertJsonPath('launch.deferred', 2); + // balance payload должен быть от первого удержанного + expect($resp->json('launch.balance'))->not->toBeNull(); + expect($resp->json('launch.balance.topup_rub'))->not->toBeNull(); + // оба проекта созданы (не заблокировано), но не запущены + expect($resp->json('data'))->toHaveCount(2); + expect(collect($resp->json('data'))->filter(fn ($p) => $p['is_active'])->count())->toBe(0); +}); + +it('blocks first project without requisites (422 requisites_required)', function () { + Queue::fake(); + + // Tenant без реквизитов, проектов 0 + [$tenant, $user, $s1, $s2] = setupGateScene(['balance_rub' => '10000.00', 'delivered_in_month' => 0]); + // Реквизиты НЕ создаём намеренно + + $resp = $this->actingAs($user)->postJson('/api/autopodbor/projects', [ + 'source_ids' => [$s1->id, $s2->id], + 'daily_limit_target' => 5, + 'delivery_days_mask' => 127, + 'launch' => true, + ]); + + $resp->assertStatus(422); + $resp->assertJsonPath('error', 'requisites_required'); +}); diff --git a/app/tests/Feature/Billing/BillingOverviewControllerTest.php b/app/tests/Feature/Billing/BillingOverviewControllerTest.php index 3bc5f718..d7ada76b 100644 --- a/app/tests/Feature/Billing/BillingOverviewControllerTest.php +++ b/app/tests/Feature/Billing/BillingOverviewControllerTest.php @@ -75,7 +75,11 @@ test('GET /api/billing/wallet: runway_days = 0 при отрицательном 'tenant_id' => $this->tenant->id, 'type' => 'lead_charge', 'amount_rub' => '-3000.00', - 'created_at' => now()->subDays(10), + // now() (текущий месяц) — а не subDays(10): RefreshDatabase-тесты делают + // migrate:fresh, пересоздающий только текущую+будущие месячные партиции; + // дата прошлого месяца упиралась бы в отсутствующую партицию. На проверку + // runway дата транзакции не влияет. + 'created_at' => now(), ]); // Баланс уже отрицательный → runway не может быть отрицательным, клампится в 0. diff --git a/app/tests/Feature/Billing/LaunchBalanceGateTest.php b/app/tests/Feature/Billing/LaunchBalanceGateTest.php new file mode 100644 index 00000000..ec88d1c2 --- /dev/null +++ b/app/tests/Feature/Billing/LaunchBalanceGateTest.php @@ -0,0 +1,61 @@ + 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => $priceKopecks, + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]); +} + +it('passes when balance covers committed + new', function () { + seedTier(); // 100 ₽/лид + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // ёмкость 10 лидов + $r = app(LaunchBalanceGate::class)->evaluate($t, additionalLeads: 5); + expect($r->passes)->toBeTrue(); +}); + +it('fails and computes topupRub in deficit', function () { + seedTier(); // 100 ₽/лид + $t = Tenant::factory()->create(['balance_rub' => '300.00', 'delivered_in_month' => 0]); // ёмкость 3 лида + $r = app(LaunchBalanceGate::class)->evaluate($t, additionalLeads: 5); // нужно 5, не хватает 2 + expect($r->passes)->toBeFalse(); + expect($r->deficitLeads)->toBe(2); + expect($r->topupRub)->toBe('200.00'); // 2 лида × 100 ₽ +}); + +it('excludes given project from committed', function () { + seedTier(); + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // 10 лидов + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 8, 'preflight_blocked_at' => null]); + // без исключения: committed=8, +5 = 13 > 10 → fail; с исключением self: committed=0, +5=5 → pass + expect(app(LaunchBalanceGate::class)->evaluate($t, 5)->passes)->toBeFalse(); + expect(app(LaunchBalanceGate::class)->evaluate($t, 5, [$p->id])->passes)->toBeTrue(); +}); + +it('fail-closed when no active tiers and flag on', function () { + config()->set('billing.launch_requires_active_tiers', true); + $t = Tenant::factory()->create(['balance_rub' => '1000000.00', 'delivered_in_month' => 0]); + expect(app(LaunchBalanceGate::class)->evaluate($t, 1)->passes)->toBeFalse(); +}); + +it('open when no active tiers and flag off (legacy/tests)', function () { + config()->set('billing.launch_requires_active_tiers', false); + $t = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]); + expect(app(LaunchBalanceGate::class)->evaluate($t, 1)->passes)->toBeTrue(); +}); diff --git a/app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php b/app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php index 20ecf45a..4c39ecc0 100644 --- a/app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php +++ b/app/tests/Feature/Billing/ProjectBlockedSyncGuardTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Jobs\SyncSupplierProjectJob; +use App\Models\PricingTier; use App\Models\Project; use App\Models\Tenant; use App\Models\User; @@ -60,19 +61,30 @@ it('syncs unblocked project to supplier on update', function () { // --- toggle-active (возобновление) --- -it('does not sync blocked project to supplier on toggle-active resume', function () { - $tenant = Tenant::factory()->create(); +it('does not launch or sync project on resume when balance insufficient', function () { + // Тариф: 50₽/лид. Баланс 100₽ → capacity 2; daily_limit_target=30 → 409. + PricingTier::create([ + 'tier_no' => 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 5000, + 'is_active' => true, + 'effective_from' => now(), + ]); + + $tenant = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); $user = User::factory()->create(['tenant_id' => $tenant->id]); $project = Project::factory()->for($tenant)->create([ 'is_active' => false, + 'daily_limit_target' => 30, 'preflight_blocked_at' => now(), ]); $this->actingAs($user)->patchJson("/api/projects/{$project->id}/toggle-active", [ 'is_active' => true, - ])->assertOk(); + ])->assertStatus(409)->assertJsonPath('error', 'balance_insufficient'); Queue::assertNotPushed(SyncSupplierProjectJob::class); + expect((bool) $project->fresh()->is_active)->toBeFalse(); }); it('syncs unblocked project to supplier on toggle-active resume', function () { diff --git a/app/tests/Feature/Billing/ProjectPreflightTest.php b/app/tests/Feature/Billing/ProjectPreflightTest.php index 87db5fcc..7b8605ac 100644 --- a/app/tests/Feature/Billing/ProjectPreflightTest.php +++ b/app/tests/Feature/Billing/ProjectPreflightTest.php @@ -27,8 +27,9 @@ beforeEach(function () { ]); }); -it('returns 409 when new project would overload balance', function () { +it('creates project held (not launched) when it would overload balance', function () { // 1000₽ / 50₽ = 20 лидов capacity; запрашиваем daily_limit_target=30 → дефицит 10. + // Новое поведение: проект создаётся (201), но удерживается с preflight_blocked_at. $tenant = Tenant::factory()->withRequisites()->create(['balance_rub' => '1000.00']); $user = User::factory()->create(['tenant_id' => $tenant->id]); @@ -41,12 +42,19 @@ it('returns 409 when new project would overload balance', function () { 'delivery_days_mask' => 127, ]); - $response->assertStatus(409); - $response->assertJsonPath('error', 'balance_insufficient'); - $response->assertJsonPath('deficit_leads', 10); - $response->assertJsonPath('current_capacity_leads', 20); - $response->assertJsonPath('would_be_required_leads', 30); - expect(Project::where('signal_identifier', 'overload.ru')->exists())->toBeFalse(); + $response->assertCreated(); + $response->assertJsonPath('launch.launched', 0); + $response->assertJsonPath('launch.deferred', 1); + $response->assertJsonPath('launch.balance.deficit_leads', 10); + $response->assertJsonPath('launch.balance.current_capacity_leads', 20); + $response->assertJsonPath('launch.balance.would_be_required_leads', 30); + + $p = Project::where('signal_identifier', 'overload.ru')->first(); + expect($p)->not->toBeNull(); + expect($p->preflight_blocked_at)->not->toBeNull(); + expect((bool) $p->is_active)->toBeFalse(); + + Queue::assertNotPushed(SyncSupplierProjectJob::class); }); it('creates blocked project when force_save_blocked=true', function () { diff --git a/app/tests/Feature/Project/BulkResumeGateTest.php b/app/tests/Feature/Project/BulkResumeGateTest.php new file mode 100644 index 00000000..b9dbb7c8 --- /dev/null +++ b/app/tests/Feature/Project/BulkResumeGateTest.php @@ -0,0 +1,37 @@ + PricingTier::create([ + 'tier_no' => 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), +])); + +it('bulk resume launches only what fits, skips rest with balance reason', function () { + Queue::fake(); + // 1000 ₽ / 100 ₽ за лид = 10 лидов ёмкости + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); + $a = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 6, 'paused_at' => now(), 'preflight_blocked_at' => null]); + $b = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 6, 'paused_at' => now(), 'preflight_blocked_at' => null]); + + $res = app(ProjectService::class)->bulkAction($t->id, 'resume', ['ids' => [$a->id, $b->id]]); + + // Первый (6 лидов) влезает; второй (6+6=12 > 10) — скип. + expect($res['updated'])->toBe(1); + expect($res['skipped'])->toHaveCount(1); + expect($res['skipped'][0]['reason'])->toBe('balance_insufficient'); +}); diff --git a/app/tests/Feature/Project/CreateLaunchGateTest.php b/app/tests/Feature/Project/CreateLaunchGateTest.php new file mode 100644 index 00000000..ab287541 --- /dev/null +++ b/app/tests/Feature/Project/CreateLaunchGateTest.php @@ -0,0 +1,66 @@ + 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]); +}); + +it('creates paused and blocked, no supplier sync, when balance insufficient', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); // 1 лид ёмкость + $p = app(ProjectService::class)->create($t, [ + 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru', + 'daily_limit_target' => 5, 'delivery_days_mask' => 127, + ], launch: true); + + expect($p->is_active)->toBeFalse(); + expect($p->preflight_blocked_at)->not->toBeNull(); + expect($p->launch_deferred)->toBeTrue(); + expect($p->gate_payload['deficit_leads'])->toBe(4); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); + +it('creates active and syncs when balance sufficient', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); + $p = app(ProjectService::class)->create($t, [ + 'name' => 'Y', 'signal_type' => 'site', 'signal_identifier' => 'y.ru', + 'daily_limit_target' => 5, 'delivery_days_mask' => 127, + ], launch: true); + + expect($p->is_active)->toBeTrue(); + expect($p->launch_deferred)->toBeFalse(); + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +it('creates draft paused without gate when launch=false', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]); + $p = app(ProjectService::class)->create($t, [ + 'name' => 'Z', 'signal_type' => 'site', 'signal_identifier' => 'z.ru', + 'daily_limit_target' => 5, 'delivery_days_mask' => 127, + ], launch: false); + + expect($p->is_active)->toBeFalse(); + expect($p->preflight_blocked_at)->toBeNull(); // черновик, не «удержан балансом» + expect($p->launch_deferred)->toBeFalse(); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); diff --git a/app/tests/Feature/Project/SetActiveGateTest.php b/app/tests/Feature/Project/SetActiveGateTest.php new file mode 100644 index 00000000..29f1de5e --- /dev/null +++ b/app/tests/Feature/Project/SetActiveGateTest.php @@ -0,0 +1,69 @@ + PricingTier::create([ + 'tier_no' => 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), +])); + +it('resumes when balance fits', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); + $p = Project::factory()->for($t)->create([ + 'is_active' => false, + 'daily_limit_target' => 5, + 'paused_at' => now(), + 'preflight_blocked_at' => null, + ]); + + $r = app(ProjectService::class)->setActive($p, true); + + expect($r->is_active)->toBeTrue(); + expect($r->activate_deferred)->toBeFalse(); + Queue::assertPushed(SyncSupplierProjectJob::class); +}); + +it('refuses resume when balance insufficient, stays paused, no sync', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); // 1 лид + $p = Project::factory()->for($t)->create([ + 'is_active' => false, + 'daily_limit_target' => 5, + 'paused_at' => now(), + 'preflight_blocked_at' => null, + ]); + + $r = app(ProjectService::class)->setActive($p, true); + + expect($r->is_active)->toBeFalse(); + expect($r->preflight_blocked_at)->not->toBeNull(); + expect($r->activate_deferred)->toBeTrue(); + expect($r->gate_payload['deficit_leads'])->toBe(4); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +}); + +it('pauses without gate', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '0.00']); + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 5]); + $r = app(ProjectService::class)->setActive($p, false); + expect($r->is_active)->toBeFalse(); + expect($r->paused_at)->not->toBeNull(); + Queue::assertPushed(SyncSupplierProjectJob::class); // пауза тоже синкается (снять заказ) +}); diff --git a/app/tests/Feature/Project/StoreLaunchGateTest.php b/app/tests/Feature/Project/StoreLaunchGateTest.php new file mode 100644 index 00000000..11481ea0 --- /dev/null +++ b/app/tests/Feature/Project/StoreLaunchGateTest.php @@ -0,0 +1,78 @@ + 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]); +}); + +it('creates project even when balance insufficient (201, launch deferred)', function () { + $t = Tenant::factory()->withRequisites()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); + $u = User::factory()->for($t)->create(); + + $resp = $this->actingAs($u)->postJson('/api/projects', [ + 'name' => 'A', + 'signal_type' => 'site', + 'signal_identifier' => 'a.ru', + 'daily_limit_target' => 5, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + $resp->assertCreated(); + $resp->assertJsonPath('launch.launched', 0); + $resp->assertJsonPath('launch.deferred', 1); + $resp->assertJsonPath('launch.balance.topup_rub', '400.00'); // не хватает 4 лида × 100 ₽ + expect($t->projects()->first()->is_active)->toBeFalse(); +}); + +it('creates project and launches immediately when balance sufficient (201, launched)', function () { + $t = Tenant::factory()->withRequisites()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); + $u = User::factory()->for($t)->create(); + + $resp = $this->actingAs($u)->postJson('/api/projects', [ + 'name' => 'B', + 'signal_type' => 'site', + 'signal_identifier' => 'b.ru', + 'daily_limit_target' => 5, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + $resp->assertCreated(); + $resp->assertJsonPath('launch.launched', 1); + $resp->assertJsonPath('launch.deferred', 0); + expect($t->projects()->first()->is_active)->toBeTrue(); +}); + +it('returns 422 requisites_required when first project without requisites', function () { + $t = Tenant::factory()->create(['balance_rub' => '1000.00']); + $u = User::factory()->for($t)->create(); + + $resp = $this->actingAs($u)->postJson('/api/projects', [ + 'name' => 'C', + 'signal_type' => 'site', + 'signal_identifier' => 'c.ru', + 'daily_limit_target' => 5, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + $resp->assertStatus(422); + $resp->assertJsonPath('error', 'requisites_required'); +}); diff --git a/app/tests/Feature/Project/ToggleActiveGateTest.php b/app/tests/Feature/Project/ToggleActiveGateTest.php new file mode 100644 index 00000000..f085f017 --- /dev/null +++ b/app/tests/Feature/Project/ToggleActiveGateTest.php @@ -0,0 +1,58 @@ + 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]); +}); + +it('refuses resume via API when balance insufficient (409)', function () { + $t = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 5, 'paused_at' => now(), 'preflight_blocked_at' => null]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}/toggle-active", ['is_active' => true]); + + $resp->assertStatus(409); + $resp->assertJsonPath('error', 'balance_insufficient'); + expect($p->fresh()->is_active)->toBeFalse(); +}); + +it('resumes via API when balance sufficient (200 with data)', function () { + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 5, 'paused_at' => now(), 'preflight_blocked_at' => null]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}/toggle-active", ['is_active' => true]); + + $resp->assertOk(); + $resp->assertJsonStructure(['data']); + expect($p->fresh()->is_active)->toBeTrue(); +}); + +it('pauses via API without balance check (200)', function () { + $t = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]); + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 5]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}/toggle-active", ['is_active' => false]); + + $resp->assertOk(); + expect($p->fresh()->is_active)->toBeFalse(); +}); diff --git a/app/tests/Feature/Project/UpdateLimitGateTest.php b/app/tests/Feature/Project/UpdateLimitGateTest.php new file mode 100644 index 00000000..41247ed6 --- /dev/null +++ b/app/tests/Feature/Project/UpdateLimitGateTest.php @@ -0,0 +1,59 @@ + 1, + 'leads_in_tier' => null, + 'price_per_lead_kopecks' => 10000, + 'is_active' => true, + 'effective_from' => now()->toDateString(), + ]); +}); + +it('rejects limit raise beyond balance with unified payload', function () { + $t = Tenant::factory()->create(['balance_rub' => '300.00', 'delivered_in_month' => 0]); // 3 лида + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 2, 'preflight_blocked_at' => null]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}", ['daily_limit_target' => 10]); + + $resp->assertStatus(409); + $resp->assertJsonPath('error', 'balance_insufficient'); + $resp->assertJsonPath('balance.topup_rub', '700.00'); // не хватает 7 лидов × 100 ₽ + expect($p->fresh()->daily_limit_target)->toBe(2); // лимит НЕ изменился +}); + +it('allows limit raise when balance sufficient', function () { + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // 10 лидов + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 2, 'preflight_blocked_at' => null]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}", ['daily_limit_target' => 5]); + + $resp->assertOk(); + expect($p->fresh()->daily_limit_target)->toBe(5); +}); + +it('allows limit raise on paused project without gate check', function () { + // Паузированный проект: не активный, гейт не нужен + $t = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]); + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 2, 'preflight_blocked_at' => null]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}", ['daily_limit_target' => 10]); + + $resp->assertOk(); + expect($p->fresh()->daily_limit_target)->toBe(10); +}); diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index e5f98cf4..ea072043 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -3,6 +3,8 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; abstract class TestCase extends BaseTestCase { @@ -22,4 +24,41 @@ abstract class TestCase extends BaseTestCase // driver — this override only ever runs under APP_ENV=testing. config(['cache.stores.redis.driver' => 'array']); } + + /** + * Laravel вызывает этот хук из трейта RefreshDatabase сразу после migrate:fresh. + * + * migrate:fresh пересоздаёт только родительские партиционированные таблицы; + * месячные партиции — отдельная команда (только вперёд). Тесты с датами + * прошлых месяцев иначе падают на отсутствии партиции. Диапазон: 2 назад + + * текущий + 3 вперёд. Идемпотентно. + * + * DDL идёт через pgsql_supplier (как в MonthlyPartitionManager::DDL_CONNECTION), + * иначе на проде crm_app_user не имеет прав создавать партиции. + */ + protected function afterRefreshingDatabase() + { + Artisan::call('partitions:create-months', ['--ahead' => 3]); + + // Окно партиций считаем от РЕАЛЬНОГО системного времени (new DateTimeImmutable), + // а не от now()/Carbon: отдельные тесты замораживают Carbon::setTestNow на + // прошлые/будущие даты, и тогда единственный migrate:fresh пересоздавал бы + // месяцы вокруг замороженной даты, не покрывая реальный прошлый месяц (тесты + // с датами вроде now()->subDays(10) падали на отсутствии партиции). Диапазон + // -3..+5 месяцев. Идемпотентно. DDL через pgsql_supplier (DDL_CONNECTION) — + // у crm_app_user нет прав на создание партиций. + $base = new \DateTimeImmutable('first day of this month 00:00:00'); + $parents = DB::select("select c.relname from pg_class c join pg_partitioned_table p on p.partrelid = c.oid where c.relkind = 'p'"); + for ($m = -3; $m <= 5; $m++) { + $start = $base->modify(($m >= 0 ? '+' : '-').abs($m).' months'); + $end = $start->modify('+1 month'); + $suffix = '_y'.$start->format('Y').'_m'.$start->format('m'); + foreach ($parents as $p) { + $t = $p->relname; + DB::connection('pgsql_supplier')->statement( + "CREATE TABLE IF NOT EXISTS \"{$t}{$suffix}\" PARTITION OF \"{$t}\" FOR VALUES FROM ('{$start->format('Y-m-d')}') TO ('{$end->format('Y-m-d')}')" + ); + } + } + } } diff --git a/app/tests/Unit/Billing/GateResultTest.php b/app/tests/Unit/Billing/GateResultTest.php new file mode 100644 index 00000000..31540e69 --- /dev/null +++ b/app/tests/Unit/Billing/GateResultTest.php @@ -0,0 +1,18 @@ +passes)->toBeFalse(); + expect($r->deficitLeads)->toBe(50); + expect($r->toBalancePayload())->toBe([ + 'current_balance_rub' => '123.45', + 'current_capacity_leads' => 100, + 'would_be_required_leads' => 150, + 'deficit_leads' => 50, + 'topup_rub' => '1000.00', + ]); +}); diff --git a/app/tests/Unit/Services/Project/PausedAtWriteSideTest.php b/app/tests/Unit/Services/Project/PausedAtWriteSideTest.php index a729c359..e83f77c4 100644 --- a/app/tests/Unit/Services/Project/PausedAtWriteSideTest.php +++ b/app/tests/Unit/Services/Project/PausedAtWriteSideTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Tests\Unit\Services\Project; -use App\Http\Controllers\Api\ProjectController; use App\Services\Project\ProjectService; use ReflectionMethod; use Tests\TestCase; @@ -34,12 +33,12 @@ class PausedAtWriteSideTest extends TestCase $this->assertStringContainsString('is_active', $body); } - public function test_project_controller_toggle_active_writes_paused_at(): void + public function test_project_service_set_active_writes_paused_at(): void { - $body = $this->methodBody(ProjectController::class, 'toggleActive'); + $body = $this->methodBody(ProjectService::class, 'setActive'); $this->assertStringContainsString('paused_at', $body, - 'toggleActive должен явно обновлять paused_at вместе с is_active'); + 'setActive должен явно обновлять paused_at вместе с is_active'); $this->assertStringContainsString('is_active', $body); } diff --git a/docs/superpowers/plans/2026-07-02-launch-gate-balance.md b/docs/superpowers/plans/2026-07-02-launch-gate-balance.md new file mode 100644 index 00000000..222a2efa --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-launch-gate-balance.md @@ -0,0 +1,1027 @@ +# Единый защищённый гейт запуска (баланс блокирует запуск, не создание) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Проверять баланс только в момент запуска проекта (не создания), в одной защищённой точке под замком на клиента, покрыв все клиентские пути и понятно сообщая клиенту в рублях. + +**Architecture:** Единый доменный сервис `LaunchBalanceGate` (заменяет 3 копии preflight). Гейт встроен в `ProjectService::create`/`setActive`/bulk resume под `DB::transaction`+`lockForUpdate(Tenant)` (сериализация evaluate+activate, паттерн `LedgerService`). Не хватает баланса → проект не запускается, метится существующим `preflight_blocked_at` (durable-метка «удержан балансом», без изменения схемы), фронт показывает единое сообщение в рублях + постоянный бейдж. Групповой запуск — «сколько влезло». Создание никогда не блокируется. + +**Tech Stack:** PHP 8.3, Laravel 13, Pest 4 (`SharesSupplierPdo` при записи через pgsql_supplier), Vue 3 + Vuetify 3, Vitest, Pinia. + +**Спека:** `docs/superpowers/specs/2026-07-02-launch-gate-balance-design.md` + +**Соглашения окружения для тестов (проверено на Фазе 1):** +- Трейт разделяемого PDO — namespace `Tests\Concerns\SharesSupplierPdo` (НЕ `Tests\SharesSupplierPdo`). Подключать на уровне файла как в существующих billing-тестах: `uses(Tests\Concerns\SharesSupplierPdo::class);` (+ `uses(Illuminate\Foundation\Testing\DatabaseTransactions::class);` где нужно). +- `PricingTier` создавать с обязательным `effective_from => now()->toDateString()` (NOT NULL; `PricingTierRepository::activeAt` фильтрует по нему). Пример хелпера: `PricingTier::create(['tier_no'=>1,'leads_in_tier'=>null,'price_per_lead_kopecks'=>10000,'is_active'=>true,'effective_from'=>now()->toDateString()])`. +- НЕ запускать `artisan migrate` (liderra_testing уже мигрирована) и НЕ «голый» `artisan test` (только `--filter=`); pint — только по конкретным файлам. Файлы писать через Write/Edit (без BOM). + +**Общий рабочий каталог:** воркстри `worktree-avtopodbor`, код под `app/`. Тесты бэка: `DB_DATABASE=liderra_testing c:/tools/php83/php artisan test --filter=...`. Пинт: `c:/tools/php83/php ./vendor/bin/pint `. Фронт: `npm run test:vue -- `. + +**Контракт ответа (единый для фронта и бэка):** +- Создание (`POST /api/projects`, `POST /api/autopodbor/projects`): при отложенном запуске → HTTP 201, тело `{data: {...}, launch: {launched: N, deferred: M, balance: BalancePayload|null}}`. +- Запуск/возобновление/лимит (`toggle-active`, `update`): при нехватке → HTTP 409 `{error: 'balance_insufficient', balance: BalancePayload}`. +- `BalancePayload = {current_balance_rub: string, current_capacity_leads: int, would_be_required_leads: int, deficit_leads: int, topup_rub: string}`. + +--- + +## Файловая структура (что и зачем трогаем) + +**Новое (бэк):** +- `app/app/Services/Billing/LaunchBalanceGate.php` — единый гейт (evaluate + topupRub + fail-closed). Одна ответственность: «поместится ли запуск N лидов в баланс + сколько пополнить». +- `app/app/Services/Billing/GateResult.php` — readonly DTO результата гейта. + +**Правим (бэк):** +- `app/app/Services/Project/ProjectService.php` — `create` (+`$launch`, гейт под локом, transient `launch_deferred`/`gate_payload`), новый `setActive`, `bulkPauseResume` (resume через гейт «сколько влезло»). +- `app/app/Http/Controllers/Api/ProjectController.php` — `store`/`update`/`toggleActive` (через сервис/гейт; убрать локальный `runPreflight` и `force_save_blocked`). +- `app/app/Http/Controllers/Api/AutopodborController.php` — `createProjects` (убрать свой `runPreflight`, всегда создавать, гейт реквизитов, сводка). +- `app/app/Services/Autopodbor/AutopodborProjectCreator.php` — `DB::transaction` + проброс `$launch` + сводка «сколько влезло». +- `app/config/billing.php` (создать при отсутствии) — флаг `launch_requires_active_tiers`. + +**Правим (фронт):** +- `app/resources/js/components/projects/ProjectLimitOverloadDialog.vue` — переформулировать в «запуск отложен» (рубли). +- `app/resources/js/views/projects/NewProjectDialog.vue`, `components/projects/ProjectDetailsDrawer.vue`, `components/projects/ProjectCard.vue`, `views/ProjectsView.vue`, `components/projects/BulkActionsBar.vue`, `stores/projectsStore.ts`. +- `app/resources/js/views/autopodbor/screens/CreateScreen.vue`, `views/autopodbor/screens/FieldCompetitorScreen.vue`, `stores/autopodborStore.ts`, `api/autopodbor.ts`. + +**Вне scope (спека §Границы):** ежедневная заморозка/разморозка, правила изменения источника/окон, `SupplierProjectImporter` (служебный импорт — решение владельца «не трогать»), `triggerSync` (см. примечание Task 9b), схема БД/миграции. + +--- + +## ФАЗА 1 — Гейт (ядро) + +### Task 1: config-флаг fail-closed + +**Files:** +- Create/Modify: `app/config/billing.php` + +- [ ] **Step 1: Добавить флаг** + +Если файла нет — создать; если есть — добавить ключ: +```php + (bool) env('BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS', false), +]; +``` + +- [ ] **Step 2: Commit** — `git add app/config/billing.php && git commit -m "feat(billing): флаг launch_requires_active_tiers (fail-closed на проде)"` + +--- + +### Task 2: `GateResult` DTO + +**Files:** +- Create: `app/app/Services/Billing/GateResult.php` +- Test: `app/tests/Unit/Billing/GateResultTest.php` + +- [ ] **Step 1: Failing test** +```php +passes)->toBeFalse(); + expect($r->deficitLeads)->toBe(50); + expect($r->toBalancePayload())->toBe([ + 'current_balance_rub' => '123.45', + 'current_capacity_leads' => 100, + 'would_be_required_leads' => 150, + 'deficit_leads' => 50, + 'topup_rub' => '1000.00', + ]); +}); +``` + +- [ ] **Step 2: Run → fail** — `DB_DATABASE=liderra_testing c:/tools/php83/php artisan test --filter=GateResultTest` — Expected: класс не найден. + +- [ ] **Step 3: Implement** +```php + $this->balanceRub, + 'current_capacity_leads' => $this->capacityLeads, + 'would_be_required_leads' => $this->requiredLeads, + 'deficit_leads' => $this->deficitLeads, + 'topup_rub' => $this->topupRub, + ]; + } +} +``` + +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Pint + commit** — `c:/tools/php83/php ./vendor/bin/pint app/app/Services/Billing/GateResult.php app/tests/Unit/Billing/GateResultTest.php` → `git add -A && git commit -m "feat(billing): GateResult DTO"` + +--- + +### Task 3: `LaunchBalanceGate` сервис + +**Files:** +- Create: `app/app/Services/Billing/LaunchBalanceGate.php` +- Test: `app/tests/Feature/Billing/LaunchBalanceGateTest.php` + +**Контекст для реализатора:** `BalancePreflightService::evaluate(string $balanceRub, int $deliveredInMonth, int $requiredLeads, Collection $tiers): PreflightResult{passes, requiredLeads, capacityLeads, deficitLeads}`. `PricingTierRepository::activeAt(now('Europe/Moscow')): Collection`. `BalanceToLeadsConverter::convert(...)['current_tier']['price_rub']` — цена следующего лида (строка руб). `committed` = сумма `daily_limit_target` проектов `is_active=true AND preflight_blocked_at IS NULL`, кроме `excludeProjectIds`. + +- [ ] **Step 1: Failing test** +```php + 1, 'leads_in_tier' => null, + 'price_per_lead_kopecks' => $priceKopecks, 'is_active' => true]); +} + +it('passes when balance covers committed + new', function () { + seedTier(); // 100 ₽/лид + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // ёмкость 10 лидов + $r = app(LaunchBalanceGate::class)->evaluate($t, additionalLeads: 5); + expect($r->passes)->toBeTrue(); +})->uses(Tests\SharesSupplierPdo::class); + +it('fails and computes topupRub in deficit', function () { + seedTier(); // 100 ₽/лид + $t = Tenant::factory()->create(['balance_rub' => '300.00', 'delivered_in_month' => 0]); // ёмкость 3 лида + $r = app(LaunchBalanceGate::class)->evaluate($t, additionalLeads: 5); // нужно 5, не хватает 2 + expect($r->passes)->toBeFalse(); + expect($r->deficitLeads)->toBe(2); + expect($r->topupRub)->toBe('200.00'); // 2 лида × 100 ₽ +})->uses(Tests\SharesSupplierPdo::class); + +it('excludes given project from committed', function () { + seedTier(); + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // 10 лидов + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 8, 'preflight_blocked_at' => null]); + // без исключения: committed=8, +5 = 13 > 10 → fail; с исключением self: committed=0, +5=5 → pass + expect(app(LaunchBalanceGate::class)->evaluate($t, 5)->passes)->toBeFalse(); + expect(app(LaunchBalanceGate::class)->evaluate($t, 5, [$p->id])->passes)->toBeTrue(); +})->uses(Tests\SharesSupplierPdo::class); + +it('fail-closed when no active tiers and flag on', function () { + config()->set('billing.launch_requires_active_tiers', true); + $t = Tenant::factory()->create(['balance_rub' => '1000000.00', 'delivered_in_month' => 0]); + expect(app(LaunchBalanceGate::class)->evaluate($t, 1)->passes)->toBeFalse(); +})->uses(Tests\SharesSupplierPdo::class); + +it('open when no active tiers and flag off (legacy/tests)', function () { + config()->set('billing.launch_requires_active_tiers', false); + $t = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]); + expect(app(LaunchBalanceGate::class)->evaluate($t, 1)->passes)->toBeTrue(); +})->uses(Tests\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: Run → fail** (класс не найден). + +- [ ] **Step 3: Implement** +```php +balance_rub; + $deliveredInMonth = (int) $tenant->delivered_in_month; + $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); + + $committed = (int) Project::where('tenant_id', $tenant->id) + ->where('is_active', true) + ->whereNull('preflight_blocked_at') + ->when($excludeProjectIds !== [], fn ($q) => $q->whereNotIn('id', $excludeProjectIds)) + ->sum('daily_limit_target'); + + $required = $committed + max(0, $additionalLeads); + + if ($tiers->isEmpty()) { + $failClosed = (bool) config('billing.launch_requires_active_tiers', false); + + return new GateResult( + passes: ! $failClosed, + capacityLeads: $failClosed ? 0 : PHP_INT_MAX, + committedLeads: $committed, + requiredLeads: $required, + deficitLeads: $failClosed ? $required : 0, + topupRub: '0.00', + balanceRub: $balanceRub, + ); + } + + $result = $this->preflight->evaluate($balanceRub, $deliveredInMonth, $required, $tiers); + + return new GateResult( + passes: $result->passes, + capacityLeads: $result->capacityLeads, + committedLeads: $committed, + requiredLeads: $required, + deficitLeads: $result->deficitLeads, + topupRub: $this->topupRub($balanceRub, $deliveredInMonth, $tiers, $result->deficitLeads), + balanceRub: $balanceRub, + ); + } + + /** Сколько ₽ пополнить, чтобы дефицит-лиды поместились (по цене текущей ступени). */ + private function topupRub(string $balanceRub, int $deliveredInMonth, $tiers, int $deficitLeads): string + { + if ($deficitLeads <= 0) { + return '0.00'; + } + $priceRub = $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['current_tier']['price_rub'] ?? '0.00'; + + return bcmul($priceRub, (string) $deficitLeads, 2); + } +} +``` + +- [ ] **Step 4: Run → pass.** Если factory Tenant/Project требует полей — досеять минимально; фикстуры pricing_tiers обязательны (fail-closed off по умолчанию, поэтому первые тесты сеют тариф явно). +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(billing): LaunchBalanceGate — единый гейт запуска + topupRub + fail-closed"` + +--- + +## ФАЗА 2 — Пути запуска через гейт (backend) + +### Task 4: `ProjectService::create` — параметр `$launch` + гейт под локом + +**Files:** +- Modify: `app/app/Services/Project/ProjectService.php:620-660` (create) +- Test: `app/tests/Feature/Project/CreateLaunchGateTest.php` + +**Контекст:** сейчас `create(Tenant $tenant, array $data): Project` жёстко ставит `is_active=true` (стр. 626) и диспатчит `SyncSupplierProjectJob` при `preflight_blocked_at===null` (стр. 655-657). Меняем сигнатуру на `create(Tenant $tenant, array $data, bool $launch = true): Project` и возвращаем Project с transient-полями `launch_deferred` (bool) и `gate_payload` (array|null). + +- [ ] **Step 1: Failing test** +```php + 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 10000, 'is_active' => true]); +}); + +it('creates paused and blocked, no supplier sync, when balance insufficient', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); // 1 лид ёмкость + $p = app(ProjectService::class)->create($t, [ + 'name' => 'X', 'signal_type' => 'site', 'signal_identifier' => 'x.ru', + 'daily_limit_target' => 5, 'delivery_days_mask' => 127, + ], launch: true); + + expect($p->is_active)->toBeFalse(); + expect($p->preflight_blocked_at)->not->toBeNull(); + expect($p->launch_deferred)->toBeTrue(); + expect($p->gate_payload['deficit_leads'])->toBe(4); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +})->uses(Tests\SharesSupplierPdo::class); + +it('creates active and syncs when balance sufficient', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); + $p = app(ProjectService::class)->create($t, [ + 'name' => 'Y', 'signal_type' => 'site', 'signal_identifier' => 'y.ru', + 'daily_limit_target' => 5, 'delivery_days_mask' => 127, + ], launch: true); + + expect($p->is_active)->toBeTrue(); + expect($p->launch_deferred)->toBeFalse(); + Queue::assertPushed(SyncSupplierProjectJob::class); +})->uses(Tests\SharesSupplierPdo::class); + +it('creates draft paused without gate when launch=false', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '0.00', 'delivered_in_month' => 0]); + $p = app(ProjectService::class)->create($t, [ + 'name' => 'Z', 'signal_type' => 'site', 'signal_identifier' => 'z.ru', + 'daily_limit_target' => 5, 'delivery_days_mask' => 127, + ], launch: false); + + expect($p->is_active)->toBeFalse(); + expect($p->preflight_blocked_at)->toBeNull(); // черновик, не «удержан балансом» + expect($p->launch_deferred)->toBeFalse(); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +})->uses(Tests\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: Run → fail** (сигнатура/поведение). + +- [ ] **Step 3: Implement** — заменить тело `create` (сохранив блок assertNameUnique/assertSourceUnique/ops->record): +```php +public function create(Tenant $tenant, array $data, bool $launch = true): Project +{ + $data['tenant_id'] = $tenant->id; + $data['regions'] = $data['regions'] ?? []; + $data['region_mask'] = 255; + $data['region_mode'] = 'include'; + + $this->assertNameUnique($tenant->id, (string) $data['name']); + $this->assertSourceUnique($tenant->id, $data); + + return DB::transaction(function () use ($tenant, $data, $launch) { + /** @var Tenant $lockedTenant */ + $lockedTenant = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); + + $gate = null; + if ($launch) { + $gate = app(\App\Services\Billing\LaunchBalanceGate::class) + ->evaluate($lockedTenant, (int) $data['daily_limit_target']); + } + $launched = $launch && $gate->passes; + + $data['is_active'] = $launched; + // Не запущен из-за баланса → durable-метка «удержан» (не заказываем). + // Черновик (launch=false) → обычная пауза, без метки. + $data['preflight_blocked_at'] = ($launch && ! $launched) ? now() : null; + if (! $launched) { + $data['paused_at'] = now(); + } + + $project = Project::create($data); + + $this->ops->record( + tenantId: $project->tenant_id, userId: auth()->id(), + entityType: 'project', entityId: $project->id, event: 'project.created', + payloadBefore: null, payloadAfter: $project->toArray(), + ip: request()->ip(), userAgent: request()->userAgent(), + ); + + // Заказ поставщику — только реально активный и не-удержанный проект. + if ($project->is_active && $project->preflight_blocked_at === null) { + SyncSupplierProjectJob::dispatch($project->id); + } + + $fresh = $project->fresh(); + $fresh->launch_deferred = $launch && ! $launched; + $fresh->gate_payload = ($launch && ! $launched) ? $gate->toBalancePayload() : null; + + return $fresh; + }); +} +``` + +- [ ] **Step 4: Run → pass** (все три теста + прогнать существующие create-тесты, что не сломались: `--filter=Project`). +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(project): create — баланс на запуске, черновик без заказа, гонки под локом"` + +--- + +### Task 5: `ProjectService::setActive` — запуск/возобновление через гейт + +**Files:** +- Modify: `app/app/Services/Project/ProjectService.php` (добавить метод рядом с triggerSync) +- Test: `app/tests/Feature/Project/SetActiveGateTest.php` + +- [ ] **Step 1: Failing test** +```php + PricingTier::create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 10000, 'is_active' => true])); + +it('resumes when balance fits', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); + $p = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 5, 'paused_at' => now(), 'preflight_blocked_at' => null]); + + $r = app(ProjectService::class)->setActive($p, true); + + expect($r->is_active)->toBeTrue(); + expect($r->activate_deferred)->toBeFalse(); + Queue::assertPushed(SyncSupplierProjectJob::class); +})->uses(Tests\SharesSupplierPdo::class); + +it('refuses resume when balance insufficient, stays paused, no sync', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); // 1 лид + $p = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 5, 'paused_at' => now(), 'preflight_blocked_at' => null]); + + $r = app(ProjectService::class)->setActive($p, true); + + expect($r->is_active)->toBeFalse(); + expect($r->preflight_blocked_at)->not->toBeNull(); + expect($r->activate_deferred)->toBeTrue(); + expect($r->gate_payload['deficit_leads'])->toBe(4); + Queue::assertNotPushed(SyncSupplierProjectJob::class); +})->uses(Tests\SharesSupplierPdo::class); + +it('pauses without gate', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '0.00']); + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 5]); + $r = app(ProjectService::class)->setActive($p, false); + expect($r->is_active)->toBeFalse(); + expect($r->paused_at)->not->toBeNull(); + Queue::assertPushed(SyncSupplierProjectJob::class); // пауза тоже синкается (снять заказ) +})->uses(Tests\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: Run → fail** (метод не найден). + +- [ ] **Step 3: Implement** +```php +public function setActive(Project $project, bool $active): Project +{ + return DB::transaction(function () use ($project, $active) { + Tenant::whereKey($project->tenant_id)->lockForUpdate()->firstOrFail(); + + if (! $active) { + $project->update(['is_active' => false, 'paused_at' => now()]); + $fresh = $project->fresh(); + $fresh->activate_deferred = false; + $fresh->gate_payload = null; + SyncSupplierProjectJob::dispatch($project->id); + + return $fresh; + } + + $tenant = Tenant::findOrFail($project->tenant_id); + $gate = app(\App\Services\Billing\LaunchBalanceGate::class) + ->evaluate($tenant, (int) $project->daily_limit_target, [$project->id]); + + if (! $gate->passes) { + $project->update(['preflight_blocked_at' => now()]); // остаётся на паузе + $fresh = $project->fresh(); + $fresh->activate_deferred = true; + $fresh->gate_payload = $gate->toBalancePayload(); + + return $fresh; + } + + $project->update(['is_active' => true, 'paused_at' => null, 'preflight_blocked_at' => null]); + SyncSupplierProjectJob::dispatch($project->id); + $fresh = $project->fresh(); + $fresh->activate_deferred = false; + $fresh->gate_payload = null; + + return $fresh; + }); +} +``` + +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(project): setActive — гейт запуска/возобновления под локом"` + +--- + +### Task 6: `bulkPauseResume` resume — «сколько влезло» + +**Files:** +- Modify: `app/app/Services/Project/ProjectService.php:361-376` (bulkPauseResume) +- Test: `app/tests/Feature/Project/BulkResumeGateTest.php` + +- [ ] **Step 1: Failing test** +```php + PricingTier::create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 10000, 'is_active' => true])); + +it('bulk resume launches only what fits, skips rest with balance reason', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '1000.00', 'delivered_in_month' => 0]); // 10 лидов ёмкости + $a = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 6, 'paused_at' => now(), 'preflight_blocked_at' => null]); + $b = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 6, 'paused_at' => now(), 'preflight_blocked_at' => null]); + + $res = app(ProjectService::class)->bulkAction($t->id, 'resume', ['ids' => [$a->id, $b->id]]); + + expect($res['updated'])->toBe(1); // 6 влезло, второй 6+6=12 > 10 → skip + expect($res['skipped'])->toHaveCount(1); + expect($res['skipped'][0]['reason'])->toBe('balance_insufficient'); +})->uses(Tests\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: Run → fail** (сейчас resume безусловный, updated=2, skipped пуст). + +- [ ] **Step 3: Implement** — заменить `bulkPauseResume`: +```php +private function bulkPauseResume($query, bool $isActive): array +{ + if (! $isActive) { + // Пауза — без гейта, как раньше (mass-update + синк per id). + $ids = (clone $query)->pluck('id')->all(); + $updated = $query->update(['is_active' => false, 'paused_at' => DB::raw('NOW()')]); + foreach ($ids as $id) { + SyncSupplierProjectJob::dispatch((int) $id); + } + + return ['updated' => $updated, 'skipped' => [], 'warnings' => []]; + } + + // Возобновление — «сколько влезло»: по одному через setActive (кумулятивный гейт). + $projects = (clone $query)->get(); + $updated = 0; + $skipped = []; + foreach ($projects as $project) { + $r = $this->setActive($project, true); + if ($r->is_active) { + $updated++; + } else { + $skipped[] = ['id' => $project->id, 'reason' => 'balance_insufficient']; + } + } + + return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []]; +} +``` + +- [ ] **Step 4: Run → pass** (+ регрессия bulk pause). +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(project): bulk resume — сколько влезло, skipped balance"` + +--- + +### Task 7: `ProjectController@store` — создание без блока, deferred-ответ + +**Files:** +- Modify: `app/app/Http/Controllers/Api/ProjectController.php:126-166` (store) +- Test: `app/tests/Feature/Project/StoreLaunchGateTest.php` + +- [ ] **Step 1: Failing test** +```php + PricingTier::create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 10000, 'is_active' => true])); + +it('creates project even when balance insufficient (201, launch deferred)', function () { + $t = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); + // реквизиты, чтобы гейт первого проекта не мешал + $t->update(['requisites' => ['inn' => '1', 'name' => 'X']]); // при необходимости — через isLightComplete-минимум + $u = User::factory()->for($t)->create(); + + $resp = $this->actingAs($u)->postJson('/api/projects', [ + 'name' => 'A', 'signal_type' => 'site', 'signal_identifier' => 'a.ru', + 'daily_limit_target' => 5, 'delivery_days_mask' => 127, + ]); + + $resp->assertCreated(); + $resp->assertJsonPath('launch.launched', 0); + $resp->assertJsonPath('launch.deferred', 1); + $resp->assertJsonPath('launch.balance.topup_rub', '400.00'); // не хватает 4 лида × 100 ₽ + expect($t->projects()->first()->is_active)->toBeFalse(); +})->uses(Tests\SharesSupplierPdo::class); +``` +*(Реализатор: сверить, как именно `RequisitesService::isLightComplete` считает готовность — засеять минимально необходимое, чтобы дойти до баланс-ветки.)* + +- [ ] **Step 2: Run → fail** (сейчас вернёт 409). + +- [ ] **Step 3: Implement** — заменить тело `store` (сохранив гейт реквизитов первого проекта строки 131-135): +```php +public function store(StoreProjectRequest $request): JsonResponse +{ + $validated = $request->validated(); + $tenant = $request->user()->tenant; + + if (Project::where('tenant_id', $tenant->id)->count() === 0 + && ! $this->requisites->isLightComplete($tenant)) { + return response()->json(['error' => 'requisites_required'], 422); + } + + unset($validated['force_save_blocked']); // больше не блокируем создание + + $project = $this->projects->create($tenant, $validated, launch: true); + + return response()->json([ + 'data' => new ProjectResource($project->loadCount('supplierProjects')), + 'launch' => [ + 'launched' => $project->launch_deferred ? 0 : 1, + 'deferred' => $project->launch_deferred ? 1 : 0, + 'balance' => $project->gate_payload, + ], + ], 201); +} +``` +Удалить локальный `runPreflight` из контроллера (Task 8 использует гейт), если он больше нигде не нужен после Task 8. + +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(project): store — создание всегда, баланс на запуске"` + +--- + +### Task 8: `ProjectController@update` — повышение лимита через гейт + +**Files:** +- Modify: `app/app/Http/Controllers/Api/ProjectController.php:169-207` (update) + удалить `runPreflight` (212-235) +- Test: `app/tests/Feature/Project/UpdateLimitGateTest.php` + +- [ ] **Step 1: Failing test** +```php + PricingTier::create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 10000, 'is_active' => true])); + +it('rejects limit raise beyond balance with unified payload', function () { + $t = Tenant::factory()->create(['balance_rub' => '300.00', 'delivered_in_month' => 0]); // 3 лида + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => true, 'daily_limit_target' => 2, 'preflight_blocked_at' => null]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}", ['daily_limit_target' => 10]); + + $resp->assertStatus(409); + $resp->assertJsonPath('error', 'balance_insufficient'); + $resp->assertJsonPath('balance.topup_rub', '700.00'); // не хватает 7 лидов × 100 ₽ + expect($p->fresh()->daily_limit_target)->toBe(2); // лимит НЕ изменился +})->uses(Tests\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement** — в `update` заменить блок префлайта (179-202) на гейт; убрать `force_save_blocked`: +```php +public function update(UpdateProjectRequest $request, int $id): JsonResponse +{ + $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); + $validated = $request->validated(); + $tenant = $request->user()->tenant; + unset($validated['force_save_blocked']); + + if (array_key_exists('daily_limit_target', $validated) + && (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target + && $project->is_active && $project->preflight_blocked_at === null) { + $delta = (int) $validated['daily_limit_target'] - (int) $project->daily_limit_target; + $gate = app(\App\Services\Billing\LaunchBalanceGate::class)->evaluate($tenant, $delta, [$project->id]); + if (! $gate->passes) { + return response()->json(array_merge(['error' => 'balance_insufficient'], ['balance' => $gate->toBalancePayload()]), 409); + } + } + + $updated = $this->projects->update($project, $validated); + + return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]); +} +``` +Удалить приватный `runPreflight` (212-235), `use PricingTierRepository/BalancePreflightService` если больше не нужны. + +- [ ] **Step 4: Run → pass** (+ регрессия update-тестов). +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(project): update лимита через единый гейт"` + +--- + +### Task 9: `ProjectController@toggleActive` — через setActive + +**Files:** +- Modify: `app/app/Http/Controllers/Api/ProjectController.php:267-289` (toggleActive) +- Test: `app/tests/Feature/Project/ToggleActiveGateTest.php` + +- [ ] **Step 1: Failing test** +```php + PricingTier::create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 10000, 'is_active' => true])); + +it('refuses resume via API when balance insufficient (409)', function () { + $t = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]); + $u = User::factory()->for($t)->create(); + $p = Project::factory()->for($t)->create(['is_active' => false, 'daily_limit_target' => 5, 'paused_at' => now(), 'preflight_blocked_at' => null]); + + $resp = $this->actingAs($u)->patchJson("/api/projects/{$p->id}/toggle-active", ['is_active' => true]); + + $resp->assertStatus(409); + $resp->assertJsonPath('error', 'balance_insufficient'); + expect($p->fresh()->is_active)->toBeFalse(); +})->uses(Tests\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: Run → fail** (сейчас возобновит, 200). + +- [ ] **Step 3: Implement** +```php +public function toggleActive(Request $request, int $id): JsonResponse +{ + $request->validate(['is_active' => ['required', 'boolean']]); + $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); + + $result = $this->projects->setActive($project, $request->boolean('is_active')); + + if ($result->activate_deferred ?? false) { + return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409); + } + + return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]); +} +``` + +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(project): toggle-active (возобновление) через гейт"` + +**Task 9b (примечание, кода нет):** `ProjectService::triggerSync` (ручная «Синхронизировать») НЕ меняем — она не активирует и не повышает лимит, только пере-отправляет текущий заказ активного проекта и уже гардит `preflight_blocked_at`. Новый заказ сверх обязательства не создаёт → гейт не нужен. Зафиксировано осознанно. + +--- + +## ФАЗА 3 — Конкурентное поле + +### Task 10: `AutopodborProjectCreator` — транзакция + launch + «сколько влезло» + +**Files:** +- Modify: `app/app/Services/Autopodbor/AutopodborProjectCreator.php:21-47` +- Test: `app/tests/Feature/Autopodbor/CreateFromSourcesGateTest.php` + +- [ ] **Step 1: Failing test** (проверяет: launch=true частичная ёмкость → часть активна, часть удержана; без преждевременного синка; транзакционность). Реализатор: засеять tenant + 2 `AutopodborSource` с competitor; PricingTier как выше. +```php + PricingTier::create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 10000, 'is_active' => true])); + +it('launch=true partial capacity → some launched, rest held, no premature sync', function () { + Queue::fake(); + $t = Tenant::factory()->create(['balance_rub' => '600.00', 'delivered_in_month' => 0]); // 6 лидов + [$s1, $s2] = seedTwoAutopodborSources($t); // helper реализатора: 2 источника + $projects = app(AutopodborProjectCreator::class)->createFromSources( + $t->id, [$s1->id, $s2->id], + ['regions' => [], 'daily_limit_target' => 5, 'delivery_days_mask' => 127], + launch: true, + ); + $active = collect($projects)->filter->is_active; + expect($active)->toHaveCount(1); // 5 влезло, второй 5+5=10 > 6 → удержан + Queue::assertPushed(SyncSupplierProjectJob::class, 1); // только за запущенный +})->uses(Tests\SharesSupplierPdo::class); +``` + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement** +```php +public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array +{ + return \Illuminate\Support\Facades\DB::transaction(function () use ($tenantId, $sourceIds, $common, $launch) { + $tenant = Tenant::findOrFail($tenantId); + $sources = AutopodborSource::where('tenant_id', $tenantId) + ->whereIn('id', $sourceIds)->with('competitor')->get(); + + $created = []; + foreach ($sources as $src) { + $name = $this->uniqueName($tenantId, $this->displayName($src)); + // ProjectService::create сам проходит гейт под локом; «сколько влезло» + // работает т.к. запущенные добавляются в committed для следующего. + $project = $this->projects->create($tenant->fresh(), [ + 'name' => $name, + 'signal_type' => $src->signal_type, + 'signal_identifier' => $src->identifier, + 'daily_limit_target' => $common['daily_limit_target'], + 'regions' => $common['regions'], + 'delivery_days_mask' => $common['delivery_days_mask'], + ], $launch); + $src->update(['created_project_id' => $project->id]); + $created[] = $project; + } + + return $created; + }); +} +``` + +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(autopodbor): создание пачки в транзакции, launch через общий гейт"` + +--- + +### Task 11: `AutopodborController@createProjects` — убрать свой префлайт, всегда создавать, сводка + +**Files:** +- Modify: `app/app/Http/Controllers/Api/AutopodborController.php:632-712` +- Test: `app/tests/Feature/Autopodbor/CreateProjectsGateTest.php` + +- [ ] **Step 1: Failing test** (launch=true нехватка → 201, всё удержано, сводка deferred=N; гейт реквизитов первого проекта → 422). +```php +it('creates all held with summary when balance insufficient (201)', function () { + // tenant с реквизитами, PricingTier 100 ₽/лид, balance на 1 лид, 2 источника, daily=5 + // POST /api/autopodbor/projects {source_ids:[..], daily_limit_target:5, delivery_days_mask:127, launch:true} + // assertCreated(); assertJsonPath('launch.launched', 0); assertJsonPath('launch.deferred', 2); +})->uses(Tests\SharesSupplierPdo::class); + +it('blocks first project without requisites (422 requisites_required)', function () { + // tenant без реквизитов, 0 проектов → 422 +})->uses(Tests\SharesSupplierPdo::class); +``` +*(Реализатор дописывает тела по образцу Task 7/10; ключ — статусы и сводка.)* + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement** — заменить `createProjects`; удалить приватный `runPreflight` (685-712): +```php +public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse +{ + $v = $request->validate([ + 'source_ids' => 'required|array', + 'source_ids.*' => 'integer', + 'regions' => 'array', + 'regions.*' => 'integer', + 'daily_limit_target' => 'required|integer|min:1|max:10000', + 'delivery_days_mask' => 'required|integer', + 'launch' => 'boolean', + ]); + + $tenant = $request->user()->tenant; + $launch = (bool) ($v['launch'] ?? false); + + // Гейт реквизитов первого проекта — как в ProjectController@store. + if (Project::where('tenant_id', $tenant->id)->count() === 0 + && ! app(\App\Services\Tenant\RequisitesService::class)->isLightComplete($tenant)) { + return response()->json(['error' => 'requisites_required'], 422); + } + + $projects = $creator->createFromSources( + $tenant->id, $v['source_ids'], + ['regions' => $v['regions'] ?? [], 'daily_limit_target' => (int) $v['daily_limit_target'], 'delivery_days_mask' => (int) $v['delivery_days_mask']], + $launch, + ); + + $launched = collect($projects)->filter->is_active->count(); + $deferred = count($projects) - $launched; + // payload баланса — от первого удержанного (для сообщения «пополните ~X ₽»). + $held = collect($projects)->first(fn ($p) => ($p->launch_deferred ?? false)); + + return response()->json([ + 'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name, 'is_active' => (bool) $p->is_active])->all(), + 'launch' => ['launched' => $launched, 'deferred' => $deferred, 'balance' => $held?->gate_payload], + ], 201); +} +``` +*(Реализатор: сверить точный namespace класса реквизитов — тот же, что в `ProjectController` через `$this->requisites`.)* + +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Pint + commit** — `git commit -m "feat(autopodbor): createProjects — всегда создаём, единый гейт, гейт реквизитов, сводка"` + +--- + +## ФАЗА 4 — Фронт (единое понятное сообщение + бейдж + reroute) + +> Фронт-задачи: сначала Vitest-тест поведения (red), затем правка компонента (green). Реализатор читает актуальные компоненты; ниже — контракт и точки правок. + +### Task 12: единый компонент сообщения (рубли) + +**Files:** +- Modify: `app/resources/js/components/projects/ProjectLimitOverloadDialog.vue` +- Test: `app/resources/js/components/projects/__tests__/ProjectLimitOverloadDialog.spec.ts` (создать/дополнить) + +- [ ] **Step 1: Vitest — рендерит рубли и действия** +```ts +import { mount } from '@vue/test-utils'; +import { describe, it, expect } from 'vitest'; +import Dialog from '../ProjectLimitOverloadDialog.vue'; + +describe('ProjectLimitOverloadDialog', () => { + it('shows top-up amount in rubles and reduce/topup actions', () => { + const w = mount(Dialog, { props: { modelValue: true, payload: { + current_balance_rub: '100.00', current_capacity_leads: 1, + would_be_required_leads: 5, deficit_leads: 4, topup_rub: '400.00' } } }); + expect(w.text()).toContain('не запущен'); + expect(w.text()).toContain('400'); // ₽ пополнить + expect(w.find('[data-test="topup"]').exists()).toBe(true); + expect(w.find('[data-test="reduce"]').exists()).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run → fail** — `npm run test:vue -- ProjectLimitOverloadDialog`. +- [ ] **Step 3: Implement** — переписать тексты: заголовок «Проект создан, но не запущен» / «Проект не запущен»; тело «Чтобы запустить (≈ {would_be_required_leads − committed?} лидов/день), пополните примерно на {{ payload.topup_rub }} ₽ или уменьшите объём»; кнопки с `data-test="topup"` (→ `/billing`) и `data-test="reduce"` (emit `reduce`); использовать `payload.topup_rub`. Добавить проп поддержки группового текста (опционально `summary?: {launched, deferred}`). +- [ ] **Step 4: Run → pass.** +- [ ] **Step 5: Commit** — `git commit -m "feat(ui): единое сообщение об отложенном запуске (рубли)"` + +### Task 13: `NewProjectDialog` — deferred на создании + +**Files:** `app/resources/js/views/projects/NewProjectDialog.vue`, тест `__tests__/NewProjectDialog.launch.spec.ts`. +- [ ] **Step 1: Vitest** — при ответе 201 c `launch.deferred=1` показывается сообщение (не блок), проект считается созданным; баннер «поставит в сбор сразу» заменён на условие «если хватает баланса». +- [ ] **Step 2: fail.** +- [ ] **Step 3: Implement** — в `persist()` success-ветке: если `resp.data.launch?.deferred` → открыть единый диалог с `resp.data.launch.balance`; иначе как сейчас. Убрать обработку 409 balance на создании (её больше не будет). Обновить баннер (стр. ~83). +- [ ] **Step 4: pass. Step 5: commit** `git commit -m "feat(ui): создание проекта — сообщение об отложенном запуске"`. + +### Task 14: возобновление (toggle) — обработка 409 + +**Files:** `app/resources/js/stores/projectsStore.ts` (`toggleActive`), `components/projects/ProjectCard.vue`, `components/projects/ProjectDetailsDrawer.vue`, `views/ProjectsView.vue`. Тест: `stores/__tests__/projectsStore.toggle.spec.ts`. +- [ ] **Step 1: Vitest** — `toggleActive` при 409 balance не переключает `is_active` в сторе и прокидывает payload/флаг для показа сообщения. +- [ ] **Step 2: fail.** +- [ ] **Step 3: Implement** — обернуть `toggleActive` в try/catch; при 409 `balance_insufficient` не менять состояние, вернуть/выбросить payload; в `ProjectsView`/карточке/дроуэре показать единый диалог. (Сейчас `@toggle-active="store.toggleActive"` — голый вызов; добавить обработчик.) +- [ ] **Step 4: pass. Step 5: commit** `git commit -m "feat(ui): возобновление — сообщение при нехватке баланса"`. + +### Task 15: постоянный бейдж «не запущен — не хватает баланса» + +**Files:** `components/projects/ProjectCard.vue`, `ProjectDetailsDrawer.vue`, `views/ProjectsView.vue`. Тест: `ProjectCard.badge.spec.ts`. +**Контекст:** проект с `preflight_blocked_at != null` = удержан балансом (в отличие от обычной паузы). `ProjectResource` уже отдаёт `preflight_blocked_at` (проверить; если нет — добавить в ресурс, схему НЕ трогаем). +- [ ] **Step 1: Vitest** — проект с `preflight_blocked_at` показывает бейдж «Не запущен — не хватает баланса»; обычная пауза (`preflight_blocked_at=null, is_active=false`) — обычный «Приостановлен». +- [ ] **Step 2: fail.** +- [ ] **Step 3: Implement** — бейдж по `project.preflight_blocked_at`; подсказка «пополните, чтобы запустить». Убедиться, что `ProjectResource` включает поле. +- [ ] **Step 4: pass. Step 5: commit** `git commit -m "feat(ui): постоянный бейдж «не запущен — не хватает баланса»"`. + +### Task 16: `BulkActionsBar` — сводка «запущено/отложено» на resume + +**Files:** `components/projects/BulkActionsBar.vue`. Тест: `BulkActionsBar.resume.spec.ts`. +- [ ] **Step 1: Vitest** — при ответе resume `{updated:1, skipped:[{reason:'balance_insufficient'}]}` тост: «Запущено 1, отложено 1 — не хватает баланса». +- [ ] **Step 2: fail.** +- [ ] **Step 3: Implement** — расширить группировку skipped-причин (уже есть для `balance_insufficient` на лимите; переиспользовать текст для resume-сводки «запущено N, отложено M»). +- [ ] **Step 4: pass. Step 5: commit** `git commit -m "feat(ui): bulk resume — сводка запущено/отложено"`. + +### Task 17: конкурентное поле — deferred-сводка + reroute bulk + +**Files:** `views/autopodbor/screens/CreateScreen.vue`, `views/autopodbor/screens/FieldCompetitorScreen.vue`, `stores/autopodborStore.ts`, `api/autopodbor.ts`. Тест: `FieldCompetitorScreen.bulk.spec.ts`, `CreateScreen.launch.spec.ts`. +- [ ] **Step 1: Vitest (a)** — `CreateScreen` на ответ `launch.deferred>0` показывает единый диалог/сводку. **(b)** массовые действия `FieldCompetitorScreen` (`bulkWork`) шлют `POST /api/projects/bulk` с `ids` = project_id выбранных источников (а не цикл по одиночным ручкам). +- [ ] **Step 2: fail.** +- [ ] **Step 3: Implement** — (a) `makeProjects`/`createProjects` возвращают `launch`-сводку → показать. (b) переписать `bulkWork` (стр. ~177-199) на сбор `s.project.id` и вызов `projectsStore.bulkAction`/api `/api/projects/bulk` (pause/resume/delete) — наследует BULK_MAX + слепок-защиту + гейт. +- [ ] **Step 4: pass. Step 5: commit** `git commit -m "feat(autopodbor): deferred-сводка + массовые действия через /api/projects/bulk"`. + +--- + +## ФАЗА 5 — Финализация + +### Task 18: Полный прогон + нормативка + +- [ ] **Step 1:** Бэк — `DB_DATABASE=liderra_testing c:/tools/php83/php artisan test --filter="Project|Autopodbor|Billing"` — все зелёные; затем полный `artisan test` (регрессия). +- [ ] **Step 2:** Фронт — `npm run test:vue` — зелёный; `npm run type-check`. +- [ ] **Step 3:** Pint — `c:/tools/php83/php ./vendor/bin/pint --dirty`. +- [ ] **Step 4:** Записать правило «баланс блокирует запуск, не создание; единый гейт под локом; групповой запуск сколько влезло» в продуктовую документацию (файл — уточнить: `docs/CRM_bp-gr_Инструкция_v8_5.md` §биллинг/проекты, через контроль версий документа). Схему БД НЕ трогаем. +- [ ] **Step 5:** Финальное ревью всей ветки (subagent-driven-development финальный reviewer). Коммит документации — по «эскейп». + +--- + +## Self-Review (проверено против спека) + +- **Создание не блокируется:** Task 4/7/10/11. ✅ +- **Запуск гейтится везде:** create (4/7), resume single (5/9), bulk resume (6), автоподбор (10/11), лимит (8). ✅ +- **Атомарность/гонки:** `DB::transaction`+`lockForUpdate(Tenant)` в create (4) и setActive (5); bulk resume идёт через setActive (6). ✅ +- **Fail-closed без тарифа:** Task 1 + Task 3. ✅ +- **«Сколько влезло»:** bulk (6), autopodbor (10). ✅ +- **Durable-метка + бейдж:** preflight_blocked_at (4/5) + Task 15. ✅ +- **Рубли/понятность:** topupRub (3), единый компонент (12), точки 13/14/16/17. ✅ +- **Автоподбор bulk через общий механизм:** Task 17b. ✅ +- **triggerSync:** осознанно без изменений (Task 9b). ✅ +- **Схема БД:** не трогаем. ✅ +- **Вне scope:** импорт поставщика, sweep/release, правила изменения — не тронуты. ✅ + +**Тип-консистентность:** `GateResult` (2) используется в `LaunchBalanceGate` (3), `create`/`setActive` (4/5), контроллерах (7/8/9/11). Контракт ответа `{launch:{launched,deferred,balance}}` (create) и `{error,balance}` (409) — единый, фронт (12-17) на него завязан. diff --git a/docs/superpowers/specs/2026-07-02-launch-gate-balance-design.md b/docs/superpowers/specs/2026-07-02-launch-gate-balance-design.md new file mode 100644 index 00000000..96348a57 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-launch-gate-balance-design.md @@ -0,0 +1,133 @@ +# Дизайн: «Баланс блокирует запуск, а не создание» — единый защищённый гейт запуска + +**Дата:** 02.07.2026 +**Воркстри:** `worktree-avtopodbor` (код под `app/`), НЕ на боевом. +**Статус:** дизайн согласован с владельцем (единое правило; групповой запуск «сколько влезло»; расширен адверсариальным аудитом 02.07 — защита от перерасхода у поставщика и ясность сообщений). Ждёт implementation-plan. + +--- + +## Goal + +Создавать проекты можно всегда без проверки баланса; проверка денег происходит только в момент **запуска** (сделать проект активным = начать заказывать лиды у поставщика). Правило единое для обычных проектов и «Конкурентного поля», **не обходится ни одним путём**, защищено от гонок, и клиенту **предельно понятно** сообщается, что делать (в рублях). + +## Главный принцип (что именно защищаем) + +Адверсариальный аудит 02.07 показал: **баланс клиента в минус из клиентского потока НЕ уходит** — списание за лид (`LedgerService::chargeForDelivery`) атомарно, под `lockForUpdate(Tenant)`, с проверкой остатка и потолком `delivered_today`. Реальные деньги мы теряем **на ПЕРЕЗАКАЗЕ у поставщика**: если проект активирован на объём, который клиент оплатить не может, мы уже заказали (и платим поставщику) за этот объём. Поэтому защищать нужно **момент активации/заказа**, и делать это **атомарно**. + +Корневая причина всех дыр: балансовое правило продублировано в контроллерах (`store`, `update`, `bulkUpdateLimit`, autopodbor `createProjects`) и **полностью отсутствует** в возобновлении, ручной синхронизации, bulk-resume. Решение — **один защищённый гейт в доменном слое**. + +--- + +## Каноническое правило + +> **Создание** проекта никогда не проверяет баланс и само по себе не заказывает лиды. +> **Запуск** = перевод проекта в состояние «активен и заказывает у поставщика». Точки запуска: создать-и-запустить, запустить/возобновить (одиночно и массово), ручная синхронизация, повышение дневного лимита, автосоздание-с-запуском в конкурентном поле. +> Каждый запуск проходит **единый гейт** под **замком на клиента** (атомарно). Не хватает баланса → проект **не запускается**, помечается durable-меткой «удержан из-за баланса» (не заказывает), клиент видит понятное сообщение в рублях. +> Групповой запуск — **«сколько влезло»**: запускаем по порядку, пока хватает; остальные удержаны; показываем «Запущено N, отложено M». +> При пополнении баланса удержанные проекты, которые теперь помещаются, **запускаются автоматически** (как сейчас делает разморозка). + +Изменения схемы БД **нет**: durable-метка «удержан из-за баланса» = существующее поле `preflight_blocked_at` (уже означает «баланс-удержание, не заказывать», уже исключается из снапшота заказа и снимается разморозкой при пополнении). Отдельный признак «на паузе вручную» vs «удержан балансом» различается наличием `preflight_blocked_at`. + +--- + +## Архитектура: единый защищённый гейт (узловая точка) + +### 1. Доменный сервис баланса — единственный источник правила +`app/app/Services/Billing/LaunchBalanceGate.php` — заменяет ВСЕ копии `runPreflight` (в `ProjectController`, `AutopodborController`, `applyBalancePreflightToBulkLimit`): + +- `evaluate(Tenant $tenant, int $additionalLeads, array $excludeProjectIds = []): GateResult` + - `committed` = сумма `daily_limit_target` активных не-удержанных проектов, кроме `excludeProjectIds`; + - `capacity` = сколько лидов позволяет баланс по активному тарифу; + - `required = committed + additionalLeads`; `passes = required <= capacity`; + - `GateResult{passes, capacityLeads, committedLeads, requiredLeads, deficitLeads, topupRub}` — где **`topupRub`** = сколько рублей пополнить, чтобы `deficitLeads` поместились (по цене активной ступени) — для сообщения клиенту. +- **Fail-closed на запуске:** если активных `pricing_tiers` на сегодня нет — гейт запуска возвращает `passes=false` (а не «безлимит»). Тесты/фикстуры обязаны сеять тариф. Дополнительно — ops-инвариант «тариф активен на сегодня» (алерт). *(Сейчас пустой тариф даёт `passes=true, capacity=PHP_INT_MAX` — это дыра «настежь».)* +- Единый helper «required для клиента» — свести `Tenant::requiredLeadsForTomorrow` и выборки гейта/разморозки к одному определению (blocked исключаются везде одинаково). + +### 2. Правило — в доменном слое, под замком (нельзя обойти, нет гонок) +Единственная точка, делающая проект активным/заказывающим — методы `ProjectService`. Гейт встроен в них и **сериализован на строке тенанта**: + +- Каждый запуск-путь оборачивается в `DB::transaction` + `Tenant::whereKey($id)->lockForUpdate()->firstOrFail()`; `evaluate` и запись `is_active`/`preflight_blocked_at`/диспатч `SyncSupplierProjectJob` — **внутри одного лока** (паттерн уже применён в `LedgerService`/`BillingTopupService`). Это закрывает TOCTOU-гонку (двойной клик / две вкладки / bulk+одиночный). +- `ProjectService::create(Tenant, array $data, bool $launch=true): CreateResult{project, launched, gate}`: + - создаёт запись; `$launch` → гейт под локом; passes → `is_active=true` + диспатч; !passes → `is_active=false`, `preflight_blocked_at=now()` (удержан), **синк НЕ шлётся**, `launched=false`, `gate` заполнен; + - `!$launch` → `is_active=false, paused_at=now()`, гейт не зовём, синк не шлём; + - **диспатч синка только когда проект реально `is_active=true` И `preflight_blocked_at IS NULL`** (чинит гонку «черновик уехал к поставщику до паузы»). +- `ProjectService::setActive(Project, bool $active): ActivateResult{activated, gate}` — единая точка запуска/возобновления: + - `false` → пауза, без гейта; + - `true` → под локом гейт (`additionalLeads=project.daily_limit_target`, `exclude=[id]`); passes → `is_active=true, paused_at=null, preflight_blocked_at=null` + синк; !passes → НЕ активируем, `preflight_blocked_at=now()`, синк не шлём, `gate` заполнен. +- Все пути ниже идут через `create`/`setActive`/гейт — контроллеры только представляют результат. + +### 3. Покрытие ВСЕХ путей активации (из аудита полноты) +| Путь | Файл | Как покрываем | +|---|---|---| +| Создание клиентом | `ProjectController@store` | `create(launch:true)`; убрать инлайн-preflight и 409-блок создания; гейт реквизитов 1-го проекта — оставить | +| Возобновление одиночное | `ProjectController@toggleActive` | `setActive(true)`; !activated → 409 + payload ⚠ **дыра сейчас** | +| Массовое возобновление | `ProjectService::bulkPauseResume` | `setActive(true)` по каждому, кумулятивно под локом, «сколько влезло», skipped[balance] ⚠ **дыра сейчас (безусловный заказ)** | +| Ручная «Синхронизировать» | `ProjectService::triggerSync` | перед синком гейт (проект должен помещаться) ⚠ **дыра сейчас** | +| Повышение лимита | `ProjectController@update` / bulkUpdateLimit | через `LaunchBalanceGate`; не влезает → не применяем + унифиц. сообщение; убрать `force_save_blocked` | +| Конкурентное поле создание | `AutopodborController@createProjects` → `AutopodborProjectCreator` | всегда создаём (не блокируем); `create(launch:$launch)` в **`DB::transaction`**; «сколько влезло»; убрать свою копию preflight; добавить гейт реквизитов 1-го проекта | +| Массовые действия поля | `FieldCompetitorScreen.vue` | перевести на `/api/projects/bulk` → наследует гейт + `BULK_MAX` + слепок-защиту | +| Расширение регионов/дней | update/bulk regions/days | **НЕ рычаг перерасхода** (объём заказа = дневной лимит, регионы/дни влияют лишь на матч; покрыто ежедневной заморозкой) — гейт не требуется | +| Разморозка после пополнения | `ProjectBlockReleaseService` | уже гейт (passes) — оставляем; авто-запуск удержанных, что теперь влезли | +| Вечерний sweep 18:00 | `BalancePreflightSweepJob` | уже гейт — оставляем | +| Досыл отложенной очереди 00:05 | `FlushDeferredOnlineSyncJob` | полагается на гейт в момент постановки — оставляем (примечание) | +| Импорт от поставщика (artisan) | `SupplierProjectImporter::commit` | ⚠ создаёт активные в обход `ProjectService` — **ops/админ-путь, не клиентский**; вне scope этой правки (отдельное решение) | +| Сидер имитации (artisan) | `ImitationSeedCommand` | dev-инструмент — вне scope | + +--- + +## Ясность для клиента (владелец: «даже самый тупой должен понять») + +Всё в **рублях** (клиент думает деньгами), с точной цифрой и конкретным действием. Один переиспользуемый компонент вместо трёх разных. + +**Сообщение при отказе запуска (одиночно):** +> «Проект создан, но **не запущен** — не хватает баланса. +> Чтобы запустить (≈ {N} лидов/день), **пополните примерно на {topupRub} ₽** или уменьшите объём.» +> Кнопки: **[Пополнить баланс]** (→ /billing) · **[Уменьшить лимит]** · [Понятно]. + +**Групповой:** +> «Запущено {N} из {M}. Остальные {M−N} не запущены — не хватает баланса. +> Чтобы запустить все — пополните примерно на {topupRub} ₽ или уменьшите объём.» + +**Постоянная метка на самом проекте (не исчезающий тост):** проект с `preflight_blocked_at` показывает бейдж **«⏸ Не запущен — не хватает баланса»** с подсказкой «пополните ~{topupRub} ₽, чтобы запустить». Так клиент понимает состояние в любой момент, а не только в секунду действия. Отличается от обычной паузы (у той метки нет). + +**Точки применения единого компонента:** create/edit (`NewProjectDialog`), боковая панель (`ProjectDetailsDrawer` — сейчас бедная инлайн-ошибка), карточка (`ProjectCard`), список (`ProjectsView`), bulk (`BulkActionsBar` — сводка «запущено/отложено»), конкурентное поле (`CreateScreen`, `FieldCompetitorScreen`). Обработку `toggleActive` (сейчас голый вызов) обернуть, чтобы показывать сообщение. + +--- + +## Остаточные риски (зафиксировать, часть — вне scope) + +- **Хвост слепка / маржа:** после паузы мы ~сутки платим поставщику за заказанный слепок, даже если клиент уже не платит. Резко уменьшается закрытием дыр 1–3 (перезаказа не будет). Полная сверка «заказано у поставщика ↔ списано с клиента» — **отдельная ops-тема**, не в этой правке. +- **Импорт от поставщика** (`SupplierProjectImporter`) активирует в обход гейта — ops-путь, отдельное решение (эти проекты поставщик уже исполняет). +- **Fail-closed на пустых тарифах** — меняет поведение при незаданных тарифах; тесты обязаны сеять тариф; на проде — инвариант «тариф активен». + +## Границы (не трогаем) +Ежедневная заморозка/разморозка баланса и её покрытие всех клиентов; правила изменения проекта (смена источника, слепок-grace, окна 18:00→00:00, уникальность, запрет удаления при сделках); модель канала B и движок сбора; **схема БД / миграции**. + +--- + +## Тестирование (TDD) + +### Бэкенд (Pest) +- **Гонка:** два параллельных/последовательных запуска под нехватку — суммарный активный лимит НЕ превышает ёмкость (лок держит); только «сколько влезло» активируется. +- **Возобновление:** одиночное `setActive(true)` при нехватке → остаётся удержан (`preflight_blocked_at`), 409+payload, синк не шлётся; bulk-resume → «сколько влезло», skipped[balance]. +- **Ручная синхронизация** при нехватке → заказ не уходит. +- **Создание:** нехватка → `is_active=false`, `preflight_blocked_at` выставлен, `launch_deferred=true`, синк НЕ задиспатчен; достаток → активен + синк. +- **Автоподбор:** launch=true полная нехватка → все удержаны, без преждевременного синка; частичная ёмкость → N запущено/M удержано; пачка транзакционна (падение середины → 0 создано); launch=false → пауза, без гейта/синка/гонки; гейт реквизитов 1-го проекта → 422. +- **Fail-closed:** нет активного тарифа → запуск не проходит. +- **Топап:** пополнение авто-запускает удержанные, что теперь влезают. +- **topupRub:** дефицит лидов корректно переводится в рубли по активной ступени. +- **Регрессия:** обычное создание при достатке — активен + синк. + +### Фронт (Vitest) +- `launch_deferred` → единое сообщение (не блокирующее окно), с суммой в рублях. +- `toggleActive` 409 → сообщение, проект не активируется в UI. +- Постоянный бейдж «не запущен — не хватает баланса» на удержанном проекте. +- `FieldCompetitorScreen` массовые действия → `/api/projects/bulk`. +- Единый компонент переиспользован во всех точках; групповая сводка «запущено/отложено». + +--- + +## Карта файлов +**Бэкенд:** новый `app/app/Services/Billing/LaunchBalanceGate.php` (+DTO `GateResult`/`CreateResult`/`ActivateResult`); `ProjectService.php` (create+launch/DTO, `setActive`, bulkPauseResume, triggerSync, убрать дубли-preflight, лок тенанта); `ProjectController.php` (store/update/toggleActive/bulk; убрать `runPreflight` и `force_save_blocked`); `AutopodborController.php` (createProjects: убрать свой preflight, всегда создавать, сводка, реквизиты); `AutopodborProjectCreator.php` (транзакция + launch); helper «required» (Tenant/сервисы). +**Фронт:** `ProjectLimitOverloadDialog.vue` (→ «запуск отложен», рубли, бейдж); `NewProjectDialog.vue`, `ProjectDetailsDrawer.vue`, `ProjectCard.vue`, `ProjectsView.vue`, `BulkActionsBar.vue`, `stores/projectsStore.ts`; `CreateScreen.vue`, `FieldCompetitorScreen.vue`, `stores/autopodborStore.ts`, `api/autopodbor.ts`. +**Нормативка:** после реализации — запись правила в продуктовую документацию (файл уточнить в плане); схему БД не трогаем.