Files
portal/app/tests/Feature/Supplier/SupplierProjectImporterTest.php
T
Дмитрий 4b7b67cefa refactor(supplier-grouping): R-17 — unify on buildUniqueKeyAgnostic
Deleted platform-specific buildUniqueKey($project, $platform). It diverged for
SMS (B2='sender+keyword', B3='sender' alone) → orphan supplier_projects on
sharing rebalance — B2 and B3 rows for the same project couldn't be reconciled
as one group. Now ALL platforms use buildUniqueKeyAgnostic:
  site/call    → signal_identifier
  sms+keyword  → sender+keyword
  sms (no kw)  → sender

3 callers updated: SyncSupplierProjectJob (online + batch paths) and
SupplierProjectImporter. Pest +1 test on Importer SMS commit asserts uniform
unique_key=sender+keyword across B2+B3 (RED before fix, GREEN after).
Full Importer suite 15/15 GREEN, SyncSupplierProjectsJob 12/12 GREEN.
Stage 4 §4.4.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:12:21 +03:00

302 lines
15 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\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\Import\SupplierProjectImporter;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* @param list<array<string, mixed>> $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]);
});