> $rows */ function importerWithRows(array $rows): SupplierProjectImporter { $client = Mockery::mock(SupplierPortalClient::class); $client->shouldReceive('listProjects')->andReturn($rows); return new SupplierProjectImporter($client); } test('buildPlan groups B1/B2/B3 call rows into one planned project, limit = sum', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']], ['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']], ['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(1); $p = $plan['planned'][0]; expect($p['signal_type'])->toBe('call'); expect($p['signal_identifier'])->toBe('79991112233'); expect($p['daily_limit_target'])->toBe(18); expect($p['delivery_days_mask'])->toBe(31); expect($p['tag'])->toBe('Каранга'); expect($p['regions'])->toBe([]); expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']); expect(collect($p['platforms'])->firstWhere('platform', 'B1')['external_id'])->toBe(4001); }); test('buildPlan skips inactive rows (status=false)', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79995550000', 'tag' => 'X', 'lim' => '5', 'status' => false, 'regions' => '', 'workdays' => []], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(0); }); test('buildPlan skips dop2 (unsupported source) and reports it', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '6001', 'src' => 'dop2', 'type' => 'calls', 'content' => '79996660000', 'tag' => 'X', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(0); expect(collect($plan['skipped'])->pluck('reason'))->toContain('unsupported_source'); }); test('buildPlan reverse-maps regions and unions across platforms', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '7001', 'src' => 'rt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false], ['id' => '7002', 'src' => 'bl', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '77', 'workdays' => [], 'regions_reverse' => false], ['id' => '7003', 'src' => 'mt', 'type' => 'hosts', 'content' => 'okna.ru', 'tag' => 'Окна', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false], ])->buildPlan($tenant->id); expect($plan['planned'][0]['regions'])->toBe([29, 82]); }); test('buildPlan treats any empty-regions platform as all-Russia', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '7101', 'src' => 'rt', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false], ['id' => '7102', 'src' => 'bl', 'type' => 'hosts', 'content' => 'all.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '', 'workdays' => [], 'regions_reverse' => false], ])->buildPlan($tenant->id); expect($plan['planned'][0]['regions'])->toBe([]); }); test('buildPlan skips group when any active row has regions_reverse=true', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '7201', 'src' => 'rt', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true], ['id' => '7202', 'src' => 'bl', 'type' => 'hosts', 'content' => 'excl.ru', 'tag' => 'A', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(0); expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude'); }); test('buildPlan groups sms by sender: B2 (sender+keyword) and B3 (sender)', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '8001', 'src' => 'bl', 'type' => 'sms', 'content' => '79001234567+KVARTIRA', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []], ['id' => '8002', 'src' => 'mt', 'type' => 'sms', 'content' => '79001234567', 'tag' => 'СМС', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => []], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(1); $p = $plan['planned'][0]; expect($p['signal_type'])->toBe('sms'); expect($p['signal_identifier'])->toBeNull(); expect($p['sms_senders'])->toBe(['79001234567']); expect($p['sms_keyword'])->toBe('KVARTIRA'); expect($p['daily_limit_target'])->toBe(8); expect(collect($p['platforms'])->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']); }); test('buildPlan handles sms B3-only (no keyword)', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '8101', 'src' => 'mt', 'type' => 'sms', 'content' => '79009998877', 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => []], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(1); expect($plan['planned'][0]['sms_senders'])->toBe(['79009998877']); expect($plan['planned'][0]['sms_keyword'])->toBeNull(); expect($plan['planned'][0]['platforms'][0]['platform'])->toBe('B3'); }); test('buildPlan skips a group whose Project already exists for the tenant', function (): void { $tenant = Tenant::factory()->create(); Project::factory()->create([ 'tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => '79993332211', ]); $plan = importerWithRows([ ['id' => '9001', 'src' => 'rt', 'type' => 'calls', 'content' => '79993332211', 'tag' => 'X', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => []], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(0); expect(collect($plan['skipped'])->pluck('reason'))->toContain('already_exists'); }); test('commit creates Project + supplier_projects (external_id from portal) + pivot, no portal write', function (): void { Http::fake(); // ловушка: НИ один HTTP не должен уйти на портал $tenant = Tenant::factory()->create(); $importer = importerWithRows([ ['id' => '4001', 'src' => 'rt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']], ['id' => '4002', 'src' => 'bl', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']], ['id' => '4003', 'src' => 'mt', 'type' => 'calls', 'content' => '79991112233', 'tag' => 'Каранга', 'lim' => '6', 'status' => true, 'regions' => '24', 'workdays' => ['1', '2', '3', '4', '5']], ]); $plan = $importer->buildPlan($tenant->id); $result = $importer->commit($plan, $tenant->id); expect($result['created_projects'])->toBe(1); $project = Project::on('pgsql_supplier') ->where('tenant_id', $tenant->id) ->where('signal_identifier', '79991112233') ->first(); expect($project)->not->toBeNull(); expect($project->daily_limit_target)->toBe(18); expect($project->is_active)->toBeTrue(); expect($project->regions)->toBe([29]); expect($project->delivery_days_mask)->toBe(31); $sps = SupplierProject::on('pgsql_supplier')->where('unique_key', '79991112233')->get(); expect($sps)->toHaveCount(3); expect($sps->pluck('supplier_external_id')->sort()->values()->all())->toBe(['4001', '4002', '4003']); expect($sps->pluck('sync_status')->unique()->all())->toBe(['ok']); expect($sps->firstWhere('platform', 'B1')->current_limit)->toBe(6); $pivot = DB::connection('pgsql_supplier')->table('project_supplier_links') ->where('project_id', $project->id)->count(); expect($pivot)->toBe(3); Http::assertNothingSent(); }); test('commit reuses an existing supplier_project row instead of duplicating', function (): void { Http::fake(); $tenant = Tenant::factory()->create(); // supplier_project уже есть (например, создан webhook resolveOrStub ранее) SupplierProject::on('pgsql_supplier')->forceCreate([ 'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79994445566', 'subject_code' => null, 'supplier_external_id' => 'EXIST1', 'current_limit' => 6, 'current_workdays' => [1, 2, 3, 4, 5], 'current_regions' => [], 'sync_status' => 'ok', 'last_synced_at' => now(), ]); $importer = importerWithRows([ ['id' => '4500', 'src' => 'rt', 'type' => 'calls', 'content' => '79994445566', 'tag' => 'Y', 'lim' => '6', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3', '4', '5']], ]); $plan = $importer->buildPlan($tenant->id); $importer->commit($plan, $tenant->id); // по-прежнему ровно 1 supplier_project с этим ключом+платформой (реюз, не дубль) expect(SupplierProject::on('pgsql_supplier') ->where('unique_key', '79994445566')->where('platform', 'B1')->count())->toBe(1); // pivot привязал существующую строку к новому проекту $project = Project::on('pgsql_supplier')->where('signal_identifier', '79994445566')->first(); $sp = SupplierProject::on('pgsql_supplier')->where('unique_key', '79994445566')->first(); expect(DB::connection('pgsql_supplier')->table('project_supplier_links') ->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->count())->toBe(1); }); test('buildPlan unions workdays across platforms with different schedules', function (): void { $tenant = Tenant::factory()->create(); // B1 = Пн-Ср [1,2,3] → mask 0b0000111 = 7; B2 = Чт-Пт [4,5] → mask 0b0011000 = 24; // union = 31 (Пн-Пт). Тест проверяет реальный OR-merge, не одинаковые расписания. $plan = importerWithRows([ ['id' => '5001', 'src' => 'rt', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['1', '2', '3']], ['id' => '5002', 'src' => 'bl', 'type' => 'calls', 'content' => '79992223344', 'tag' => 'W', 'lim' => '4', 'status' => true, 'regions' => '', 'workdays' => ['4', '5']], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(1); expect($plan['planned'][0]['delivery_days_mask'])->toBe(31); }); test('buildPlan skips sms group when any active row has regions_reverse=true', function (): void { $tenant = Tenant::factory()->create(); $plan = importerWithRows([ ['id' => '6001', 'src' => 'bl', 'type' => 'sms', 'content' => '79007776655+CODE', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => true], ['id' => '6002', 'src' => 'mt', 'type' => 'sms', 'content' => '79007776655', 'tag' => 'СМС', 'lim' => '3', 'status' => true, 'regions' => '24', 'workdays' => [], 'regions_reverse' => false], ])->buildPlan($tenant->id); expect($plan['planned'])->toHaveCount(0); expect(collect($plan['skipped'])->pluck('reason'))->toContain('regions_exclude'); }); test('deriveName uses sms sender as fallback when tag is empty', function (): void { $tenant = Tenant::factory()->create(); // tag='РФ' → попадает в fallback; sms → должен взять sender, а не 'проект'. $plan = importerWithRows([ ['id' => '7001', 'src' => 'mt', 'type' => 'sms', 'content' => '79001112222', 'tag' => 'РФ', 'lim' => '2', 'status' => true, 'regions' => '', 'workdays' => []], ])->buildPlan($tenant->id); expect($plan['planned'][0]['name'])->toBe('79001112222'); }); // --------------------------------------------------------------------------- // Stage 4 / Task 4.1 — R-17 (spec §4.4.1): unified buildUniqueKey. // Before fix buildUniqueKey($p, 'B2') = sender+keyword while buildUniqueKey($p, 'B3') // = sender alone → orphan supplier_projects rows on rebalance (B2 row keyed under // sender+keyword, B3 row keyed under sender → can't be reconciled as same group). // After fix all platforms use buildUniqueKeyAgnostic = sender+keyword for SMS with // keyword (sender alone only when keyword is null/empty). // --------------------------------------------------------------------------- test('R-17 commit creates SMS supplier_projects with UNIFORM unique_key=sender+keyword (no B3 divergence)', function (): void { Http::fake(); $tenant = Tenant::factory()->create(); $sender = '7903'.fake()->numerify('#######'); $keyword = 'TASKR17_'.\Illuminate\Support\Str::random(5); // SMS group with keyword: only B2 + B3 (no B1 — CHECK constraint chk_supplier_projects_b1_not_for_sms). // Content format: 'sender+keyword' for B2 (src='bl'), 'sender' for B3 (src='mt') — supplier portal convention. $importer = importerWithRows([ ['id' => '9101', 'src' => 'bl', 'type' => 'sms', 'content' => $sender.'+'.$keyword, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1','2','3','4','5']], ['id' => '9102', 'src' => 'mt', 'type' => 'sms', 'content' => $sender, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1','2','3','4','5']], ]); $plan = $importer->buildPlan($tenant->id); $importer->commit($plan, $tenant->id); $expected = $sender.'+'.$keyword; // Both B2 and B3 supplier_projects must share the SAME unique_key (= sender+keyword). $sps = SupplierProject::on('pgsql_supplier') ->where('signal_type', 'sms') ->whereIn('platform', ['B2', 'B3']) ->where(function ($q) use ($expected, $sender) { $q->where('unique_key', $expected)->orWhere('unique_key', $sender); }) ->get(); expect($sps)->toHaveCount(2); expect($sps->pluck('unique_key')->unique()->values()->all())->toBe([$expected]); });