diff --git a/app/Console/Commands/ReportCompanyMerge.php b/app/Console/Commands/ReportCompanyMerge.php new file mode 100644 index 00000000..2b32098e --- /dev/null +++ b/app/Console/Commands/ReportCompanyMerge.php @@ -0,0 +1,105 @@ +build( + max(2, (int) $this->option('threshold')), + max(1, (int) $this->option('limit')), + ); + + if ($this->option('json')) { + $this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $g = $data['gesamt']; + + $this->info('Trockenlauf Newsroom-Konsolidierung — es wird nichts geschrieben.'); + $this->newLine(); + $this->line(sprintf(' Firmen gesamt: %d', $g['firmen'])); + $this->line(sprintf(' Adressen mit mehr als einer Firma: %d (%d Firmen)', $g['adressen_mehrfach'], $g['firmen_mehrfach'])); + $this->line(sprintf(' Schwellwert Sammeladresse (E1): ab %d abweichenden Namen', $data['schwellwert'])); + $this->newLine(); + + $rows = []; + $mergeable = 0; + + foreach ($data['klassen'] as $class) { + $rows[] = [ + $class['klasse'], + $class['adressen'], + $class['firmen'], + $class['faellt_weg'] > 0 ? $class['faellt_weg'] : '–', + $class['firmen_mit_pm'], + $class['beschreibung'], + ]; + + $mergeable += $class['faellt_weg']; + } + + $this->table(['Klasse', 'Adressen', 'Firmen', 'fällt weg', 'davon mit PM', 'Bedeutung'], $rows); + + $this->line(sprintf( + ' Eine automatische Zusammenführung würde %d Firmen zu bestehenden Newsrooms verschmelzen.', + $mergeable, + )); + + if ($data['nie_veroeffentlicht'] !== []) { + $this->newLine(); + $this->warn('Adressen ohne jede Veröffentlichung — von jeder automatischen Aktion ausnehmen:'); + + $this->table( + ['Adresse', 'Namen', 'Firmen', 'davon mit PM'], + array_map( + static fn (array $row): array => [$row['email'], $row['namen'], $row['firmen'], $row['firmen_mit_pm']], + $data['nie_veroeffentlicht'], + ), + ); + + $this->line(' Viele Namen, nie etwas veröffentlicht. Darunter sind Platzhalter wie yahoo@yahoo.com,'); + $this->line(' hinter denen gar keine Firma steht — würde über sie zusammengeführt, verschmölzen'); + $this->line(' tausende unbeteiligte Firmen zu einem Newsroom. Es können aber auch inaktive'); + $this->line(' Agenturen darunter sein. Deshalb: einzeln ansehen, nichts automatisch tun.'); + } + + if ($data['sammeladressen'] !== []) { + $this->newLine(); + $this->line('Sammeladressen — Kandidaten für das Agenturmodell (3.3):'); + + $this->table( + ['Adresse', 'Namen', 'Firmen', 'davon mit PM', 'Beispiele'], + array_map( + static fn (array $row): array => [ + $row['email'], $row['namen'], $row['firmen'], $row['firmen_mit_pm'], $row['beispiele'], + ], + $data['sammeladressen'], + ), + ); + } + + return self::SUCCESS; + } +} diff --git a/app/Services/Company/CompanyMergeReport.php b/app/Services/Company/CompanyMergeReport.php new file mode 100644 index 00000000..125b1013 --- /dev/null +++ b/app/Services/Company/CompanyMergeReport.php @@ -0,0 +1,243 @@ + + */ + 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', + ]; + + /** + * @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (Entscheidung E1) + * @return array{ + * schwellwert: int, + * gesamt: array{firmen: int, adressen_mehrfach: int, firmen_mehrfach: int}, + * klassen: list, + * sammeladressen: list, + * nie_veroeffentlicht: list, + * } + */ + public function build(int $collectionThreshold = 3, int $limit = 15): array + { + $groups = $this->groups(); + + $classes = [ + 'dublette' => ['beschreibung' => 'identischer Name — automatisch zusammenführbar', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0], + 'namensvariante' => ['beschreibung' => 'nur Rechtsform/Schreibweise abweichend — automatisch zusammenführbar', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0], + 'vorschlag' => ['beschreibung' => 'abweichende Namen unter dem Schwellwert — Vorschlag im Admin, keine automatische Aktion', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0], + 'sammeladresse' => ['beschreibung' => 'Agentur oder Dienstleister — markieren, nicht zusammenführen', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0], + 'nie_veroeffentlicht' => ['beschreibung' => 'nie eine Veröffentlichung — von jeder automatischen Aktion ausnehmen, einzeln prüfen', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0], + ]; + + $collections = []; + $junk = []; + + foreach ($groups as $group) { + $class = $this->classify($group, $collectionThreshold); + + $classes[$class]['adressen']++; + $classes[$class]['firmen'] += $group['firmen']; + $classes[$class]['firmen_mit_pm'] += $group['firmen_mit_pm']; + + // Wegfallen können nur die Klassen, die tatsächlich verschmolzen + // werden. Bei allem anderen bleibt jede Firma stehen. + if (in_array($class, ['dublette', 'namensvariante'], true)) { + $classes[$class]['faellt_weg'] += $group['firmen'] - 1; + } + + if ($class === 'sammeladresse') { + $collections[] = [ + 'email' => $group['email'], + 'namen' => $group['namen'], + 'firmen' => $group['firmen'], + 'firmen_mit_pm' => $group['firmen_mit_pm'], + 'beispiele' => Str::limit(implode(' | ', array_slice($group['beispiele'], 0, 3)), 90), + ]; + } + + if ($class === 'nie_veroeffentlicht') { + $junk[] = [ + 'email' => $group['email'], + 'namen' => $group['namen'], + 'firmen' => $group['firmen'], + 'firmen_mit_pm' => $group['firmen_mit_pm'], + ]; + } + } + + usort($collections, static fn (array $a, array $b): int => $b['firmen'] <=> $a['firmen']); + usort($junk, static fn (array $a, array $b): int => $b['firmen'] <=> $a['firmen']); + + return [ + 'schwellwert' => $collectionThreshold, + 'gesamt' => [ + 'firmen' => Company::query()->withoutGlobalScope(PortalScope::class)->count(), + 'adressen_mehrfach' => count($groups), + 'firmen_mehrfach' => array_sum(array_column($groups, 'firmen')), + ], + 'klassen' => array_map( + static fn (string $key, array $data): array => ['klasse' => $key] + $data, + array_keys($classes), + array_values($classes), + ), + 'sammeladressen' => array_slice($collections, 0, $limit), + 'nie_veroeffentlicht' => array_slice($junk, 0, $limit), + ]; + } + + /** + * Adressen mit mehr als einer Firma, mit Namen und PM-Bestand. + * + * @return list, firmen_mit_pm: int}> + */ + private function groups(): array + { + // Einmal alle Firmen mit mindestens einer veröffentlichten Meldung, + // statt je Firma eine korrelierte Unterabfrage: Der Bericht läuft über + // den gesamten Bestand, und das ist eine Abfrage gegen hunderttausend. + $withPublication = array_flip( + PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->where('status', 'published') + ->whereNotNull('company_id') + ->distinct() + ->pluck('company_id') + ->all() + ); + + $rows = Company::query() + ->withoutGlobalScope(PortalScope::class) + ->whereNotNull('email') + ->where('email', '!=', '') + ->orderBy('email') + ->orderBy('id') + ->get(['id', 'email', 'name']); + + $groups = []; + + foreach ($rows as $row) { + $key = mb_strtolower(trim((string) $row->email)); + + $groups[$key] ??= ['email' => $key, 'firmen' => 0, 'namen' => 0, 'beispiele' => [], 'firmen_mit_pm' => 0, 'normalisiert' => []]; + $groups[$key]['firmen']++; + $groups[$key]['firmen_mit_pm'] += isset($withPublication[$row->id]) ? 1 : 0; + + $name = trim((string) $row->name); + + if (! in_array($name, $groups[$key]['beispiele'], true)) { + $groups[$key]['beispiele'][] = $name; + } + + $groups[$key]['normalisiert'][$this->normalise($name)] = true; + } + + $result = []; + + foreach ($groups as $group) { + if ($group['firmen'] < 2) { + continue; + } + + $group['namen'] = count($group['beispiele']); + $group['namen_normalisiert'] = count($group['normalisiert']); + unset($group['normalisiert']); + + $result[] = $group; + } + + return $result; + } + + /** + * @param array{firmen: int, namen: int, namen_normalisiert: int, firmen_mit_pm: int} $group + */ + private function classify(array $group, int $collectionThreshold): string + { + if ($group['namen'] >= self::JunkThreshold + && $group['firmen_mit_pm'] / max(1, $group['firmen']) < self::JunkPublishedRatio) { + return 'nie_veroeffentlicht'; + } + + if ($group['namen'] === 1) { + return 'dublette'; + } + + // Normalisiert derselbe Name: Rechtsform oder Schreibweise weicht ab, + // die Firma ist dieselbe. Genau der Fall „gleicher ODER ÄHNLICHER + // Firmenname" aus dem Regelwerk. + if ($group['namen_normalisiert'] === 1) { + return 'namensvariante'; + } + + return $group['namen_normalisiert'] >= $collectionThreshold ? 'sammeladresse' : 'vorschlag'; + } + + /** + * Firmenname auf seinen Kern reduziert: Kleinschreibung, Umlaute + * aufgelöst, Rechtsform und Satzzeichen entfernt. + */ + private function normalise(string $name): string + { + $value = mb_strtolower($name); + $value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); + $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)); + } + } + + return $value; + } +} diff --git a/tests/Feature/CompanyMergeReportTest.php b/tests/Feature/CompanyMergeReportTest.php new file mode 100644 index 00000000..cd62ddf3 --- /dev/null +++ b/tests/Feature/CompanyMergeReportTest.php @@ -0,0 +1,133 @@ +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('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 */ + for ($i = 1; $i <= 30; $i++) { + mergeCompany('yahoo@yahoo.com', "Firma {$i}"); + } + + $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 */ + for ($i = 1; $i <= 30; $i++) { + mergeCompany('pr@agentur.ag', "Kunde {$i}", 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('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(); + + 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(); + + // 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(); +});