diff --git a/app/Services/Company/CompanyMergeReport.php b/app/Services/Company/CompanyMergeReport.php index 125b1013..23319c38 100644 --- a/app/Services/Company/CompanyMergeReport.php +++ b/app/Services/Company/CompanyMergeReport.php @@ -48,14 +48,33 @@ class CompanyMergeReport * „Hoppe Unternehmensberatung" und „Hoppe Unternehmensberatung GmbH" sind * dieselbe Firma. * + * Als einzelne Wörter geführt und vom Ende her wiederholt entfernt, bis + * keines mehr passt. So fallen „GmbH & Co. KG", „Ltd. & Co. KG" und + * „AG & Co. KGaA" gleichermaßen weg, ohne dass jede Kombination einzeln + * aufgeführt sein muss — die Satzzeichen sind zu dem Zeitpunkt ohnehin + * schon fort. + * * @var list */ - private const LegalForms = [ - 'gmbh & co kg', 'gmbh & co. kg', 'gmbh und co kg', 'ag & co kg', - 'gmbh', 'mbh', 'ug haftungsbeschraenkt', 'ug', 'ohg', 'kgaa', 'kg', 'ag', 'se', - 'e k', 'ek', 'ev', 'e v', 'gbr', 'partg', 'ltd', 'llc', 'inc', 'bv', 'nv', 'sa', 'srl', 'plc', + private const LegalFormTokens = [ + 'gmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ohg', 'kgaa', 'kg', 'ag', 'se', 'co', 'cie', 'und', + 'ek', 'ev', 'gbr', 'partg', 'ltd', 'llc', 'inc', 'bv', 'nv', 'sa', 'srl', 'plc', 'mvz', ]; + /** + * Zwei normalisierte Namen gelten als derselbe, wenn sie sich nur um + * wenige Zeichen unterscheiden (Tippfehler: „Berursverband" / + * „Berufsverband", „S Research" / „JS Research") oder denselben Anfang + * teilen (Zusätze: „Praxis Prof Dr Dhom Kollegen" / „Praxis Prof Dr Dhom + * Partner"). Unter EINER Firmen-E-Mail ist ein gemeinsamer Anfang aus + * drei Wörtern ein sehr starkes Zeichen für dieselbe Einheit. + */ + private const SimilarMaxDistance = 2; + + private const SimilarDistanceRatio = 0.15; + + private const SharedPrefixWords = 3; + /** * @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (Entscheidung E1) * @return array{ @@ -188,7 +207,7 @@ class CompanyMergeReport } $group['namen'] = count($group['beispiele']); - $group['namen_normalisiert'] = count($group['normalisiert']); + $group['namen_normalisiert'] = $this->countDistinct(array_keys($group['normalisiert'])); unset($group['normalisiert']); $result[] = $group; @@ -232,12 +251,60 @@ class CompanyMergeReport $value = (string) preg_replace('/[^a-z0-9]+/', ' ', $value); $value = trim((string) preg_replace('/\s+/', ' ', $value)); - foreach (self::LegalForms as $form) { - if (str_ends_with($value, ' '.$form)) { - $value = trim(mb_substr($value, 0, -mb_strlen($form) - 1)); - } + $words = explode(' ', $value); + + // Vom Ende her, solange das letzte Wort eine Rechtsform ist — aber nie + // bis auf nichts: „AG" allein ist ein Firmenname, kein Zusatz. + while (count($words) > 1 && in_array(end($words), self::LegalFormTokens, true)) { + array_pop($words); } - return $value; + return implode(' ', $words); + } + + /** + * Zahl der wirklich verschiedenen Namen in einer Liste normalisierter + * Namen. Ähnliche werden gierig demselben Cluster zugeschlagen. + * + * @param list $names + */ + private function countDistinct(array $names): int + { + $representatives = []; + + foreach ($names as $name) { + foreach ($representatives as $representative) { + if ($this->similar($name, $representative)) { + continue 2; + } + } + + $representatives[] = $name; + } + + return count($representatives); + } + + private function similar(string $a, string $b): bool + { + if ($a === $b) { + return true; + } + + $length = max(mb_strlen($a), mb_strlen($b)); + $allowed = max(self::SimilarMaxDistance, (int) ceil($length * self::SimilarDistanceRatio)); + + // levenshtein() arbeitet auf Bytes und ist auf 255 begrenzt — die + // Namen sind zu diesem Zeitpunkt ASCII und kurz. + if (levenshtein(substr($a, 0, 255), substr($b, 0, 255)) <= $allowed) { + return true; + } + + $wordsA = explode(' ', $a); + $wordsB = explode(' ', $b); + + return count($wordsA) >= self::SharedPrefixWords + && count($wordsB) >= self::SharedPrefixWords + && array_slice($wordsA, 0, self::SharedPrefixWords) === array_slice($wordsB, 0, self::SharedPrefixWords); } } diff --git a/tests/Feature/CompanyMergeReportTest.php b/tests/Feature/CompanyMergeReportTest.php index cd62ddf3..9de3f867 100644 --- a/tests/Feature/CompanyMergeReportTest.php +++ b/tests/Feature/CompanyMergeReportTest.php @@ -48,6 +48,51 @@ test('a differing legal form is the same company', function () { 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'); @@ -77,8 +122,8 @@ test('the threshold decides where a collection address begins', function () { test('many names without a single publication are flagged, not merged', function () { /** @var TestCase $this */ - for ($i = 1; $i <= 30; $i++) { - mergeCompany('yahoo@yahoo.com', "Firma {$i}"); + foreach (range(1, 30) as $i) { + mergeCompany('yahoo@yahoo.com', fake()->unique()->company().' '.fake()->unique()->word()); } $classes = mergeClasses(); @@ -93,8 +138,11 @@ test('many names without a single publication are flagged, not merged', function test('a large but working agency is not mistaken for a placeholder', function () { /** @var TestCase $this */ - for ($i = 1; $i <= 30; $i++) { - mergeCompany('pr@agentur.ag', "Kunde {$i}", published: true); + // 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.