create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); DB::statement('SET app.current_tenant_id = '.$tenant->id); $run = AutopodborRun::create(['tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'done', 'region_code' => 16, 'params' => []]); return [$tenant, $user, $run]; } function dupComp(Tenant $t, AutopodborRun $run, string $name): AutopodborCompetitor { return AutopodborCompetitor::create([ 'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => $name, 'dedup_key' => 'c:'.md5($name), 'box' => 'field', ]); } function dupCall(Tenant $t, AutopodborRun $run, AutopodborCompetitor $c, string $phone): void { AutopodborSource::create([ 'tenant_id' => $t->id, 'competitor_id' => $c->id, 'study_run_id' => $run->id, 'signal_type' => 'call', 'identifier' => $phone, 'dedup_key' => 'call:'.$phone, 'box' => 'field', 'confirmations' => 1, ]); } it('GET field/duplicate-groups — группирует конкурентов с общим телефоном, отдаёт имена и причину', function () { [$t, $u, $run] = dupSetup(); $a = dupComp($t, $run, 'AutoMoney'); $b = dupComp($t, $run, 'Автодом24'); $c = dupComp($t, $run, 'Другая'); // без общих контактов dupCall($t, $run, $a, '79135396546'); dupCall($t, $run, $b, '79135396546'); dupCall($t, $run, $c, '79990001122'); $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups')->assertOk(); $resp->assertJsonCount(1, 'groups'); expect(mb_strtolower($resp->json('groups.0.reason')))->toContain('телефон'); $ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all(); expect($ids)->toBe(collect([$a->id, $b->id])->sort()->values()->all()); $names = collect($resp->json('groups.0.competitors'))->pluck('name')->all(); expect($names)->toContain('AutoMoney')->toContain('Автодом24'); }); it('GET field/duplicate-groups?box=proposal — группирует ТОЛЬКО предложения, поле не мешает', function () { [$t, $u, $run] = dupSetup(); // Два дубля в предложениях $p1 = AutopodborCompetitor::create(['tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл A', 'dedup_key' => 'p:a', 'box' => 'proposal']); $p2 = AutopodborCompetitor::create(['tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл B', 'dedup_key' => 'p:b', 'box' => 'proposal']); dupCall($t, $run, $p1, '79135396546'); dupCall($t, $run, $p2, '79135396546'); // Дубль в поле — не должен попасть в выборку по box=proposal $f1 = dupComp($t, $run, 'Поле A'); $f2 = dupComp($t, $run, 'Поле B'); dupCall($t, $run, $f1, '79990001122'); dupCall($t, $run, $f2, '79990001122'); $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')->assertOk(); $resp->assertJsonCount(1, 'groups'); $ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all(); expect($ids)->toBe(collect([$p1->id, $p2->id])->sort()->values()->all()); }); function dupProp(Tenant $t, AutopodborRun $run, string $name, array $phones): AutopodborCompetitor { return AutopodborCompetitor::create([ 'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => $name, 'dedup_key' => 'p:'.md5($name), 'box' => 'proposal', 'phones' => $phones, ]); } it('предложения: общий телефон из сохранённого поля (БЕЗ источников) ловится, причина — тот номер', function () { // Шаг 3: у предложений источников нет — телефоны берём из поля phones конкурента. [$t, $u, $run] = dupSetup(); $a = dupProp($t, $run, 'AutoMoney', ['79135396546']); $b = dupProp($t, $run, 'Автодом24', ['79135396546', '73912000000']); dupProp($t, $run, 'Другая', ['79990001122']); // свой номер — не в группе $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')->assertOk(); $resp->assertJsonCount(1, 'groups'); // причина = именно СВЯЗАВШИЙ номер, а не весь список телефонов expect($resp->json('groups.0.reason'))->toContain('79135396546') ->and($resp->json('groups.0.reason'))->not->toContain('73912000000'); $ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all(); expect($ids)->toBe(collect([$a->id, $b->id])->sort()->values()->all()); }); it('группа несёт сайт и ссылки-справочники каждой фирмы — чтобы клиент мог проверить и решить', function () { [$t, $u, $run] = dupSetup(); $a = AutopodborCompetitor::create([ 'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Сфера займа', 'dedup_key' => 'p:sf', 'box' => 'proposal', 'phones' => ['73912329282'], 'site_url' => 'sfera.ru', 'directory_urls' => ['https://2gis.ru/krasnoyarsk/firm/1'], ]); $b = AutopodborCompetitor::create([ 'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Ломбард Экспресс', 'dedup_key' => 'p:le', 'box' => 'proposal', 'phones' => ['73912329282'], 'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/org/le/2'], ]); $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal')->assertOk(); $comp = collect($resp->json('groups.0.competitors'))->keyBy('name'); expect($comp['Сфера займа']['site_url'])->toBe('sfera.ru') ->and($comp['Сфера займа']['directory_urls'])->toContain('https://2gis.ru/krasnoyarsk/firm/1') ->and($comp['Ломбард Экспресс']['site_url'])->toBeNull() ->and($comp['Ломбард Экспресс']['directory_urls'])->toContain('https://yandex.ru/maps/org/le/2'); }); it('ответ помечает ВЫЖИВАЮЩУЮ карточку — и это НЕ обязательно первая по номеру', function () { // Баг «САПС/Метрополис»: клиент переименовал свежую карточку и слил — а выжила ДРУГАЯ (с проектами // /в поле), его имя затёрлось. Чтобы этого не было вслепую, ответ помечает, какая карточка останется, // а фронт по ней ставит имя по умолчанию и показывает «останется эта». [$t, $u, $run] = dupSetup(); $prop = dupProp($t, $run, 'Метрополис', ['73912555555']); // свежее предложение, меньший id, без источников $field = dupComp($t, $run, 'СМ-сити'); // полевая, больший id, с источником в поле dupCall($t, $run, $field, '73912555555'); // общий телефон → одна группа $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?scope=cross')->assertOk(); $comp = collect($resp->json('groups.0.competitors'))->keyBy('id'); expect($comp[$field->id]['survivor'])->toBeTrue() // выживает полевая (в ней работа), хоть id больше ->and($comp[$prop->id]['survivor'])->toBeFalse(); // ровно одна выжившая на группу expect(collect($resp->json('groups.0.competitors'))->where('survivor', true))->toHaveCount(1); }); it('предложения: единый номер застройщика у многих проектов ГРУППИРУЕТ (порог людного номера снят)', function () { // Кейс «Образцово» (реш. владельца 05.07): у застройщика (СМ.Сити) один номер отдела продаж // висит на всех его ЖК. Прежний порог 4 прятал их как «рекламный номер». Теперь показываем // группу — склейку клиент подтверждает вручную. [$t, $u, $run] = dupSetup(); for ($i = 1; $i <= 9; $i++) { dupProp($t, $run, 'ЖК '.$i, ['73912779901']); // единый номер застройщика на 9 проектах } $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?box=proposal') ->assertOk() ->assertJsonCount(1, 'groups'); expect($resp->json('groups.0.competitors'))->toHaveCount(9); expect(mb_strtolower($resp->json('groups.0.reason')))->toContain('телефон'); }); it('GET field/duplicate-groups — группирует по общему адресу источников (слабый сигнал)', function () { [$t, $u, $run] = dupSetup(); $a = dupComp($t, $run, 'ГорЗайм'); $b = dupComp($t, $run, 'Центр Займов'); // Разные телефоны, но ОДИН адрес → должно предложить объединение по адресу. foreach ([[$a, '79001112233'], [$b, '79004445566']] as [$comp, $phone]) { AutopodborSource::create([ 'tenant_id' => $t->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id, 'signal_type' => 'call', 'identifier' => $phone, 'dedup_key' => 'call:'.$phone, 'box' => 'field', 'confirmations' => 1, 'office' => 'Водопьянова 2', ]); } $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups')->assertOk(); $resp->assertJsonCount(1, 'groups'); expect(mb_strtolower($resp->json('groups.0.reason')))->toContain('адрес'); $ids = collect($resp->json('groups.0.competitors'))->pluck('id')->sort()->values()->all(); expect($ids)->toBe(collect([$a->id, $b->id])->sort()->values()->all()); }); it('GET field/duplicate-groups — дублей нет → пустой список групп', function () { [$t, $u, $run] = dupSetup(); $a = dupComp($t, $run, 'A'); $b = dupComp($t, $run, 'B'); dupCall($t, $run, $a, '79135396546'); dupCall($t, $run, $b, '79990001122'); $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups') ->assertOk() ->assertJsonCount(0, 'groups'); }); it('GET field/duplicate-groups?scope=cross — ловит предложение как дубль карточки поля', function () { [$t, $u, $run] = dupSetup(); $f = dupComp($t, $run, 'СМ-сити'); // в поле $p = AutopodborCompetitor::create([ 'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Южный берег', 'dedup_key' => 'p:yb', 'box' => 'proposal', ]); dupCall($t, $run, $f, '73912179941'); dupCall($t, $run, $p, '73912179941'); // общий телефон — связывает предложение с полем $resp = $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?scope=cross')->assertOk(); $resp->assertJsonCount(1, 'groups'); $comp = collect($resp->json('groups.0.competitors'))->keyBy('id'); expect($comp->keys()->map(fn ($k) => (int) $k)->sort()->values()->all()) ->toBe(collect([$f->id, $p->id])->sort()->values()->all()); // в выдаче видно, кто из какого ящика (чтобы клиент понял: это предложение к карточке поля) expect($comp[$p->id]['box'])->toBe('proposal') ->and($comp[$f->id]['box'])->toBe('field'); }); it('scope=cross — пара чисто-предложений (без карточки поля) НЕ показывается на экране поля', function () { [$t, $u, $run] = dupSetup(); // Два предложения с общим телефоном — но НИ ОДНОГО в поле → на экране поля не показываем. $p1 = AutopodborCompetitor::create(['tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл A', 'dedup_key' => 'p:a', 'box' => 'proposal']); $p2 = AutopodborCompetitor::create(['tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл B', 'dedup_key' => 'p:b', 'box' => 'proposal']); dupCall($t, $run, $p1, '73912179941'); dupCall($t, $run, $p2, '73912179941'); $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups?scope=cross') ->assertOk() ->assertJsonCount(0, 'groups'); }); it('GET field/duplicate-groups БЕЗ scope — по-прежнему один ящик (поле), предложения не мешают', function () { [$t, $u, $run] = dupSetup(); $f = dupComp($t, $run, 'Поле A'); $p = AutopodborCompetitor::create([ 'tenant_id' => $t->id, 'search_run_id' => $run->id, 'name' => 'Предл A', 'dedup_key' => 'p:a', 'box' => 'proposal', ]); dupCall($t, $run, $f, '73912179941'); dupCall($t, $run, $p, '73912179941'); // без scope=cross поле и предложение НЕ группируются (обратная совместимость) $this->actingAs($u)->getJson('/api/autopodbor/field/duplicate-groups') ->assertOk() ->assertJsonCount(0, 'groups'); }); it('POST field/merge — сливает подтверждённую группу, возвращает выжившего, дубль исчезает', function () { [$t, $u, $run] = dupSetup(); $a = dupComp($t, $run, 'A'); $b = dupComp($t, $run, 'B'); dupCall($t, $run, $a, '79135396546'); dupCall($t, $run, $b, '79135396546'); $resp = $this->actingAs($u)->postJson('/api/autopodbor/field/merge', [ 'competitor_ids' => [$a->id, $b->id], ])->assertOk(); $survivor = $resp->json('survivor_id'); expect(AutopodborCompetitor::whereIn('id', [$a->id, $b->id])->count())->toBe(1); expect($survivor)->toBeIn([$a->id, $b->id]); }); it('POST field/merge — меньше двух id → 422', function () { [$t, $u, $run] = dupSetup(); $a = dupComp($t, $run, 'A'); $this->actingAs($u)->postJson('/api/autopodbor/field/merge', [ 'competitor_ids' => [$a->id], ])->assertStatus(422); }); it('merge-events: после склейки в журнале видно «что во что слили», и карточку можно ВЕРНУТЬ', function () { [$t, $u, $run] = dupSetup(); $keep = dupComp($t, $run, 'СМ-сити'); // выживет (источник в поле) $gone = dupProp($t, $run, 'Метрополис', ['79135396546']); // поглотится dupCall($t, $run, $keep, '79135396546'); // Сливаем (кросс-ящик поле+предложение) $this->actingAs($u)->postJson('/api/autopodbor/field/merge', [ 'competitor_ids' => [$keep->id, $gone->id], ])->assertOk(); // Журнал показывает слияние: поглощена «Метрополис», выжившая — «СМ-сити» $events = $this->actingAs($u)->getJson('/api/autopodbor/field/merge-events')->assertOk(); $events->assertJsonCount(1, 'events'); expect($events->json('events.0.survivor_name'))->toBe('СМ-сити'); expect(collect($events->json('events.0.absorbed'))->pluck('name')->all())->toContain('Метрополис'); $eventId = $events->json('events.0.id'); // Возврат: «Метрополис» появляется снова как отдельная карточка-предложение $restore = $this->actingAs($u)->postJson("/api/autopodbor/field/merge-events/{$eventId}/restore")->assertOk(); expect(collect($restore->json('restored'))->pluck('name')->all())->toContain('Метрополис'); expect(AutopodborCompetitor::where('tenant_id', $t->id)->where('name', 'Метрополис')->where('box', 'proposal')->exists())->toBeTrue(); // Повторный возврат из того же события — 422 (уже вернули) $this->actingAs($u)->postJson("/api/autopodbor/field/merge-events/{$eventId}/restore")->assertStatus(422); });