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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user