Files
portal/app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php
T
Дмитрий 050e271d51 feat(slepok): Task 2.6 — RouteSupplierLeadJob snapshot lock + is_active recheck (R-04/R-06/R-09)
createDealCopyForProject теперь:
1. После lockForUpdate(Project) проверяет live is_active — если paused между
   matchEligibleProjects и handle, return false (не доставляем под lock).
2. Читает snapshot.daily_limit под lockForUpdate(snapshot row) за активную
   дату слепка (до 21:00 МСК = today, после = today+1). delivered_today
   сравнивается с snapshot.daily_limit, не с live daily_limit_target.
3. После $project->increment('delivered_today') атомарно инкрементит
   snapshot.delivered_count — для CSV business-drift reconcile.

Closes R-04 (auto-pause каскад прерывается под lock'ом), R-06 (уменьшение
лимита после слепка не блокирует уже-зафиксированный поток), R-09 (race
recheck under lockForUpdate).

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.6
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.4

Tests added:
- tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php (2 tests, GREEN locally).

Combined Task 2.5+2.6 targeted regression: 52/52 GREEN.
2026-05-28 06:32:55 +03:00

135 lines
4.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Carbon\Carbon;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
function runSnapshotRouteJob(int $supplierLeadId): void
{
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
it('uses snapshot daily_limit, not live daily_limit_target (R-04/R-06)', function (): void {
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 100, // live limit big
'delivered_today' => 4,
'delivered_in_month' => 0,
]);
$sp = SupplierProject::factory()->create(['signal_type' => 'site']);
linkProjectToSupplier($project, $sp);
// Snapshot имеет МАЛЕНЬКИЙ daily_limit=5 — после доставки 1 deal'a должно стать 5.
createRoutingSnapshotFromProject(
$project,
date: '2026-05-28',
signalType: 'site',
signalIdentifier: $sp->unique_key,
dailyLimit: 5,
);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'raw_payload' => ['project' => $sp->platform.'_'.$sp->unique_key, 'phones' => ['79161234567']],
'phone' => '79161234567',
'vid' => 1001,
]);
runSnapshotRouteJob($lead->id);
expect(DB::table('deals')->where('tenant_id', $tenant->id)->count())->toBe(1);
// delivered_today инкрементнут на live, delivered_count на snapshot.
expect((int) DB::table('projects')->where('id', $project->id)->value('delivered_today'))->toBe(5);
$snap = DB::table('project_routing_snapshots')
->where('snapshot_date', '2026-05-28')
->where('project_id', $project->id)
->first();
expect((int) $snap->delivered_count)->toBe(1);
Carbon::setTestNow();
});
it('rejects lead when is_active becomes false under lock (R-09)', function (): void {
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
// Live state: is_active=true для snapshot:backfill, но потом клиент нажмёт «пауза»
// (между matchEligibleProjects и handle). Имитируем это inline UPDATE'ом.
$project = Project::factory()->for($tenant)->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
'delivered_today' => 0,
'delivered_in_month' => 0,
]);
$sp = SupplierProject::factory()->create(['signal_type' => 'site']);
linkProjectToSupplier($project, $sp);
// Snapshot НЕ paused (fixed до момента pause'а).
createRoutingSnapshotFromProject(
$project,
date: '2026-05-28',
signalType: 'site',
signalIdentifier: $sp->unique_key,
dailyLimit: 10,
);
// ↓ Имитация: клиент paused проект в окне между matchEligible и handle.
DB::table('projects')->where('id', $project->id)->update(['is_active' => false]);
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'raw_payload' => ['project' => $sp->platform.'_'.$sp->unique_key, 'phones' => ['79161234567']],
'phone' => '79161234567',
'vid' => 1002,
]);
runSnapshotRouteJob($lead->id);
// Snapshot говорит «доставлять», но live is_active=false под lock'ом — НЕ доставляем.
expect(DB::table('deals')->where('tenant_id', $tenant->id)->count())->toBe(0);
expect((int) DB::table('projects')->where('id', $project->id)->value('delivered_today'))->toBe(0);
$snap = DB::table('project_routing_snapshots')
->where('snapshot_date', '2026-05-28')
->where('project_id', $project->id)
->first();
expect((int) $snap->delivered_count)->toBe(0);
Carbon::setTestNow();
});