companies: Trockenlauf zur Newsroom-Konsolidierung (Roadmap 1a.1)

Schreibt nichts. Der Bericht steht vor 1a.2 und 1a.3, weil für die rückwirkende
Zusammenführung im Altbestand das Portal die Verantwortung trägt — dort wird
nicht angelegt, sondern verschmolzen, und das ist nicht folgenlos umkehrbar.

Geschlüsselt wird über die FIRMEN-E-Mail, nicht über die Pressekontaktadresse:
Der Pressekontakt ist der Firma untergeordnet und wechselt, die Firmenidentität
darf nicht an einer Person hängen.

Der Bericht führt fünf Klassen statt der drei Signale aus dem Regelwerk. Zwei
Ergänzungen, die aus den Daten kommen:

Erstens die Namensvariante. "Hoppe Unternehmensberatung" und "Hoppe
Unternehmensberatung GmbH" sind dieselbe Firma; ein Vergleich auf Gleichheit
würde sie trennen. Die Namen werden deshalb normalisiert — Kleinschreibung,
Umlaute aufgelöst, Rechtsform und Satzzeichen entfernt. Das ist der Fall
"gleicher ODER ÄHNLICHER Firmenname" aus dem Regelwerk.

Zweitens, und wichtiger: Adressen, die viele Namen tragen und unter denen NIE
etwas veröffentlicht wurde. Im Bestand sind das 23 Adressen mit 5965 Firmen,
angeführt von yahoo@yahoo.com mit 4376. Dahinter steht keine Firma und keine
Agentur. Würde über sie zusammengeführt, verschmölzen tausende unbeteiligte
Firmen zu einem Newsroom — der teuerste denkbare Fehler dieses Blocks.

Die Namensanzahl allein trennt diese Adressen NICHT von einer großen Agentur:
pr@deutsche-stadtauskunft.ag trägt 133 Namen und hat 134 Firmen mit
veröffentlichten Meldungen. Trennscharf ist erst die Veröffentlichung.

Der Schwellwert für Sammeladressen ist ein Parameter, kein fester Wert — E1
soll an Zahlen entschieden werden, nicht an einem Gefühl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-04 10:55:15 +00:00
parent 45f2d90e4e
commit 8df6b9e3a9
3 changed files with 481 additions and 0 deletions

View file

@ -0,0 +1,243 @@
<?php
namespace App\Services\Company;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Support\Str;
/**
* Trockenlauf-Bericht zur Newsroom-Konsolidierung (Roadmap 1a.1).
*
* 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.
*/
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.
*
* @var list<string>
*/
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<array{klasse: string, beschreibung: string, adressen: int, firmen: int, faellt_weg: int, firmen_mit_pm: int}>,
* sammeladressen: list<array{email: string, namen: int, firmen: int, firmen_mit_pm: int, beispiele: string}>,
* nie_veroeffentlicht: list<array{email: string, namen: int, firmen: int, firmen_mit_pm: int}>,
* }
*/
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<array{email: string, firmen: int, namen: int, beispiele: list<string>, 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;
}
}