companies: Adress-Klassifizierung persistieren (Roadmap 1a.2, E1 = 3)
Die Klassifizierung aus dem Trockenlauf wird jetzt nach company_email_groups geschrieben — eine Zeile je Adresse, denn ob zusammengeführt werden darf, ist eine Eigenschaft der Adresse und nicht der Firma. Die Zusammenführung (1a.3) und die laufende Zuordnung neuer API-Meldungen (1a.4) lesen dort, statt den Trockenlauf jedes Mal neu zu rechnen. Die Logik liegt jetzt in CompanyAddressClassifier und wird von Bericht und Persistierung gleichermaßen 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. effectiveClassification() ist die einzige Stelle, die Abnehmer fragen dürfen. Eigene Tabelle statt Spalten auf companies: Die Tabelle trägt einen FULLTEXT-Index, jedes ADD COLUMN dort ist ein Rebuild. Die Zuordnung Firma → Gruppe ist ohnehin nur die normalisierte Adresse, und die eine Stelle, die festlegt, was "dieselbe Adresse" heißt, ist jetzt Company::emailKey(). E1 ist entschieden: Schwellwert 3, nach drei Live-Läufen. Bei 2 wird jede zweite Schreibweise zur "Agentur", bei 4 wandern echte Mehrkunden-Adressen in die Handarbeit. Konfiguriert in config/newsroom.php; Bericht und Klassifizierung lesen den Wert von dort. Die Ähnlichkeitsprüfung ist quadratisch und jetzt bei 25 Repräsentanten gedeckelt — ab da steht die Klasse fest, und yahoo@yahoo.com mit über 4000 Namen hätte sonst neun Millionen Levenshtein-Aufrufe gekostet. Laufzeit über den ganzen Bestand: 25 s → 3 s, Ergebnis unverändert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c40a0cd98
commit
a0ac32cf95
13 changed files with 837 additions and 243 deletions
117
app/Console/Commands/ClassifyCompanyAddresses.php
Normal file
117
app/Console/Commands/ClassifyCompanyAddresses.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\CompanyAddressClass;
|
||||
use App\Models\CompanyEmailGroup;
|
||||
use App\Services\Company\CompanyAddressClassifier;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Persistiert die Klassifizierung der Firmen-E-Mail-Adressen (Roadmap 1a.2).
|
||||
*
|
||||
* Schreibt nach `company_email_groups`, worauf die Zusammenführung (1a.3) und
|
||||
* die laufende Zuordnung neuer API-Meldungen (1a.4) filtern. Wiederholbar:
|
||||
* Jeder Lauf berechnet alle Gruppen neu, Adressen, die inzwischen nur noch
|
||||
* eine Firma tragen, verschwinden. Von Hand gesetzte Klassen
|
||||
* (`manual_classification`) überleben jeden Lauf — sie sind die Stelle, an der
|
||||
* ein Mensch die Grenzfälle entscheidet, die die Berechnung nicht entscheiden
|
||||
* kann.
|
||||
*/
|
||||
class ClassifyCompanyAddresses extends Command
|
||||
{
|
||||
protected $signature = 'companies:classify-addresses
|
||||
{--threshold= : Schwellwert Sammeladresse (Standard: config newsroom.merge.collection_threshold, E1)}
|
||||
{--dry-run : Nur zählen, nichts schreiben}';
|
||||
|
||||
protected $description = 'Klassifiziert alle Firmen-E-Mail-Adressen für die Newsroom-Konsolidierung und speichert das Ergebnis.';
|
||||
|
||||
public function handle(CompanyAddressClassifier $classifier): int
|
||||
{
|
||||
$threshold = max(2, (int) ($this->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<string, int> $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')),
|
||||
);
|
||||
|
||||
|
|
|
|||
57
app/Enums/CompanyAddressClass.php
Normal file
57
app/Enums/CompanyAddressClass.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Klassifizierung einer Firmen-E-Mail-Adresse für die Newsroom-Konsolidierung
|
||||
* (Roadmap 1a.2).
|
||||
*
|
||||
* Eine Eigenschaft der ADRESSE, nicht der Firma: Ob zusammengeführt werden
|
||||
* darf, entscheidet sich daran, was alles unter dieser Adresse steht.
|
||||
*/
|
||||
enum CompanyAddressClass: string
|
||||
{
|
||||
case Duplicate = 'dublette';
|
||||
|
||||
case NameVariant = 'namensvariante';
|
||||
|
||||
case Proposal = 'vorschlag';
|
||||
|
||||
case CollectionAddress = 'sammeladresse';
|
||||
|
||||
case NeverPublished = 'nie_veroeffentlicht';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Duplicate => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<User, $this>
|
||||
*/
|
||||
|
|
|
|||
66
app/Models/CompanyEmailGroup.php
Normal file
66
app/Models/CompanyEmailGroup.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CompanyAddressClass;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Klassifizierung einer Firmen-E-Mail-Adresse (Roadmap 1a.2).
|
||||
*
|
||||
* Geschrieben von `companies:classify-addresses`, gelesen von der
|
||||
* Zusammenführung (1a.3) und der laufenden Zuordnung (1a.4). Die Firmen einer
|
||||
* Gruppe findet man über ihre normalisierte Adresse — siehe
|
||||
* `Company::emailKey()`.
|
||||
*/
|
||||
class CompanyEmailGroup extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'classification',
|
||||
'manual_classification',
|
||||
'companies_count',
|
||||
'distinct_names',
|
||||
'published_companies_count',
|
||||
'threshold',
|
||||
'classified_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
251
app/Services/Company/CompanyAddressClassifier.php
Normal file
251
app/Services/Company/CompanyAddressClassifier.php
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
use App\Enums\CompanyAddressClass;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
|
||||
/**
|
||||
* Klassifiziert Firmen-E-Mail-Adressen für die Newsroom-Konsolidierung.
|
||||
*
|
||||
* Eine Logik, zwei Abnehmer: der Trockenlauf-Bericht (1a.1) und die
|
||||
* Persistierung in `company_email_groups` (1a.2). Beide müssen dieselbe
|
||||
* Antwort geben — sonst zeigt der Bericht etwas anderes, als die
|
||||
* Zusammenführung später tut.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Fünf Klassen statt der drei Signale aus dem Regelwerk. Zwei Ergänzungen
|
||||
* kommen aus den Daten: die Namensvariante (Rechtsform, Tippfehler, Zusatz —
|
||||
* dieselbe Firma) und die Adresse ohne jede Veröffentlichung. Letztere ist der
|
||||
* Grund, warum der Trockenlauf vor jeder Zusammenführung stehen muss: Hinter
|
||||
* `yahoo@yahoo.com` stehen 4376 Firmen und keine einzige Meldung. Würde über
|
||||
* sie zusammengeführt, verschmölzen Tausende Unbeteiligte zu einem Newsroom.
|
||||
*/
|
||||
class CompanyAddressClassifier
|
||||
{
|
||||
/**
|
||||
* Ab so vielen unterschiedlichen Firmennamen auf einer Adresse kommt sie
|
||||
* für die Nie-veröffentlicht-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<string>
|
||||
*/
|
||||
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<array{email: string, klasse: CompanyAddressClass, firmen: int, namen: int, namen_normalisiert: int, beispiele: list<string>, 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<string> $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>
|
||||
*/
|
||||
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<array{email: string, namen: int, firmen: int, firmen_mit_pm: int}>,
|
||||
* }
|
||||
*/
|
||||
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<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'] = $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<string> $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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
config/newsroom.php
Normal file
26
config/newsroom.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Newsroom-Konsolidierung (Roadmap 1a)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Schlüssel der Zusammenführung ist die Firmen-E-Mail. Der Schwellwert
|
||||
| (Entscheidung E1, 11.09.2026) legt fest, ab wie vielen WIRKLICH
|
||||
| verschiedenen Firmennamen eine Adresse als Sammeladresse gilt — also als
|
||||
| Agentur oder Dienstleister, über den nicht zusammengeführt wird.
|
||||
|
|
||||
| 3 ist das Ergebnis dreier Live-Läufe: Bei 2 wird jede zweite Schreibweise
|
||||
| zur „Agentur" (ProCoReX, Welcon), bei 4 wandern echte Mehrkunden-Adressen
|
||||
| in die Handarbeit. Tippfehler und Rechtsformen zählen dabei nicht als
|
||||
| verschiedene Namen (siehe CompanyAddressClassifier).
|
||||
|
|
||||
*/
|
||||
|
||||
'merge' => [
|
||||
'collection_threshold' => (int) env('NEWSROOM_MERGE_COLLECTION_THRESHOLD', 3),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Klassifizierung der Firmen-E-Mail-Adressen für die Newsroom-Konsolidierung
|
||||
* (Roadmap 1a.2).
|
||||
*
|
||||
* Eine Zeile je Adresse, nicht je Firma: Ob zusammengeführt werden darf,
|
||||
* hängt daran, was alles unter dieser Adresse steht — das ist eine
|
||||
* Eigenschaft der Adresse. 1a.3 (Zusammenführung im Bestand) und 1a.4
|
||||
* (laufende Zuordnung neuer API-Meldungen) lesen hier, statt den Trockenlauf
|
||||
* jedes Mal neu zu rechnen.
|
||||
*
|
||||
* `manual_classification` übersteuert die berechnete Klasse und überlebt jede
|
||||
* Neuberechnung. Nötig, weil die Berechnung Grenzfälle kennt, die nur ein
|
||||
* Mensch entscheiden kann — eine Agentur, die nie veröffentlicht hat, sieht in
|
||||
* den Daten aus wie eine Wegwerfadresse.
|
||||
*
|
||||
* Bewusst eine eigene Tabelle statt Spalten auf `companies`: Die trägt einen
|
||||
* FULLTEXT-Index, jedes ADD COLUMN dort ist ein Rebuild. Und die Zuordnung
|
||||
* Firma → Gruppe ist ohnehin nur die normalisierte Adresse; die steht bereits
|
||||
* an der Firma.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('company_email_groups', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
135
tests/Feature/ClassifyCompanyAddressesTest.php
Normal file
135
tests/Feature/ClassifyCompanyAddressesTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CompanyAddressClass;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyEmailGroup;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Persistierte Klassifizierung der Firmen-E-Mail-Adressen (Roadmap 1a.2).
|
||||
* Hierauf filtern Zusammenführung (1a.3) und laufende Zuordnung (1a.4).
|
||||
*/
|
||||
function classifiedCompany(string $email, string $name): Company
|
||||
{
|
||||
return Company::factory()->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);
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue