diff --git a/app/Console/Commands/ClassifyCompanyAddresses.php b/app/Console/Commands/ClassifyCompanyAddresses.php new file mode 100644 index 00000000..bdee5bcc --- /dev/null +++ b/app/Console/Commands/ClassifyCompanyAddresses.php @@ -0,0 +1,117 @@ +option('threshold') ?? config('newsroom.merge.collection_threshold'))); + $groups = $classifier->classifyAll($threshold); + + $counts = array_fill_keys(array_map(static fn (CompanyAddressClass $c): string => $c->value, CompanyAddressClass::cases()), 0); + + foreach ($groups as $group) { + $counts[$group['klasse']->value]++; + } + + if ($this->option('dry-run')) { + $this->info(sprintf('[Dry-Run] %d Adressen würden klassifiziert (Schwellwert %d).', count($groups), $threshold)); + $this->renderCounts($counts); + + return self::SUCCESS; + } + + $now = now(); + $manual = CompanyEmailGroup::query() + ->whereNotNull('manual_classification') + ->pluck('manual_classification', 'email'); + + DB::transaction(function () use ($groups, $threshold, $now): void { + $seen = []; + + foreach (array_chunk($groups, 500) as $chunk) { + $rows = []; + + foreach ($chunk as $group) { + $seen[] = $group['email']; + $rows[] = [ + 'email' => $group['email'], + 'classification' => $group['klasse']->value, + 'companies_count' => $group['firmen'], + 'distinct_names' => $group['namen_normalisiert'], + 'published_companies_count' => $group['firmen_mit_pm'], + 'threshold' => $threshold, + 'classified_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + // manual_classification steht bewusst NICHT in der Update-Liste: + // Der Lauf berechnet, der Mensch entscheidet — und die + // Entscheidung darf keine Neuberechnung überschreiben. + CompanyEmailGroup::query()->upsert( + $rows, + ['email'], + ['classification', 'companies_count', 'distinct_names', 'published_companies_count', 'threshold', 'classified_at', 'updated_at'], + ); + } + + // Adressen, unter denen inzwischen nur noch eine Firma steht, + // brauchen keine Klassifizierung mehr. Von Hand gesetzte bleiben + // trotzdem — die Entscheidung könnte für die nächste Firma unter + // dieser Adresse wieder gelten. + CompanyEmailGroup::query() + ->whereNotIn('email', $seen) + ->whereNull('manual_classification') + ->delete(); + }); + + $this->info(sprintf('%d Adressen klassifiziert (Schwellwert %d).', count($groups), $threshold)); + $this->renderCounts($counts); + + if ($manual->isNotEmpty()) { + $this->line(sprintf(' %d von Hand gesetzte Klassen unverändert übernommen.', $manual->count())); + } + + return self::SUCCESS; + } + + /** + * @param array $counts + */ + private function renderCounts(array $counts): void + { + $rows = []; + + foreach (CompanyAddressClass::cases() as $class) { + $rows[] = [$class->label(), $counts[$class->value], $class->isMergeable() ? 'ja' : 'nein']; + } + + $this->table(['Klasse', 'Adressen', 'zusammenführbar'], $rows); + } +} diff --git a/app/Console/Commands/ReportCompanyMerge.php b/app/Console/Commands/ReportCompanyMerge.php index 2b32098e..9f663dcf 100644 --- a/app/Console/Commands/ReportCompanyMerge.php +++ b/app/Console/Commands/ReportCompanyMerge.php @@ -16,7 +16,7 @@ use Illuminate\Console\Command; class ReportCompanyMerge extends Command { protected $signature = 'companies:merge-report - {--threshold=3 : Ab wie vielen abweichenden Firmennamen eine Adresse als Sammeladresse gilt (E1)} + {--threshold= : Ab wie vielen abweichenden Firmennamen eine Adresse als Sammeladresse gilt (Standard: config newsroom.merge.collection_threshold, E1)} {--limit=15 : Zeilen je Liste} {--json : Ausgabe als JSON statt als Tabelle}'; @@ -25,7 +25,7 @@ class ReportCompanyMerge extends Command public function handle(CompanyMergeReport $report): int { $data = $report->build( - max(2, (int) $this->option('threshold')), + max(2, (int) ($this->option('threshold') ?? config('newsroom.merge.collection_threshold'))), max(1, (int) $this->option('limit')), ); diff --git a/app/Enums/CompanyAddressClass.php b/app/Enums/CompanyAddressClass.php new file mode 100644 index 00000000..21b481bc --- /dev/null +++ b/app/Enums/CompanyAddressClass.php @@ -0,0 +1,57 @@ + 'Dublette', + self::NameVariant => 'Namensvariante', + self::Proposal => 'Vorschlag', + self::CollectionAddress => 'Sammeladresse', + self::NeverPublished => 'nie veröffentlicht', + }; + } + + public function description(): string + { + return match ($this) { + self::Duplicate => 'identischer Name — automatisch zusammenführbar', + self::NameVariant => 'nur Rechtsform/Schreibweise abweichend — automatisch zusammenführbar', + self::Proposal => 'abweichende Namen unter dem Schwellwert — Vorschlag im Admin, keine automatische Aktion', + self::CollectionAddress => 'Agentur oder Dienstleister — markieren, nicht zusammenführen', + self::NeverPublished => 'nie eine Veröffentlichung — von jeder automatischen Aktion ausnehmen, einzeln prüfen', + }; + } + + /** + * Darf über diese Adresse automatisch zusammengeführt werden? + * + * Nur die beiden Klassen, bei denen unter der Adresse nachweislich EINE + * Firma steht. Alles andere bleibt stehen — eine Sammeladresse würde + * Kunden verschmelzen, eine Platzhalteradresse Tausende Unbeteiligte. + */ + public function isMergeable(): bool + { + return in_array($this, [self::Duplicate, self::NameVariant], true); + } +} diff --git a/app/Models/Company.php b/app/Models/Company.php index f31f576c..824b3004 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -131,6 +131,20 @@ class Company extends Model return $this->gone_at !== null; } + /** + * Normalisierte Firmen-E-Mail — der Schlüssel der Newsroom-Konsolidierung. + * + * Die eine Stelle, die festlegt, was „dieselbe Adresse" heißt. + * Klassifizierer, Bericht, Zusammenführung und laufende Zuordnung müssen + * exakt gleich normalisieren, sonst finden sie einander nicht. + */ + public static function emailKey(?string $email): ?string + { + $key = mb_strtolower(trim((string) $email)); + + return $key === '' ? null : $key; + } + /** * @return BelongsTo */ diff --git a/app/Models/CompanyEmailGroup.php b/app/Models/CompanyEmailGroup.php new file mode 100644 index 00000000..c8692963 --- /dev/null +++ b/app/Models/CompanyEmailGroup.php @@ -0,0 +1,66 @@ + + */ + protected function casts(): array + { + return [ + 'classification' => CompanyAddressClass::class, + 'manual_classification' => CompanyAddressClass::class, + 'classified_at' => 'datetime', + ]; + } + + /** + * Die Klasse, die gilt: die von Hand gesetzte, sonst die berechnete. Wer + * über eine Adresse entscheidet, fragt IMMER hier — nie die berechnete + * Spalte direkt. + */ + public function effectiveClassification(): CompanyAddressClass + { + return $this->manual_classification ?? $this->classification; + } + + public function isMergeable(): bool + { + return $this->effectiveClassification()->isMergeable(); + } + + public static function forEmail(?string $email): ?self + { + $key = Company::emailKey($email); + + if ($key === null) { + return null; + } + + return static::query()->where('email', $key)->first(); + } +} diff --git a/app/Services/Company/CompanyAddressClassifier.php b/app/Services/Company/CompanyAddressClassifier.php new file mode 100644 index 00000000..d7da9dac --- /dev/null +++ b/app/Services/Company/CompanyAddressClassifier.php @@ -0,0 +1,251 @@ + + */ + 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; + + /** + * Alle Adressen mit mehr als einer Firma, klassifiziert. + * + * @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (E1) + * @return list, firmen_mit_pm: int}> + */ + public function classifyAll(int $collectionThreshold): array + { + // Einmal alle Firmen mit mindestens einer veröffentlichten Meldung, + // statt je Firma eine korrelierte Unterabfrage: Das 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 = Company::emailKey($row->email); + + if ($key === null) { + continue; + } + + $groups[$key] ??= ['email' => $key, 'firmen' => 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'] = $this->countDistinct(array_keys($group['normalisiert'])); + unset($group['normalisiert']); + $group['klasse'] = $this->classify($group, $collectionThreshold); + + $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): CompanyAddressClass + { + if ($group['namen'] >= self::JunkThreshold + && $group['firmen_mit_pm'] / max(1, $group['firmen']) < self::JunkPublishedRatio) { + return CompanyAddressClass::NeverPublished; + } + + if ($group['namen'] === 1) { + return CompanyAddressClass::Duplicate; + } + + // Normalisiert derselbe Name: Rechtsform, Schreibweise oder Zusatz + // weicht ab, die Firma ist dieselbe. Genau der Fall „gleicher ODER + // ÄHNLICHER Firmenname" aus dem Regelwerk. + if ($group['namen_normalisiert'] === 1) { + return CompanyAddressClass::NameVariant; + } + + return $group['namen_normalisiert'] >= $collectionThreshold + ? CompanyAddressClass::CollectionAddress + : CompanyAddressClass::Proposal; + } + + /** + * Firmenname auf seinen Kern reduziert: Kleinschreibung, Umlaute + * aufgelöst, Rechtsform und Satzzeichen entfernt. + */ + public 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)); + + $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 implode(' ', $words); + } + + /** + * Zahl der wirklich verschiedenen Namen in einer Liste normalisierter + * Namen. Ähnliche werden gierig demselben Cluster zugeschlagen. + * + * Gedeckelt bei JunkThreshold: Ab so vielen verschiedenen Namen steht die + * Klasse fest (Sammeladresse oder nie veröffentlicht), und der Vergleich + * ist quadratisch — bei `yahoo@yahoo.com` mit über 4000 Namen wären das + * neun Millionen Levenshtein-Aufrufe für ein Ergebnis, das ohnehin + * „viele" heißt. Der zurückgegebene Wert ist dann eine Untergrenze. + * + * @param list $names + */ + private function countDistinct(array $names): int + { + $representatives = []; + + foreach ($names as $name) { + if (count($representatives) >= self::JunkThreshold) { + break; + } + + 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/app/Services/Company/CompanyMergeReport.php b/app/Services/Company/CompanyMergeReport.php index 23319c38..60c08d55 100644 --- a/app/Services/Company/CompanyMergeReport.php +++ b/app/Services/Company/CompanyMergeReport.php @@ -2,8 +2,8 @@ namespace App\Services\Company; +use App\Enums\CompanyAddressClass; use App\Models\Company; -use App\Models\PressRelease; use App\Scopes\PortalScope; use Illuminate\Support\Str; @@ -12,68 +12,13 @@ use Illuminate\Support\Str; * * Beantwortet die Frage, die vor jeder Zusammenführung stehen muss: Wie viele * Firmen fallen über die Firmen-E-Mail zusammen, und welche Adressen tragen in - * Wahrheit mehrere verschiedene Firmen? - * - * Schlüssel ist die FIRMEN-E-Mail, nicht die Pressekontaktadresse. Der - * Pressekontakt ist der Firma untergeordnet und wechselt; die Firmenidentität - * darf nicht an einer Person hängen. - * - * Der Bericht führt drei Klassen statt der zwei aus dem Regelwerk. Die dritte - * ist der Grund, warum dieser Trockenlauf vor jeder Zusammenführung stehen - * muss: Es gibt Platzhalter- und Wegwerfadressen, hinter denen weder eine - * Firma noch eine Agentur steht. Würde über sie zusammengeführt, verschmölzen - * tausende unbeteiligte Firmen zu einem Newsroom — der teuerste denkbare - * Fehler in diesem Block, und einer, den man nicht sauber rückabwickeln kann. + * Wahrheit mehrere verschiedene Firmen? Die Klassifizierung selbst liegt im + * CompanyAddressClassifier — derselbe Code, den auch die Persistierung (1a.2) + * benutzt, damit Bericht und spätere Zusammenführung dieselbe Antwort geben. */ class CompanyMergeReport { - /** - * Ab so vielen unterschiedlichen Firmennamen auf einer Adresse kommt sie - * für die Unbrauchbar-Prüfung überhaupt in Frage. Bewusst großzügig: Eine - * echte Agentur mit 50 Kunden soll noch als Agentur gelten. - */ - private const JunkThreshold = 25; - - /** - * Die Namensanzahl allein trennt Müll NICHT von einer großen Agentur — - * `pr@deutsche-stadtauskunft.ag` trägt 133 Namen und hat trotzdem 134 - * Firmen mit veröffentlichten Meldungen. Das trennscharfe Signal ist die - * Veröffentlichung: Hinter einer Platzhalter- oder Wegwerfadresse ist nie - * etwas erschienen, hinter einer Agentur laufend. - */ - private const JunkPublishedRatio = 0.05; - - /** - * Rechtsformen und Zusätze, die für die Namensgleichheit nichts bedeuten. - * „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 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; + public function __construct(private readonly CompanyAddressClassifier $classifier) {} /** * @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (Entscheidung E1) @@ -85,35 +30,41 @@ class CompanyMergeReport * nie_veroeffentlicht: list, * } */ - public function build(int $collectionThreshold = 3, int $limit = 15): array + public function build(int $collectionThreshold, int $limit = 15): array { - $groups = $this->groups(); + $groups = $this->classifier->classifyAll($collectionThreshold); - $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], - ]; + $classes = []; + + foreach (CompanyAddressClass::cases() as $class) { + $classes[$class->value] = [ + 'klasse' => $class->value, + 'beschreibung' => $class->description(), + 'adressen' => 0, + 'firmen' => 0, + 'faellt_weg' => 0, + 'firmen_mit_pm' => 0, + ]; + } $collections = []; $junk = []; foreach ($groups as $group) { - $class = $this->classify($group, $collectionThreshold); + $class = $group['klasse']; + $key = $class->value; - $classes[$class]['adressen']++; - $classes[$class]['firmen'] += $group['firmen']; - $classes[$class]['firmen_mit_pm'] += $group['firmen_mit_pm']; + $classes[$key]['adressen']++; + $classes[$key]['firmen'] += $group['firmen']; + $classes[$key]['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->isMergeable()) { + $classes[$key]['faellt_weg'] += $group['firmen'] - 1; } - if ($class === 'sammeladresse') { + if ($class === CompanyAddressClass::CollectionAddress) { $collections[] = [ 'email' => $group['email'], 'namen' => $group['namen'], @@ -123,7 +74,7 @@ class CompanyMergeReport ]; } - if ($class === 'nie_veroeffentlicht') { + if ($class === CompanyAddressClass::NeverPublished) { $junk[] = [ 'email' => $group['email'], 'namen' => $group['namen'], @@ -143,168 +94,9 @@ class CompanyMergeReport '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), - ), + 'klassen' => 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'] = $this->countDistinct(array_keys($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)); - - $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 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/config/newsroom.php b/config/newsroom.php new file mode 100644 index 00000000..05bb2d51 --- /dev/null +++ b/config/newsroom.php @@ -0,0 +1,26 @@ + [ + 'collection_threshold' => (int) env('NEWSROOM_MERGE_COLLECTION_THRESHOLD', 3), + ], + +]; diff --git a/database/migrations/2026_09_11_100000_create_company_email_groups_table.php b/database/migrations/2026_09_11_100000_create_company_email_groups_table.php new file mode 100644 index 00000000..785b0c1d --- /dev/null +++ b/database/migrations/2026_09_11_100000_create_company_email_groups_table.php @@ -0,0 +1,51 @@ +id(); + $table->string('email', 190)->unique(); + $table->string('classification', 32); + $table->string('manual_classification', 32)->nullable(); + $table->unsignedInteger('companies_count'); + $table->unsignedInteger('distinct_names'); + $table->unsignedInteger('published_companies_count'); + $table->unsignedSmallInteger('threshold'); + $table->timestamp('classified_at'); + $table->timestamps(); + + $table->index('classification', 'company_email_groups_classification_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_email_groups'); + } +}; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 4235edcf..54ba1112 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -227,8 +227,8 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig. | Nr. | Frage | Betrifft | |---|---|---| -| E1 | Schwellwert, ab wie vielen abweichenden Firmennamen eine Adresse als Sammeladresse gilt | 1a.2 | -| E2 | Zeigt die Absenderansicht Rohzahlen oder Einordnung gegen den Portaldurchschnitt | 1c.1 | +| ~~E1~~ | **Entschieden 11.09.2026: 3** (drei Live-Läufe, siehe `basiswerte-block0.md` und `umsetzung-protokoll.md` Teil 3). Konfiguriert in `config/newsroom.php`. | 1a.2 | +| ~~E2~~ | **Empfehlung 01.09.2026: Rohzahlen** — kein Bodensatz, Verteilung symmetrisch, Einordnung gegen den Schnitt wäre wegen der Wachstumskurve falsch (siehe `basiswerte-block0.md`). | 1c.1 | | E3 | Anzahl kostenloser Prüfläufe und Preis der weiteren in Credits | 1d.4 | | E4 | Trägt die Veröffentlichungsmail den Magic-Link-Hinweis oder den Link auf die Infoseite; ein Element pro Mail | 1b.4, 1d.2 | | E5 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt | 2.5 | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index e227951f..5913f353 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -547,3 +547,88 @@ stehen in einer eigenen Spalte daneben statt im Nenner. Links tragen jetzt zwei Parameter, Blade escapt das trennende `&` korrekt zu `&` — der Vergleich läuft nun über `e()`. Verhalten unverändert richtig, nur die Erwartung war zu eng. + +--- + +# Teil 3 — Roadmap Block 1a (Newsroom-Konsolidierung) + +## 1a.1 — Trockenlauf-Bericht + +``` +php artisan companies:merge-report [--threshold=] [--limit=15] [--json] +``` + +**Live-Ergebnis vom 11.09.2026, Schwellwert 3, nach der Tippfehler-Korrektur:** + +| Klasse | Adressen | Firmen | fällt weg | davon mit PM | +|---|---|---|---|---| +| Dublette | 12.812 | 27.832 | **15.020** | 14.633 | +| Namensvariante | 1.615 | 4.540 | **2.925** | 1.087 | +| Vorschlag | 3.632 | 9.907 | – | 2.211 | +| Sammeladresse | 1.085 | 6.706 | – | 1.072 | +| nie veröffentlicht | 23 | 5.965 | – | 0 | + +**17.945 Firmen** würden automatisch zu bestehenden Newsrooms verschmelzen — +16 % des Bestands von 111.646. + +**Drei Befunde aus den Live-Läufen mit Schwellwert 2, 3 und 4:** + +1. **Die Klasse „nie veröffentlicht" fehlte im Regelwerk.** 23 Adressen mit + 5.965 Firmen und null Veröffentlichungen, angeführt von `yahoo@yahoo.com` + (4.376 Firmen). Dahinter steht keine Firma und keine Agentur. Hätte 1a.3 + ohne diesen Trockenlauf gebaut, wären 4.376 Unbeteiligte zu einem Newsroom + verschmolzen — der teuerste Fehler dieses Blocks, nicht sauber + rückabwickelbar. Die Namensanzahl allein trennt diese Adressen NICHT von + großen Agenturen (`pr@deutsche-stadtauskunft.ag`: 133 Namen, 134 Firmen mit + Meldungen); trennscharf ist erst die Veröffentlichung. +2. **Tippfehler galten als verschiedene Firmen.** `info@js-research.de` stand + mit 204 Firmen an zweiter Stelle der Agenturliste — eine Firma, siebzehnmal + leicht anders geschrieben. Normalisierte Namen werden jetzt zu + Ähnlichkeitsclustern zusammengefasst (Levenshtein ≤ 2 bzw. 15 %, oder drei + gemeinsame Anfangswörter). Namensvarianten stiegen dadurch von 730 auf 1.615 + Adressen, Sammeladressen sanken von 1.430 auf 1.085. +3. **„& Co. KG" passte nie.** Rechtsformen wurden nach dem Entfernen der + Satzzeichen abgeglichen — „gmbh & co kg" traf nie auf „gmbh co kg". Jetzt + wortweise vom Ende her gestrippt. + +**E1 entschieden: Schwellwert 3.** Bei 2 wird jede zweite Schreibweise zur +„Agentur" (ProCoReX, Welcon — je eine Firma mit Rechtsformwechsel). Bei 4 +wandern 768 echte Mehrkunden-Adressen wie `presse@agentur-zb.de` in die +Handarbeit. Konfiguriert in `config/newsroom.php`. + +**Zu 3.3:** 1.085 Sammeladressen mit 6.706 Firmen, davon 1.072 mit Meldungen — +namentlich benannte PR-Agenturen. Das Agenturmodell ist kein Zukunftsfeature, +sondern ein vorhandenes Datenproblem. + +## 1a.2 — Persistierte Klassifizierung + +``` +php artisan companies:classify-addresses [--threshold=] [--dry-run] +``` + +Tabelle `company_email_groups`, eine Zeile je Adresse — die Klassifizierung +ist eine Eigenschaft der Adresse, nicht der Firma. Bewusst eigene Tabelle statt +Spalten auf `companies` (FULLTEXT → jedes ADD COLUMN ein Rebuild). + +Die Klassifizierungslogik liegt in `CompanyAddressClassifier` und wird von +Bericht **und** Persistierung benutzt — beide müssen dieselbe Antwort geben, +sonst zeigt der Bericht etwas anderes, als die Zusammenführung später tut. + +**`manual_classification`** übersteuert die berechnete Klasse und überlebt +jede Neuberechnung. Nötig für Grenzfälle, die nur ein Mensch entscheiden kann: +`presse@pr4you.de` ist eine echte Agentur, sieht in den Daten aber aus wie eine +Wegwerfadresse. `CompanyEmailGroup::effectiveClassification()` ist die einzige +Stelle, die 1a.3/1a.4 fragen dürfen. Wer über eine Adresse entscheidet, liest +nie die berechnete Spalte direkt. + +**Wiederholbar:** Adressen, unter denen nur noch eine Firma steht, fallen beim +nächsten Lauf heraus — außer sie tragen eine Handentscheidung. + +**Laufzeit:** 3,2 s über den ganzen Bestand. Die Ähnlichkeitsprüfung ist +quadratisch und bei 25 Repräsentanten gedeckelt — ab da steht die Klasse fest, +und `yahoo@yahoo.com` hätte sonst neun Millionen Levenshtein-Aufrufe gekostet. + +**Deploy:** `migrate --force`, `config:cache`, dann einmal +`companies:classify-addresses`. Der Befehl gehört vorerst nicht in den +Scheduler — die Klassifizierung soll sich nicht über Nacht ändern, während +jemand im Admin darüber entscheidet. Nach 1a.4 neu bewerten. diff --git a/tests/Feature/ClassifyCompanyAddressesTest.php b/tests/Feature/ClassifyCompanyAddressesTest.php new file mode 100644 index 00000000..a65567b1 --- /dev/null +++ b/tests/Feature/ClassifyCompanyAddressesTest.php @@ -0,0 +1,135 @@ +create(['email' => $email, 'name' => $name]); +} + +test('the classification is written per address and readable by email', function () { + /** @var TestCase $this */ + classifiedCompany('info@muster.de', 'Muster GmbH'); + classifiedCompany('INFO@Muster.de', 'Muster GmbH'); + + $this->artisan('companies:classify-addresses')->assertSuccessful(); + + $group = CompanyEmailGroup::forEmail('Info@Muster.DE'); + + // Eine Zeile je Adresse, gefunden über dieselbe Normalisierung, die auch + // die Firma benutzt — sonst finden sich Firma und Gruppe nicht. + expect($group)->not->toBeNull() + ->and($group->effectiveClassification())->toBe(CompanyAddressClass::Duplicate) + ->and($group->isMergeable())->toBeTrue() + ->and($group->companies_count)->toBe(2) + ->and($group->threshold)->toBe(3); +}); + +test('a collection address is marked and excluded from merging', function () { + /** @var TestCase $this */ + foreach (['Auto Hein', 'Ihr Personalberater', 'Designwerkstatt Coburg'] as $name) { + classifiedCompany('presse@agentur-zb.de', $name); + } + + $this->artisan('companies:classify-addresses')->assertSuccessful(); + + $group = CompanyEmailGroup::forEmail('presse@agentur-zb.de'); + + // Genau das ist 1a.2: markiert und von der automatischen Zusammenführung + // ausgenommen. + expect($group->effectiveClassification())->toBe(CompanyAddressClass::CollectionAddress) + ->and($group->isMergeable())->toBeFalse(); +}); + +test('a manual classification survives recomputation', function () { + /** @var TestCase $this */ + classifiedCompany('presse@pr4you.de', 'Kunde A'); + classifiedCompany('presse@pr4you.de', 'Kunde B Beratung'); + classifiedCompany('presse@pr4you.de', 'Kunde C Verlag'); + + $this->artisan('companies:classify-addresses')->assertSuccessful(); + + // Der Mensch entscheidet: Diese Adresse ist KEINE Agentur, sondern ein + // Kunde mit drei Marken — wird als Vorschlag geführt. + CompanyEmailGroup::forEmail('presse@pr4you.de') + ->update(['manual_classification' => CompanyAddressClass::Proposal]); + + $this->artisan('companies:classify-addresses') + ->expectsOutputToContain('1 von Hand gesetzte Klassen unverändert übernommen.') + ->assertSuccessful(); + + $group = CompanyEmailGroup::forEmail('presse@pr4you.de'); + + // Berechnet bleibt Sammeladresse, wirksam ist die Entscheidung. + expect($group->classification)->toBe(CompanyAddressClass::CollectionAddress) + ->and($group->effectiveClassification())->toBe(CompanyAddressClass::Proposal); +}); + +test('an address that no longer holds several companies drops out', function () { + /** @var TestCase $this */ + $a = classifiedCompany('info@einmal.de', 'Einmal GmbH'); + classifiedCompany('info@einmal.de', 'Einmal GmbH'); + + $this->artisan('companies:classify-addresses')->assertSuccessful(); + expect(CompanyEmailGroup::forEmail('info@einmal.de'))->not->toBeNull(); + + $a->forceDelete(); + + $this->artisan('companies:classify-addresses')->assertSuccessful(); + + // Wiederholbar: Was keine Gruppe mehr ist, bleibt nicht als Karteileiche + // stehen und würde sonst später eine Zusammenführung mit sich selbst + // auslösen. + expect(CompanyEmailGroup::forEmail('info@einmal.de'))->toBeNull(); +}); + +test('a manually classified address stays even when it drops below two companies', function () { + /** @var TestCase $this */ + $a = classifiedCompany('info@bleibt.de', 'Bleibt GmbH'); + classifiedCompany('info@bleibt.de', 'Bleibt AG Holding'); + + $this->artisan('companies:classify-addresses')->assertSuccessful(); + CompanyEmailGroup::forEmail('info@bleibt.de') + ->update(['manual_classification' => CompanyAddressClass::CollectionAddress]); + + $a->forceDelete(); + $this->artisan('companies:classify-addresses')->assertSuccessful(); + + // Die Entscheidung könnte für die nächste Firma unter dieser Adresse + // wieder gelten — sie wird nicht mit weggeräumt. + expect(CompanyEmailGroup::forEmail('info@bleibt.de')?->effectiveClassification()) + ->toBe(CompanyAddressClass::CollectionAddress); +}); + +test('the dry run writes nothing', function () { + /** @var TestCase $this */ + classifiedCompany('info@muster.de', 'Muster GmbH'); + classifiedCompany('info@muster.de', 'Muster GmbH'); + + $this->artisan('companies:classify-addresses', ['--dry-run' => true]) + ->expectsOutputToContain('[Dry-Run] 1 Adressen') + ->assertSuccessful(); + + expect(CompanyEmailGroup::query()->count())->toBe(0); +}); + +test('the threshold comes from the configuration by default', function () { + /** @var TestCase $this */ + config()->set('newsroom.merge.collection_threshold', 4); + classifiedCompany('a@b.de', 'Alpha'); + classifiedCompany('a@b.de', 'Beta Consulting'); + classifiedCompany('a@b.de', 'Gamma Verlag'); + + $this->artisan('companies:classify-addresses')->assertSuccessful(); + + // Drei Namen sind bei Schwellwert 4 noch ein Vorschlag, keine Agentur. + expect(CompanyEmailGroup::forEmail('a@b.de')->effectiveClassification())->toBe(CompanyAddressClass::Proposal) + ->and(CompanyEmailGroup::forEmail('a@b.de')->threshold)->toBe(4); +}); diff --git a/tests/Feature/CompanyMergeReportTest.php b/tests/Feature/CompanyMergeReportTest.php index 9de3f867..7b233ea0 100644 --- a/tests/Feature/CompanyMergeReportTest.php +++ b/tests/Feature/CompanyMergeReportTest.php @@ -156,7 +156,7 @@ 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(); + $data = app(CompanyMergeReport::class)->build(3); expect($data['gesamt']['adressen_mehrfach'])->toBe(0); }); @@ -166,7 +166,7 @@ test('the report writes nothing', function () { mergeCompany('info@muster.de', 'Muster GmbH'); mergeCompany('info@muster.de', 'Muster GmbH'); - app(CompanyMergeReport::class)->build(); + 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.