050e271d51
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.
135 lines
4.9 KiB
PHP
135 lines
4.9 KiB
PHP
<?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();
|
||
});
|