put('supplier:session', [ 'phpsessid' => 'sess', 'csrf' => 'csrf', 'refreshed_at' => now()->toIso8601String(), ], now()->addHours(6)); config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); config(['services.supplier.alert_email' => 'ops@liderra.test']); }); afterEach(function (): void { Cache::store('redis')->forget('supplier:session'); Carbon::setTestNow(); }); // --------------------------------------------------------------------------- // Multi-region grouping (merged into single group) // --------------------------------------------------------------------------- /** * Project regions=[82,83] site → 1 group (merged regions) → tag='РФ' → * 1 multi-flag save → 3 supplier_projects (platforms B1/B2/B3) * subject_code=null, current_regions=[82,83]; pivot — 3 links for the project. */ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 supplier_projects + 3 pivot links', function (): void { $tenant = Tenant::factory()->create(); /** @var Project $project */ $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'persubject.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [82, 83], ]); insertSnapshotForTomorrow($project, regions: '{82,83}'); // One save (merged regions=[82,83] → tag='РФ') + one listProjects Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'], ['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'persubject.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // 3 supplier_projects (not 6): all regions merged into one group $sps = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'persubject.example.com') ->where('signal_type', 'site') ->get(); expect($sps)->toHaveCount(3); expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']); // subject_code=null (no per-subject split) expect($sps->pluck('subject_code')->unique()->all())->toContain(null); // regions merged: [82, 83] — sorted ascending, stored on each SP expect($sps->firstWhere('platform', 'B1')->current_regions)->toBe([82, 83]); // pivot: 3 links (not 6) $pivotCount = DB::table('project_supplier_links') ->where('project_id', $project->id) ->count(); expect($pivotCount)->toBe(3); }); // --------------------------------------------------------------------------- // All-RF pool // --------------------------------------------------------------------------- test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 supplier_projects', function (): void { $tenant = Tenant::factory()->create(); /** @var Project $project */ $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'rf-pool.example.com', 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '500', 'src' => 'rt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], ['id' => '501', 'src' => 'bl', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], ['id' => '502', 'src' => 'mt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); $sps = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'rf-pool.example.com') ->where('signal_type', 'site') ->get(); expect($sps)->toHaveCount(3); expect($sps->pluck('subject_code')->unique()->all())->toContain(null); expect($sps->pluck('current_regions')->first())->toBe([]); // pivot $pivotCount = DB::table('project_supplier_links') ->where('project_id', $project->id) ->count(); expect($pivotCount)->toBe(3); }); // --------------------------------------------------------------------------- // Order: 2 projects on one (source × subject) → computeOrder // --------------------------------------------------------------------------- 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(); $project1 = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'order-test.example.com', 'daily_limit_target' => 10, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project1); $project2 = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'order-test.example.com', 'daily_limit_target' => 20, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project2); // saveProjectMultiFlag called once (both projects share same group) Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '600'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '600', 'src' => 'rt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], ['id' => '601', 'src' => 'bl', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], ['id' => '602', 'src' => 'mt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 (the GROUP order), then split // across B1/B2/B3 = 7/7/6 (Σ == 20 — NOT 20 on each = 60, which would be the ×3 overspend). $sps = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'order-test.example.com') ->get(); // Single group → exactly 3 supplier_projects (not 6 as would happen if grouped separately) expect($sps)->toHaveCount(3); expect($sps->sum('current_limit'))->toBe(20); expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(7); }); test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner-reported ×3 bug)', function (): void { // 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 = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'call', 'signal_identifier' => '79135161263', 'daily_limit_target' => 18, '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), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => [ ['id' => '4001', 'src' => 'rt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'], ['id' => '4002', 'src' => 'bl', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'], ['id' => '4003', 'src' => 'mt', 'name' => '79135161263', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79135161263'], ]], 200), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // Assert only THIS group's rows (the nightly job syncs every active project in the DB). $sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79135161263')->get(); expect($sps)->toHaveCount(3); expect($sps->sum('current_limit'))->toBe(18); // Σ == project limit (not 54) expect($sps->sortBy('platform')->pluck('current_limit', 'platform')->all()) ->toBe(['B1' => 6, 'B2' => 6, 'B3' => 6]); // 18 / 3 }); // --------------------------------------------------------------------------- // SMS platforms // --------------------------------------------------------------------------- test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void { $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['79001234567'], 'sms_keyword' => 'KVARTIRA', 'daily_limit_target' => 5, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '700', 'src' => 'bl', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'], ['id' => '701', 'src' => 'mt', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); $sps = SupplierProject::on('pgsql_supplier') ->where('signal_type', 'sms') ->get(); // sms+keyword → B2+B3 only expect($sps)->toHaveCount(2); expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']); expect($sps->where('platform', 'B1')->count())->toBe(0); }); test('sms without keyword → platform B3 only (1 supplier_project)', function (): void { $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'sms', 'signal_identifier' => null, 'sms_senders' => ['79009876543'], 'sms_keyword' => null, 'daily_limit_target' => 5, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '800'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '800', 'src' => 'mt', 'name' => '79009876543', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79009876543'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); $sps = SupplierProject::on('pgsql_supplier') ->where('signal_type', 'sms') ->get(); expect($sps)->toHaveCount(1); expect($sps->first()->platform)->toBe('B3'); }); // --------------------------------------------------------------------------- // Idempotent: repeat run → updateProject (no duplicate supplier_projects/pivot) // --------------------------------------------------------------------------- test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void { $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'idempotent.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project); // First run: create Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '900', 'src' => 'rt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], ['id' => '901', 'src' => 'bl', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], ['id' => '902', 'src' => 'mt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); expect(SupplierProject::on('pgsql_supplier') ->where('unique_key', 'idempotent.example.com') ->count())->toBe(3); // Second run: no changes → updateProject calls (rt-project-save with id != 0) Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // Still 3 (no duplicates) expect(SupplierProject::on('pgsql_supplier') ->where('unique_key', 'idempotent.example.com') ->count())->toBe(3); // updateProject sends id != 0 Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && (int) ($r['id'] ?? 0) !== 0); }); // --------------------------------------------------------------------------- // Orthogonal: time budget, auth, abort-50, sync_log // --------------------------------------------------------------------------- test('respects time budget by stopping at 20:55 МСК', function (): void { // Итоговая проверка (VerifySupplierOrderJob) диспатчится в finally и сама шлёт HTTP // при реальном прогоне — вне области этого теста (тут проверяем только САМ робот). Bus::fake([VerifySupplierOrderJob::class]); Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow')); $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'time-budget.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project); Http::fake(); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); Http::assertNothingSent(); }); test('sticky auth error throws and sends critical alert email', function (): void { Mail::fake(); Bus::fake([RefreshSupplierSessionJob::class]); $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'auth-fail.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project); Http::fake([ 'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401), ]); expect(fn () => (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class))) ->toThrow(SupplierAuthException::class); Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { return $mail->alertType === 'sticky_auth'; }); }); test('aborts after 50 consecutive transient failures and sends alert', function (): void { Mail::fake(); $tenant = Tenant::factory()->create(); for ($i = 1; $i <= 60; $i++) { $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => "host{$i}.abort.com", 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project); } Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { return $mail->alertType === 'mass_transient'; }); }); test('writes supplier_sync_log row for each successful action', function (): void { $tenant = Tenant::factory()->create(); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'audit-log.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '555', 'src' => 'rt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], ['id' => '556', 'src' => 'bl', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], ['id' => '557', 'src' => 'mt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // 3 supplier_projects created → 3 log rows (one per platform) $sp = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'audit-log.example.com') ->where('platform', 'B1') ->first(); expect($sp)->not->toBeNull(); $log = SupplierSyncLog::on('pgsql_supplier') ->where('supplier_project_id', $sp->id) ->first(); expect($log)->not->toBeNull() ->and($log->action)->toBe('create') ->and($log->http_status)->toBe(200) ->and($log->error_message)->toBeNull(); }); test('nightly: re-creates donor on portal when its external_id no longer exists there', function (): void { // Regression mirror of SyncSupplierProjectJobTest: donor deleted on portal → stale // 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 = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'call', 'signal_identifier' => '79993334455', 'daily_limit_target' => 10, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project); foreach (['B1', 'B2', 'B3'] as $platform) { SupplierProject::on('pgsql_supplier')->forceCreate([ 'platform' => $platform, 'signal_type' => 'call', 'unique_key' => '79993334455', 'subject_code' => null, 'supplier_external_id' => 'GONE'.$platform, 'current_limit' => 10, 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok', 'last_synced_at' => now()->subDay(), ]); } $loadCalls = 0; Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '8003'], 200), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => function () use (&$loadCalls) { $loadCalls++; if ($loadCalls === 1) { return Http::response(['projects' => []], 200); } return Http::response(['projects' => [ ['id' => '8001', 'src' => 'rt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'], ['id' => '8002', 'src' => 'bl', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'], ['id' => '8003', 'src' => 'mt', 'name' => '79993334455', 'tag' => 'РФ', 'type' => 'calls', 'content' => '79993334455'], ]], 200); }, ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); $sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79993334455')->orderBy('platform')->get(); expect($sps)->toHaveCount(3); expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']); }); // --------------------------------------------------------------------------- // Deactivation (18:00): turn OFF orders whose source has no active project left. // Money-critical (owner-reported 2026-07-08, verified on prod live DB): prod runs // in BATCH mode. The supplier fixes tomorrow's delivery by its 21:00 snapshot, so the // ONLY window to STOP an order is the 18:00 batch sync — before that snapshot. This // nightly job only processed ACTIVE projects and never turned OFF a source whose // projects are all now paused → the paused order stayed active at the supplier → next // day leads (and charges) kept coming. The 18:00 job must push status=paused for such // orphaned orders before 21:00. // --------------------------------------------------------------------------- test('nightly 18:00: a source with no active project left is turned OFF (status=paused) at the supplier', function (): void { $tenant = Tenant::factory()->create(); $common = '79997778899'; // Project is PAUSED → excluded from tomorrow's snapshot (SnapshotProjectRoutingJob // filters is_active=true). Deliberately NO insertSnapshotForTomorrow — nothing active // references this source tonight, so its supplier order is orphaned. Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => false, 'signal_type' => 'call', 'signal_identifier' => $common, 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [], ]); // Pre-seed the supplier orders created earlier when the project was live (numeric // external ids so updateProject's (int) cast keeps id != 0). foreach (['B1' => '90001', 'B2' => '90002', 'B3' => '90003'] as $platform => $ext) { SupplierProject::on('pgsql_supplier')->create([ 'platform' => $platform, 'signal_type' => 'call', 'unique_key' => $common, 'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2, 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok', 'last_synced_at' => now()->subDay(), ]); } $savePayloads = []; Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) { $savePayloads[] = $request->data(); return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200); }, 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // The 3 orphaned orders were turned OFF: update (id != 0) with status=false. $offCalls = array_values(array_filter( $savePayloads, fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false )); expect($offCalls)->toHaveCount(3); // Non-zero limit kept (portal rejects limit=0 even when paused). foreach ($offCalls as $p) { expect((int) $p['limit'])->toBeGreaterThan(0); } // Local rows marked inactive_since so cleanup/UI reflect the pause. expect(SupplierProject::on('pgsql_supplier')->where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3); }); test('nightly 18:00: a deleted project leftover order is turned OFF but KEPT in DB while its snapshot tail still routes', function (): void { // Composition of the new deactivation pass with delete-defer (DeleteSupplierProjectTailGuardTest): // when a project is deleted but its donor is deferred (so already-ordered tail leads still // route via the snapshot), the 18:00 pass must still turn the order OFF at the supplier // (stop FUTURE ordering) — WITHOUT deleting the local donor (the tail still needs it). $tenant = Tenant::factory()->create(); $common = '79996665544'; // Donor exists (created when the project was live). Project already hard-deleted → no pivot // links, and NOT in tomorrow's active snapshot. $sp = SupplierProject::on('pgsql_supplier')->create([ 'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => $common, 'subject_code' => null, 'supplier_external_id' => '95001', 'current_limit' => 3, 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok', 'last_synced_at' => now()->subDay(), ]); // Tail snapshot for the source (fake project_id → not an active project, mirrors the // delete tail-guard fixture). LeadRouter still needs the donor for tail matching. DB::table('project_routing_snapshots')->insert([ 'snapshot_date' => Carbon::tomorrow('Europe/Moscow')->toDateString(), 'project_id' => 987654, 'tenant_id' => $tenant->id, 'daily_limit' => 5, 'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call', 'signal_identifier' => $common, 'sms_senders' => null, 'sms_keyword' => null, 'expected_volume' => 5, 'delivered_count' => 0, 'created_at' => now(), ]); $savePayloads = []; Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) { $savePayloads[] = $request->data(); return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200); }, 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // Order turned OFF at the supplier (update id != 0, status=false) — stops future ordering. $off = array_values(array_filter( $savePayloads, fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false )); expect($off)->toHaveCount(1); // The local donor record is KEPT (not deleted) so the snapshot tail still routes; marked inactive. $fresh = SupplierProject::on('pgsql_supplier')->find($sp->id); expect($fresh)->not->toBeNull(); expect($fresh->inactive_since)->not->toBeNull(); }); // --------------------------------------------------------------------------- // Итоговая проверка: робот после прогона диспатчит VerifySupplierOrderJob // --------------------------------------------------------------------------- test('handle() dispatches VerifySupplierOrderJob after run (finally block)', function (): void { Bus::fake([VerifySupplierOrderJob::class]); // Минимальный сценарий: нет ни одного eligible-проекта (как в тесте time-budget) — // групп нет, но finally всё равно должен отработать и запустить итоговую проверку. (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); Bus::assertDispatched(VerifySupplierOrderJob::class); }); test('limit-1 site project → only B1 saved, B2/B3 (0-share) skipped', function (): void { $tenant = Tenant::factory()->create(); /** @var Project $project */ $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => 'limit1.example.com', 'daily_limit_target' => 1, 'delivery_days_mask' => 127, 'regions' => [], ]); insertSnapshotForTomorrow($project, regions: '{}'); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200, ), 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( ['projects' => [ ['id' => '2001', 'src' => 'rt', 'name' => 'limit1.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'limit1.example.com'], ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); // Only B1 supplier_project created — B2/B3 (0 share) never saved. $sps = SupplierProject::on('pgsql_supplier') ->where('unique_key', 'limit1.example.com') ->where('signal_type', 'site') ->get(); expect($sps)->toHaveCount(1); expect($sps->first()->platform)->toBe('B1'); expect((int) $sps->first()->current_limit)->toBe(1); // Discriminating assertion: exactly ONE rt-project-save was sent (not three). $saveCalls = collect(Http::recorded()) ->filter(fn ($pair) => str_contains($pair[0]->url(), '/admin/visit/rt-project-save')) ->count(); expect($saveCalls)->toBe(1); });