4b7b67cefa
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>
302 lines
15 KiB
PHP
302 lines
15 KiB
PHP
<?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]);
|
||
});
|