diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index ccd667f4..9e7d2b44 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -192,18 +192,65 @@ class SyncSupplierProjectsJob implements ShouldQueue */ public function collectEligibleProjects(): Collection { - // NB: whereIn-subquery вместо whereHas — whereHas строит relation-query - // через default Eloquent connection (pgsql), а наш родительский Project::on - // на pgsql_supplier; cross-connection JOIN ломал sync-тесты (8 fails). - // FROM 'tenants' внутри subquery наследует connection родителя. - return Project::on(self::DB_CONNECTION) - ->where('is_active', true) - ->whereNull('preflight_blocked_at') - ->whereIn('tenant_id', function ($q): void { + // Task 2.9 (Spec §4.2.4b): читаем проекты ИЗ snapshot за завтра, не live + // projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync) — + // клиент мог paus'нуть проект между двумя cron'ами, но мы должны докатить + // зафиксированный slepok поставщику (slepok-инвариант). + // + // Snapshot уже отфильтрован по is_active=true, preflight_blocked_at IS NULL, + // tenants.frozen_by_balance_at IS NULL (см. SnapshotProjectRoutingJob / + // SnapshotBackfillCommand WHERE). Здесь повторяем frozen-фильтр на случай + // если tenant заморожен между 18:02 и 18:05 (rare safety net). + // + // Переопределяем live поля проекта значениями snapshot'а: daily_limit_target, + // delivery_days_mask, regions. Downstream код syncGroup() читает эти поля как + // обычно — без изменений в логике группировки/распределения. + $tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString(); + + // Eloquent JOIN — casts (PostgresIntArray для regions) применяются автоматически. + // Raw DB::table возвращал regions как PostgreSQL-string '{1,2,3}' и ломал PostgresIntArray cast. + $projects = Project::on(self::DB_CONNECTION) + ->join('project_routing_snapshots AS snap', 'snap.project_id', '=', 'projects.id') + ->whereIn('snap.tenant_id', function ($q): void { $q->select('id')->from('tenants')->whereNull('frozen_by_balance_at'); }) - ->orderBy('id') + ->where('snap.snapshot_date', $tomorrow) + ->select( + 'projects.*', + 'snap.daily_limit AS snap_daily_limit', + 'snap.delivery_days_mask AS snap_delivery_days_mask', + 'snap.regions AS snap_regions', + ) + ->orderBy('projects.id') ->get(); + + // Override live fields with snapshot values — slepok semantic. + // snap_regions приходит как PostgreSQL-array string ('{77,99}') через append + // (не Eloquent-cast), парсим вручную. + foreach ($projects as $project) { + $project->daily_limit_target = (int) $project->getAttribute('snap_daily_limit'); + $project->delivery_days_mask = (int) $project->getAttribute('snap_delivery_days_mask'); + $project->regions = $this->parsePostgresIntArray((string) $project->getAttribute('snap_regions')); + } + + return $projects; + } + + /** + * Парсит PostgreSQL int-array literal `'{1,2,3}'` или `'{}'` в PHP `[1,2,3]` / `[]`. + * Используется для snap_regions (через raw select), который не подхватывается + * Eloquent PostgresIntArray cast'ом (тот цастит только реальное regions column). + * + * @return list + */ + private function parsePostgresIntArray(string $literal): array + { + $trimmed = trim($literal, "{} \t\n\r\0\x0B"); + if ($trimmed === '') { + return []; + } + + return array_values(array_map('intval', explode(',', $trimmed))); } /** diff --git a/app/tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php b/app/tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php new file mode 100644 index 00000000..97ed540e --- /dev/null +++ b/app/tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php @@ -0,0 +1,132 @@ +toDateString(); + DB::table('project_routing_snapshots')->insert([ + 'snapshot_date' => $tomorrow, + 'project_id' => $project->id, + 'tenant_id' => $project->tenant_id, + 'daily_limit' => $dailyLimit, + 'delivery_days_mask' => $deliveryDaysMask, + 'regions' => $regions, + 'signal_type' => $signalType, + 'signal_identifier' => $signalIdentifier, + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => $dailyLimit, + 'delivered_count' => 0, + 'created_at' => now(), + ]); +} + +it('reads from snapshot for tomorrow, picks up live-paused project (race 18:02→18:05)', function (): void { + Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow'); + + $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]); + // ↓ Клиент paus'нул проект между 18:02 (snapshot) и 18:05 (sync). + $project = Project::factory()->for($tenant)->create([ + 'is_active' => false, // live state — paused + 'daily_limit_target' => 10, + 'delivery_days_mask' => 127, + ]); + insertTomorrowSnapshot($project); + + $projects = (new SyncSupplierProjectsJob)->collectEligibleProjects(); + + // Изолируем от leftover dev data в pgsql_supplier (SharesSupplierPdo шарит PDO, + // но не транзакции — данные между тестами остаются). + $ours = $projects->where('id', $project->id); + expect($ours)->toHaveCount(1); + + Carbon::setTestNow(); +}); + +it('skips project that has NO snapshot for tomorrow (live is_active=true ignored)', function (): void { + Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow'); + + $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, // live state — active + 'daily_limit_target' => 10, + 'delivery_days_mask' => 127, + ]); + // НЕТ snapshot за tomorrow — sync должен пропустить. + + $projects = (new SyncSupplierProjectsJob)->collectEligibleProjects(); + + $ours = $projects->where('id', $project->id); + expect($ours)->toHaveCount(0); + + Carbon::setTestNow(); +}); + +it('overrides daily_limit_target / regions / delivery_days_mask with snapshot values', function (): void { + Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow'); + + $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'daily_limit_target' => 100, // live = 100 + 'delivery_days_mask' => 127, // live = mon-sun + ]); + // Snapshot имеет другой лимит / маску. + insertTomorrowSnapshot( + $project, + dailyLimit: 7, // snapshot = 7 + deliveryDaysMask: 31, // mon-fri only + regions: '{77}', // только Москва + ); + + $projects = (new SyncSupplierProjectsJob)->collectEligibleProjects(); + + $ours = $projects->where('id', $project->id); + expect($ours)->toHaveCount(1); + $p = $ours->first(); + expect((int) $p->daily_limit_target)->toBe(7); + expect((int) $p->delivery_days_mask)->toBe(31); + expect((array) $p->regions)->toBe([77]); + + Carbon::setTestNow(); +}); + +it('skips frozen tenants regardless of snapshot presence', function (): void { + Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow'); + + $tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()->subDay()]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'daily_limit_target' => 10, + 'delivery_days_mask' => 127, + ]); + insertTomorrowSnapshot($project); + + $projects = (new SyncSupplierProjectsJob)->collectEligibleProjects(); + + $ours = $projects->where('id', $project->id); + expect($ours)->toHaveCount(0); // frozen tenant — sync должен пропустить + + Carbon::setTestNow(); +}); diff --git a/app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php b/app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php index edb45f2f..e23f9586 100644 --- a/app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php @@ -17,10 +17,12 @@ uses(SharesSupplierPdo::class); it('excludes projects of frozen tenants from supplier order', function () { $frozenTenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]); - Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]); + $frozenProject = Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]); + insertSnapshotForTomorrow($frozenProject); $activeTenant = Tenant::factory()->create(['frozen_by_balance_at' => null]); - Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]); + $activeProject = Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]); + insertSnapshotForTomorrow($activeProject); $eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects(); @@ -32,6 +34,7 @@ it('excludes projects of frozen tenants from supplier order', function () { it('excludes individually preflight-blocked projects', function () { $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]); $okProject = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 30, 'preflight_blocked_at' => null]); + insertSnapshotForTomorrow($okProject); $blocked = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now()]); $eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects(); diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php index 3a1ff26f..30aa2631 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php @@ -63,6 +63,7 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl 'delivery_days_mask' => 127, 'regions' => [82, 83], ]); + insertSnapshotForTomorrow($project, regions: '{82,83}'); // One save (merged regions=[82,83] → tag='РФ') + one listProjects Http::fake([ @@ -121,6 +122,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( @@ -162,7 +164,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void { $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project1 = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', @@ -171,8 +173,9 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project1); - Project::factory()->create([ + $project2 = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', @@ -181,6 +184,7 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project2); // saveProjectMultiFlag called once (both projects share same group) Http::fake([ @@ -216,7 +220,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner // The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the // portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6. $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'call', @@ -225,6 +229,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200), @@ -252,7 +257,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void { $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'sms', @@ -263,6 +268,7 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( @@ -293,7 +299,7 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi test('sms without keyword → platform B3 only (1 supplier_project)', function (): void { $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'sms', @@ -304,6 +310,7 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function ( 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( @@ -335,7 +342,7 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function ( test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void { $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', @@ -344,6 +351,7 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); // First run: create Http::fake([ @@ -395,7 +403,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow')); $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', @@ -404,6 +412,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void { 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); Http::fake(); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); @@ -416,7 +425,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi Bus::fake([RefreshSupplierSessionJob::class]); $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', @@ -425,6 +434,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); Http::fake([ 'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401), @@ -443,7 +453,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function $tenant = Tenant::factory()->create(); for ($i = 1; $i <= 60; $i++) { - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', @@ -452,6 +462,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); } Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]); @@ -466,7 +477,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function test('writes supplier_sync_log row for each successful action', function (): void { $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', @@ -475,6 +486,7 @@ test('writes supplier_sync_log row for each successful action', function (): voi 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( @@ -516,7 +528,7 @@ test('nightly: re-creates donor on portal when its external_id no longer exists // external_id in our DB → updateProject is a silent no-op → donor never re-created. // Nightly reconciler must detect missing donors (listProjects) and re-create in-place. $tenant = Tenant::factory()->create(); - Project::factory()->create([ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'call', @@ -525,6 +537,7 @@ test('nightly: re-creates donor on portal when its external_id no longer exists 'delivery_days_mask' => 127, 'regions' => [], ]); + insertSnapshotForTomorrow($project); foreach (['B1', 'B2', 'B3'] as $platform) { SupplierProject::on('pgsql_supplier')->forceCreate([ diff --git a/app/tests/Pest.php b/app/tests/Pest.php index a62703e4..b098ef3a 100644 --- a/app/tests/Pest.php +++ b/app/tests/Pest.php @@ -89,6 +89,41 @@ function linkProjectToSupplier(Project $project, SupplierProject $supplier): voi * `$project->signal_type` — на Windows-native PG факториальный override * этого поля не персистится (см. memory project_slepok_protection.md). */ +/** + * Pest helper для SyncSupplierProjectsJob тестов (Task 2.9). + * + * Вставляет snapshot в `project_routing_snapshots` за активную дату слепка + * для tomorrow МСК (cron 18:02 МСК ежедневно создаёт slepok на завтра). + * + * После Task 2.9 sync-job читает snapshot, не live `projects.is_active` — + * без снимка проект не попадает в группировку для подачи поставщику. + */ +function insertSnapshotForTomorrow( + Project $project, + string $signalType = 'call', + ?string $signalIdentifier = '79161234567', + ?int $dailyLimit = null, + ?int $deliveryDaysMask = null, + string $regions = '{}', +): void { + $tomorrow = \Carbon\Carbon::tomorrow('Europe/Moscow')->toDateString(); + DB::table('project_routing_snapshots')->insert([ + 'snapshot_date' => $tomorrow, + 'project_id' => $project->id, + 'tenant_id' => $project->tenant_id, + 'daily_limit' => $dailyLimit ?? (int) ($project->daily_limit_target ?? 10), + 'delivery_days_mask' => $deliveryDaysMask ?? (int) ($project->delivery_days_mask ?? 127), + 'regions' => $regions, + 'signal_type' => $signalType, + 'signal_identifier' => $signalIdentifier, + 'sms_senders' => null, + 'sms_keyword' => null, + 'expected_volume' => $dailyLimit ?? (int) ($project->daily_limit_target ?? 10), + 'delivered_count' => 0, + 'created_at' => \Illuminate\Support\Facades\Date::now(), + ]); +} + function createRoutingSnapshotFromProject( Project $project, ?string $date = null,