Files
portal/app/tests/Feature/Supplier/SupplierProjectImporterTest.php
T

303 lines
15 KiB
PHP
Raw Normal View History

<?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 Illuminate\Support\Str;
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_'.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]);
});