create(['email' => $email, 'name' => $name]); if ($published) { PressRelease::factory()->published()->create(['company_id' => $company->id]); } return $company; } function mergeClasses(int $threshold = 3): Collection { return collect(app(CompanyMergeReport::class)->build($threshold)['klassen'])->keyBy('klasse'); } test('identical names on one address are a mergeable duplicate', function () { /** @var TestCase $this */ mergeCompany('info@muster.de', 'Muster GmbH'); mergeCompany('INFO@Muster.de', 'Muster GmbH'); $classes = mergeClasses(); // Groß-/Kleinschreibung der Adresse darf keine zweite Identität erzeugen. expect($classes['dublette']['adressen'])->toBe(1) ->and($classes['dublette']['firmen'])->toBe(2) ->and($classes['dublette']['faellt_weg'])->toBe(1); }); test('a differing legal form is the same company', function () { /** @var TestCase $this */ mergeCompany('info@hoppe-net.de', 'Hoppe Unternehmensberatung'); mergeCompany('info@hoppe-net.de', 'Hoppe Unternehmensberatung GmbH'); // Genau der Fall „gleicher ODER ÄHNLICHER Firmenname" aus dem Regelwerk. expect(mergeClasses()['namensvariante']['faellt_weg'])->toBe(1); }); test('a legal form with ampersand is stripped like any other', function () { /** @var TestCase $this */ mergeCompany('info@welcon.de', 'Welcon Europe Ltd. & Co. KG'); mergeCompany('info@welcon.de', 'Welcon Europe GmbH & Co. KG'); // Im Live-Lauf vom 11.09. stand Welcon als „Agentur" — weil „& Co. KG" // nach dem Entfernen der Satzzeichen auf keine Rechtsform mehr passte. expect(mergeClasses()['namensvariante']['faellt_weg'])->toBe(1); }); test('typos and small additions do not make a company an agency', function () { /** @var TestCase $this */ mergeCompany('info@js-research.de', 'JS Research'); mergeCompany('info@js-research.de', 'JS Research UG (haftungsbeschränkt)'); mergeCompany('info@js-research.de', 'S Research UG (haftungsbeschränkt)'); mergeCompany('info@js-research.de', 'JS Research'); $classes = mergeClasses(3); // JS Research stand mit 204 Firmen an zweiter Stelle der Agenturliste — // eine einzelne Firma, deren Name siebzehnmal leicht anders geschrieben // wurde. Ein Tippfehler ist kein zweiter Kunde. expect($classes['namensvariante']['adressen'])->toBe(1) ->and($classes['sammeladresse']['adressen'])->toBe(0); }); test('a shared three-word prefix under one address is the same entity', function () { /** @var TestCase $this */ mergeCompany('praxis@prof-dhom.de', 'Praxis Prof. Dr. Dhom & Kollegen MVZ GmbH (Juli 2016)'); mergeCompany('praxis@prof-dhom.de', 'Praxis Prof. Dr. Dhom & Partner'); mergeCompany('praxis@prof-dhom.de', 'Praxis Prof. Dr. Dhom'); expect(mergeClasses(3)['namensvariante']['adressen'])->toBe(1); }); test('genuinely different clients under one agency address stay distinct', function () { /** @var TestCase $this */ foreach (['Auto Hein', 'Ihr Personalberater', 'Designwerkstatt Coburg'] as $name) { mergeCompany('presse@agentur-zb.de', $name); } // Die Ähnlichkeitsregel darf echte Agenturen nicht wegnormalisieren. expect(mergeClasses(3)['sammeladresse']['adressen'])->toBe(1); }); test('two genuinely different names stay below the threshold as a proposal', function () { /** @var TestCase $this */ mergeCompany('sandra@buschgroup.com', 'Busch Group'); mergeCompany('sandra@buschgroup.com', 'Pfeiffer Vacuum'); $classes = mergeClasses(3); // Keine automatische Aktion: Zwei Marken unter einem Pressekontakt sind // nicht dasselbe Unternehmen, aber auch keine Agentur. expect($classes['vorschlag']['adressen'])->toBe(1) ->and($classes['vorschlag']['faellt_weg'])->toBe(0) ->and($classes['sammeladresse']['adressen'])->toBe(0); }); test('the threshold decides where a collection address begins', function () { /** @var TestCase $this */ foreach (['Alpha', 'Beta', 'Gamma'] as $name) { mergeCompany('presse@agentur.de', $name); } // E1 ist genau dieser Schwellwert — der Bericht macht ihn verstellbar, // damit die Entscheidung an Zahlen und nicht an einem Gefühl hängt. expect(mergeClasses(3)['sammeladresse']['adressen'])->toBe(1) ->and(mergeClasses(4)['sammeladresse']['adressen'])->toBe(0) ->and(mergeClasses(4)['vorschlag']['adressen'])->toBe(1); }); test('many names without a single publication are flagged, not merged', function () { /** @var TestCase $this */ foreach (range(1, 30) as $i) { mergeCompany('yahoo@yahoo.com', fake()->unique()->company().' '.fake()->unique()->word()); } $classes = mergeClasses(); // Der teuerste denkbare Fehler dieses Blocks: über eine Platzhalteradresse // zusammenzuführen und tausende unbeteiligte Firmen zu verschmelzen. expect($classes['nie_veroeffentlicht']['adressen'])->toBe(1) ->and($classes['nie_veroeffentlicht']['firmen'])->toBe(30) ->and($classes['nie_veroeffentlicht']['faellt_weg'])->toBe(0) ->and($classes['sammeladresse']['adressen'])->toBe(0); }); test('a large but working agency is not mistaken for a placeholder', function () { /** @var TestCase $this */ // Echte, verschiedene Kundennamen — „Kunde 1" bis „Kunde 30" lägen // einander so nah, dass die Ähnlichkeitsregel sie zu Recht als eine // Firma mit Filialnummern läse. foreach (range(1, 30) as $i) { mergeCompany('pr@agentur.ag', fake()->unique()->company().' '.fake()->unique()->word(), published: true); } // Die Namensanzahl allein trennt nicht. Erst die Veröffentlichung tut es. $classes = mergeClasses(); expect($classes['sammeladresse']['adressen'])->toBe(1) ->and($classes['nie_veroeffentlicht']['adressen'])->toBe(0); }); test('an agency whose releases went offline years ago still counts as published', function () { /** @var TestCase $this */ // Der Altbestand: Das Altsystem hat 2009–2018 rund 126 000 Meldungen auf // `rejected` gesetzt, 83 000 davon mit gezählten Aufrufen. Eine abgelehnte // Meldung wird nie aufgerufen — die waren online. pr4you mit 65 Firmen // und 199 solcher Meldungen aus 2008–2011 ist eine Agentur, kein Müll. foreach (range(1, 30) as $i) { $company = mergeCompany('presse@pr4you.de', fake()->unique()->company().' '.fake()->unique()->word()); PressRelease::factory()->create(['company_id' => $company->id, 'status' => 'rejected', 'hits' => 120]); } $classes = mergeClasses(); expect($classes['sammeladresse']['adressen'])->toBe(1) ->and($classes['nie_veroeffentlicht']['adressen'])->toBe(0); }); test('a rejected release without a single view is no publication', function () { /** @var TestCase $this */ foreach (range(1, 30) as $i) { $company = mergeCompany('yahoo@yahoo.com', fake()->unique()->company().' '.fake()->unique()->word()); PressRelease::factory()->create(['company_id' => $company->id, 'status' => 'rejected', 'hits' => 0]); } expect(mergeClasses()['nie_veroeffentlicht']['adressen'])->toBe(1); }); test('a single company on its own address is not part of the report', function () { /** @var TestCase $this */ mergeCompany('allein@muster.de', 'Allein GmbH'); $data = app(CompanyMergeReport::class)->build(3); expect($data['gesamt']['adressen_mehrfach'])->toBe(0); }); test('the report writes nothing', function () { /** @var TestCase $this */ mergeCompany('info@muster.de', 'Muster GmbH'); mergeCompany('info@muster.de', 'Muster GmbH'); app(CompanyMergeReport::class)->build(3); // Für die rückwirkende Zusammenführung im Altbestand trägt das Portal die // Verantwortung — deshalb steht der Trockenlauf davor und fasst nichts an. expect(Company::query()->count())->toBe(2); }); test('the command runs and reports the threshold in use', function () { /** @var TestCase $this */ $this->artisan('companies:merge-report', ['--threshold' => 4]) ->expectsOutputToContain('ab 4 abweichenden Namen') ->assertSuccessful(); });