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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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')}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
**Нормативка:** после реализации — запись правила в продуктовую документацию (файл уточнить в плане); схему БД не трогаем.
|
||||
Reference in New Issue
Block a user