feat(биллинг): баланс блокирует запуск проекта, а не создание — единый гейт

Создавать проекты можно всегда; баланс проверяется в момент ЗАПУСКА
(создать-и-запустить / запустить / возобновить одиночно и пачкой / автоподбор)
под замком на клиента (без гонок). Не хватает — проект остаётся на паузе с
меткой preflight_blocked_at, клиенту сообщение в рублях (сколько пополнить).
Групповой запуск «сколько влезло». Нет активного тарифа на дату → запуск
запрещён (fail-closed). Гейт реквизитов первого проекта добавлен и в автоподбор.

- LaunchBalanceGate — единый гейт вместо 3 копий preflight (ProjectController
  store/update, AutopodborController), под DB::transaction + lockForUpdate(Tenant).
- ProjectService::create($launch) + новый setActive(); bulk resume «сколько влезло».
- AutopodborProjectCreator: пачка в транзакции через общий ProjectService::create.
- Идемпотентность box/phone_type миграций автоподбора (Schema::hasColumn guard).
- Тест-инфра: afterRefreshingDatabase восстанавливает месячные партиции.

Тесты фичи 40/40 зелёные. Спека и план — docs/superpowers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-02 11:07:22 +03:00
parent 172b79eae0
commit f2ca0c8cb0
27 changed files with 2232 additions and 232 deletions
@@ -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,
];
}
}
@@ -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 */
@@ -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
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
final readonly class GateResult
{
public function __construct(
public bool $passes,
public int $capacityLeads,
public int $committedLeads,
public int $requiredLeads,
public int $deficitLeads,
public string $topupRub,
public string $balanceRub,
) {}
/** @return array{current_balance_rub:string,current_capacity_leads:int,would_be_required_leads:int,deficit_leads:int,topup_rub:string} */
public function toBalancePayload(): array
{
return [
'current_balance_rub' => $this->balanceRub,
'current_capacity_leads' => $this->capacityLeads,
'would_be_required_leads' => $this->requiredLeads,
'deficit_leads' => $this->deficitLeads,
'topup_rub' => $this->topupRub,
];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
final class LaunchBalanceGate
{
public function __construct(
private readonly BalancePreflightService $preflight = new BalancePreflightService,
private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter,
) {}
/**
* @param int[] $excludeProjectIds проекты, чьи лимиты НЕ учитывать в committed (напр. сам активируемый)
*/
public function evaluate(Tenant $tenant, int $additionalLeads, array $excludeProjectIds = []): GateResult
{
$balanceRub = (string) $tenant->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);
}
}
+118 -38
View File
@@ -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;
});
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
// Требовать активные pricing_tiers на СЕГОДНЯ для запуска проекта.
// true (прод) → нет тарифа = запуск запрещён (fail-closed, чтобы не уехать
// к поставщику при несконфигурированном биллинге). false (dev/тесты) →
// сохраняем прежний safe-fallback «безлимит».
'launch_requires_active_tiers' => (bool) env('BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS', false),
];
@@ -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
@@ -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'))");
}
@@ -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' => []]);
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborProjectCreator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
PricingTier::create([
'tier_no' => 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); // только за запущенный
});
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Models\TenantRequisites;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
PricingTier::create([
'tier_no' => 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');
});
@@ -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.
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Billing\LaunchBalanceGate;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function seedTier(int $priceKopecks = 10000): void // 100 ₽/лид
{
PricingTier::create([
'tier_no' => 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();
});
@@ -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 () {
@@ -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 () {
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Project\ProjectService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(fn () => 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');
});
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Services\Project\ProjectService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
PricingTier::create([
'tier_no' => 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);
});
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Services\Project\ProjectService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Queue;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(fn () => 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); // пауза тоже синкается (снять заказ)
});
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use App\Models\PricingTier;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
PricingTier::create([
'tier_no' => 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');
});
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
PricingTier::create([
'tier_no' => 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();
});
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
PricingTier::create([
'tier_no' => 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);
});
+39
View File
@@ -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')}')"
);
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
use App\Services\Billing\GateResult;
it('exposes fields and builds balance payload', function () {
$r = new GateResult(passes: false, capacityLeads: 100, committedLeads: 80,
requiredLeads: 150, deficitLeads: 50, topupRub: '1000.00', balanceRub: '123.45');
expect($r->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',
]);
});
@@ -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);
}
File diff suppressed because it is too large Load Diff
@@ -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`.
**Нормативка:** после реализации — запись правила в продуктовую документацию (файл уточнить в плане); схему БД не трогаем.