Merge pull request #27 from CoralMinister/feat/slepok-stage-3

feat(slepok): Stage 3 — R-03 frozen-filter + R-13 paused_at sync
This commit is contained in:
CoralMinister
2026-05-28 15:44:58 +03:00
committed by GitHub
6 changed files with 268 additions and 1 deletions
@@ -71,8 +71,19 @@ final class BalancePreflightSweepJob implements ShouldQueue
// Переход active → frozen.
if (! $result->passes && ! $isFrozen) {
$tenant->frozen_by_balance_at = now();
$freezeAt = now();
$tenant->frozen_by_balance_at = $freezeAt;
$tenant->save();
// Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты
// тенанта моментом заморозки. Это даёт SupplierSnapshotGuard
// зацепку (paused_at свежее grace-периода) — клиент не сможет
// удалить/сменить источник пока хвост слепка ещё может прилететь.
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->whereNull('paused_at')
->update(['paused_at' => $freezeAt]);
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceFrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
@@ -82,8 +93,20 @@ final class BalancePreflightSweepJob implements ShouldQueue
// Переход frozen → active.
if ($result->passes && $isFrozen) {
// Stage 3 R-13: фиксируем frozen-moment ДО $tenant->save() — нужно
// для фильтра отката paused_at. Очищаем только те проекты,
// у которых paused_at >= frozen_at_was (== поставленные нами на паузу
// в freeze-блоке). Ручные паузы клиента ДО заморозки имеют
// paused_at < frozen_at_was и сохраняются.
$frozenAtWas = $tenant->frozen_by_balance_at;
$tenant->frozen_by_balance_at = null;
$tenant->save();
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->where('paused_at', '>=', $frozenAtWas)
->update(['paused_at' => null]);
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
@@ -56,6 +56,17 @@ final class LedgerService
);
$priceKopecks = (int) $tier->price_per_lead_kopecks;
// R-03 (Stage 3 §4.3.1): frozen tenant must not receive new charges even
// if balance_rub > 0. Throwing here triggers the same auto-pause flow as
// InsufficientBalance — RouteSupplierLeadJob::handleInsufficientBalance
// flips projects.is_active=false and queues ZeroBalancePausedMail rate-limited.
if ($lockedTenant->frozen_by_balance_at !== null) {
throw new InsufficientBalanceException(
priceKopecks: $priceKopecks,
balanceRub: (string) $lockedTenant->balance_rub,
);
}
// bcmath: balance_rub × 100 ≥ priceKopecks — единственный путь списания.
// Billing v2 Spec A: prepaid-лиды убраны, balance_leads НЕ читается и НЕ изменяется.
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
+4
View File
@@ -76,6 +76,8 @@ class LeadRouter
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
AND tenants.frozen_by_balance_at IS NULL
)
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
@@ -110,6 +112,8 @@ class LeadRouter
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
AND tenants.frozen_by_balance_at IS NULL
)
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
@@ -116,3 +116,54 @@ it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online',
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id);
});
// Stage 3 / Task 3.2 — R-13 (spec §4.3.2): freeze/unfreeze sync paused_at on tenant projects.
// SupplierSnapshotGuard блокирует delete/change_source когда paused_at свежее grace-периода.
// Без этой синхронизации frozen-тенант остаётся «голым» для guard'а — клиент мог бы удалить
// проект во время заморозки и пропустить хвост слепка поставщика.
it('sets paused_at on tenant projects without paused_at when freezing', function () {
Mail::fake();
// 500₽ / 50₽ = 10 лидов; проект хочет 25 → заморозка.
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'daily_limit_target' => 25,
'paused_at' => null,
]);
(new BalancePreflightSweepJob)->handle();
$fresh = $project->fresh();
expect($fresh->paused_at)->not->toBeNull();
// freeze-moment должен совпадать с tenant.frozen_by_balance_at для последующего unfreeze-matcher'а.
expect($fresh->paused_at->timestamp)->toBe($tenant->fresh()->frozen_by_balance_at->timestamp);
});
it('clears paused_at on auto-paused projects when unfreezing, preserves manual pauses', function () {
Mail::fake();
// Frozen вчера в 12:00; пауза до этого момента = ручная, после = авто.
$frozenAt = now()->subDay();
$tenant = Tenant::factory()->create([
'balance_rub' => '2000.00',
'frozen_by_balance_at' => $frozenAt,
]);
// Auto-paused в момент freeze (timestamp == frozenAt → попадает в >= filter).
$autoPaused = Project::factory()->for($tenant)->create([
'is_active' => false,
'daily_limit_target' => 5,
'paused_at' => $frozenAt,
]);
// Manual-paused за 2 дня до freeze (timestamp < frozenAt → НЕ попадает в >= filter).
$manualPaused = Project::factory()->for($tenant)->create([
'is_active' => false,
'daily_limit_target' => 5,
'paused_at' => now()->subDays(2),
]);
(new BalancePreflightSweepJob)->handle();
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
expect($autoPaused->fresh()->paused_at)->toBeNull();
expect($manualPaused->fresh()->paused_at)->not->toBeNull();
});
@@ -168,3 +168,29 @@ it('writes supplier_lead_costs (gap-fix: Plan 2/3 не писали в sharing-f
expect((int) $cost->supplier_id)->toBe($supplier->id);
expect((string) $cost->cost_rub)->toBe($supplier->cost_rub);
});
// Stage 3 / Task 3.1 — R-03 (spec §4.3.1): a frozen tenant must be rejected at
// charge time even when balance_rub > 0. Guard is BEFORE bcmath arithmetic so
// no balance / charges state is touched on rejection. The same auto-pause flow
// kicks in (InsufficientBalanceException → RouteSupplierLeadJob handler flips
// projects.is_active=false and queues ZeroBalancePausedMail rate-limited).
it('throws InsufficientBalanceException when tenant frozen_by_balance_at is set', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'frozen_by_balance_at' => now(),
]);
$deal = makeDealForTenant($tenant);
expect(function () use ($tenant, $deal) {
DB::transaction(function () use ($tenant, $deal) {
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
$this->ledger->chargeForDelivery($locked, $deal);
});
})->toThrow(InsufficientBalanceException::class);
// No side effects on frozen reject — balance and charges untouched.
$tenant->refresh();
expect((string) $tenant->balance_rub)->toBe('500.00');
expect(LeadCharge::where('tenant_id', $tenant->id)->count())->toBe(0);
});
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow'); // pre-21:00 MSK window
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
afterEach(function (): void {
Carbon::setTestNow();
});
// ---------------------------------------------------------------------------
// Case 1 — B-platform: frozen tenant must NOT receive leads (R-03 §4.3.1)
// ---------------------------------------------------------------------------
it('does not match B-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'frozen_by_balance_at' => now(), // frozen — R-03
]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create(['platform' => 'B1']);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
]);
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => null,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads
});
// ---------------------------------------------------------------------------
// Case 2 — DIRECT-platform: frozen tenant must NOT receive leads
// ---------------------------------------------------------------------------
it('does not match DIRECT-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'frozen_by_balance_at' => now(), // frozen — R-03
]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
]);
// DIRECT supplier_project matches via signal_type + unique_key
$sp = SupplierProject::factory()->create([
'platform' => 'DIRECT',
'signal_type' => 'call',
'unique_key' => 'direct-test-frozen-001',
]);
// Snapshot must carry signal_type + signal_identifier matching sp->unique_key
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => 'direct-test-frozen-001', // matches sp->unique_key
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads
});
// ---------------------------------------------------------------------------
// Case 3 (control) — B-platform, not frozen: MUST receive leads
// ---------------------------------------------------------------------------
it('matches B-platform project for non-frozen tenant (frozen_by_balance_at IS NULL)', function () {
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'frozen_by_balance_at' => null, // NOT frozen — should match
]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
]);
$sp = SupplierProject::factory()->create(['platform' => 'B1']);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'supplier_project_id' => $sp->id,
'platform' => $sp->platform,
'subject_code' => null,
]);
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => '2026-05-28',
'project_id' => $project->id,
'tenant_id' => $tenant->id,
'daily_limit' => 10,
'delivery_days_mask' => 127,
'regions' => '{}',
'signal_type' => 'call',
'signal_identifier' => null,
'sms_senders' => null,
'sms_keyword' => null,
'expected_volume' => 10,
'delivered_count' => 0,
'created_at' => now(),
]);
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
expect($matched)->toHaveCount(1); // control: non-frozen tenant with balance IS eligible
});