diff --git a/app/Console/Commands/ReportCompanyMerge.php b/app/Console/Commands/ReportCompanyMerge.php
new file mode 100644
index 00000000..2b32098e
--- /dev/null
+++ b/app/Console/Commands/ReportCompanyMerge.php
@@ -0,0 +1,105 @@
+build(
+ max(2, (int) $this->option('threshold')),
+ max(1, (int) $this->option('limit')),
+ );
+
+ if ($this->option('json')) {
+ $this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+
+ return self::SUCCESS;
+ }
+
+ $g = $data['gesamt'];
+
+ $this->info('Trockenlauf Newsroom-Konsolidierung — es wird nichts geschrieben.');
+ $this->newLine();
+ $this->line(sprintf(' Firmen gesamt: %d', $g['firmen']));
+ $this->line(sprintf(' Adressen mit mehr als einer Firma: %d (%d Firmen)', $g['adressen_mehrfach'], $g['firmen_mehrfach']));
+ $this->line(sprintf(' Schwellwert Sammeladresse (E1): ab %d abweichenden Namen', $data['schwellwert']));
+ $this->newLine();
+
+ $rows = [];
+ $mergeable = 0;
+
+ foreach ($data['klassen'] as $class) {
+ $rows[] = [
+ $class['klasse'],
+ $class['adressen'],
+ $class['firmen'],
+ $class['faellt_weg'] > 0 ? $class['faellt_weg'] : '–',
+ $class['firmen_mit_pm'],
+ $class['beschreibung'],
+ ];
+
+ $mergeable += $class['faellt_weg'];
+ }
+
+ $this->table(['Klasse', 'Adressen', 'Firmen', 'fällt weg', 'davon mit PM', 'Bedeutung'], $rows);
+
+ $this->line(sprintf(
+ ' Eine automatische Zusammenführung würde %d Firmen zu bestehenden Newsrooms verschmelzen.',
+ $mergeable,
+ ));
+
+ if ($data['nie_veroeffentlicht'] !== []) {
+ $this->newLine();
+ $this->warn('Adressen ohne jede Veröffentlichung — von jeder automatischen Aktion ausnehmen:');
+
+ $this->table(
+ ['Adresse', 'Namen', 'Firmen', 'davon mit PM'],
+ array_map(
+ static fn (array $row): array => [$row['email'], $row['namen'], $row['firmen'], $row['firmen_mit_pm']],
+ $data['nie_veroeffentlicht'],
+ ),
+ );
+
+ $this->line(' Viele Namen, nie etwas veröffentlicht. Darunter sind Platzhalter wie yahoo@yahoo.com,');
+ $this->line(' hinter denen gar keine Firma steht — würde über sie zusammengeführt, verschmölzen');
+ $this->line(' tausende unbeteiligte Firmen zu einem Newsroom. Es können aber auch inaktive');
+ $this->line(' Agenturen darunter sein. Deshalb: einzeln ansehen, nichts automatisch tun.');
+ }
+
+ if ($data['sammeladressen'] !== []) {
+ $this->newLine();
+ $this->line('Sammeladressen — Kandidaten für das Agenturmodell (3.3):');
+
+ $this->table(
+ ['Adresse', 'Namen', 'Firmen', 'davon mit PM', 'Beispiele'],
+ array_map(
+ static fn (array $row): array => [
+ $row['email'], $row['namen'], $row['firmen'], $row['firmen_mit_pm'], $row['beispiele'],
+ ],
+ $data['sammeladressen'],
+ ),
+ );
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Services/Company/CompanyMergeReport.php b/app/Services/Company/CompanyMergeReport.php
new file mode 100644
index 00000000..125b1013
--- /dev/null
+++ b/app/Services/Company/CompanyMergeReport.php
@@ -0,0 +1,243 @@
+
+ */
+ private const LegalForms = [
+ 'gmbh & co kg', 'gmbh & co. kg', 'gmbh und co kg', 'ag & co kg',
+ 'gmbh', 'mbh', 'ug haftungsbeschraenkt', 'ug', 'ohg', 'kgaa', 'kg', 'ag', 'se',
+ 'e k', 'ek', 'ev', 'e v', 'gbr', 'partg', 'ltd', 'llc', 'inc', 'bv', 'nv', 'sa', 'srl', 'plc',
+ ];
+
+ /**
+ * @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (Entscheidung E1)
+ * @return array{
+ * schwellwert: int,
+ * gesamt: array{firmen: int, adressen_mehrfach: int, firmen_mehrfach: int},
+ * klassen: list,
+ * sammeladressen: list,
+ * nie_veroeffentlicht: list,
+ * }
+ */
+ public function build(int $collectionThreshold = 3, int $limit = 15): array
+ {
+ $groups = $this->groups();
+
+ $classes = [
+ 'dublette' => ['beschreibung' => 'identischer Name — automatisch zusammenführbar', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0],
+ 'namensvariante' => ['beschreibung' => 'nur Rechtsform/Schreibweise abweichend — automatisch zusammenführbar', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0],
+ 'vorschlag' => ['beschreibung' => 'abweichende Namen unter dem Schwellwert — Vorschlag im Admin, keine automatische Aktion', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0],
+ 'sammeladresse' => ['beschreibung' => 'Agentur oder Dienstleister — markieren, nicht zusammenführen', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0],
+ 'nie_veroeffentlicht' => ['beschreibung' => 'nie eine Veröffentlichung — von jeder automatischen Aktion ausnehmen, einzeln prüfen', 'adressen' => 0, 'firmen' => 0, 'faellt_weg' => 0, 'firmen_mit_pm' => 0],
+ ];
+
+ $collections = [];
+ $junk = [];
+
+ foreach ($groups as $group) {
+ $class = $this->classify($group, $collectionThreshold);
+
+ $classes[$class]['adressen']++;
+ $classes[$class]['firmen'] += $group['firmen'];
+ $classes[$class]['firmen_mit_pm'] += $group['firmen_mit_pm'];
+
+ // Wegfallen können nur die Klassen, die tatsächlich verschmolzen
+ // werden. Bei allem anderen bleibt jede Firma stehen.
+ if (in_array($class, ['dublette', 'namensvariante'], true)) {
+ $classes[$class]['faellt_weg'] += $group['firmen'] - 1;
+ }
+
+ if ($class === 'sammeladresse') {
+ $collections[] = [
+ 'email' => $group['email'],
+ 'namen' => $group['namen'],
+ 'firmen' => $group['firmen'],
+ 'firmen_mit_pm' => $group['firmen_mit_pm'],
+ 'beispiele' => Str::limit(implode(' | ', array_slice($group['beispiele'], 0, 3)), 90),
+ ];
+ }
+
+ if ($class === 'nie_veroeffentlicht') {
+ $junk[] = [
+ 'email' => $group['email'],
+ 'namen' => $group['namen'],
+ 'firmen' => $group['firmen'],
+ 'firmen_mit_pm' => $group['firmen_mit_pm'],
+ ];
+ }
+ }
+
+ usort($collections, static fn (array $a, array $b): int => $b['firmen'] <=> $a['firmen']);
+ usort($junk, static fn (array $a, array $b): int => $b['firmen'] <=> $a['firmen']);
+
+ return [
+ 'schwellwert' => $collectionThreshold,
+ 'gesamt' => [
+ 'firmen' => Company::query()->withoutGlobalScope(PortalScope::class)->count(),
+ 'adressen_mehrfach' => count($groups),
+ 'firmen_mehrfach' => array_sum(array_column($groups, 'firmen')),
+ ],
+ 'klassen' => array_map(
+ static fn (string $key, array $data): array => ['klasse' => $key] + $data,
+ array_keys($classes),
+ array_values($classes),
+ ),
+ 'sammeladressen' => array_slice($collections, 0, $limit),
+ 'nie_veroeffentlicht' => array_slice($junk, 0, $limit),
+ ];
+ }
+
+ /**
+ * Adressen mit mehr als einer Firma, mit Namen und PM-Bestand.
+ *
+ * @return list, firmen_mit_pm: int}>
+ */
+ private function groups(): array
+ {
+ // Einmal alle Firmen mit mindestens einer veröffentlichten Meldung,
+ // statt je Firma eine korrelierte Unterabfrage: Der Bericht läuft über
+ // den gesamten Bestand, und das ist eine Abfrage gegen hunderttausend.
+ $withPublication = array_flip(
+ PressRelease::query()
+ ->withoutGlobalScope(PortalScope::class)
+ ->where('status', 'published')
+ ->whereNotNull('company_id')
+ ->distinct()
+ ->pluck('company_id')
+ ->all()
+ );
+
+ $rows = Company::query()
+ ->withoutGlobalScope(PortalScope::class)
+ ->whereNotNull('email')
+ ->where('email', '!=', '')
+ ->orderBy('email')
+ ->orderBy('id')
+ ->get(['id', 'email', 'name']);
+
+ $groups = [];
+
+ foreach ($rows as $row) {
+ $key = mb_strtolower(trim((string) $row->email));
+
+ $groups[$key] ??= ['email' => $key, 'firmen' => 0, 'namen' => 0, 'beispiele' => [], 'firmen_mit_pm' => 0, 'normalisiert' => []];
+ $groups[$key]['firmen']++;
+ $groups[$key]['firmen_mit_pm'] += isset($withPublication[$row->id]) ? 1 : 0;
+
+ $name = trim((string) $row->name);
+
+ if (! in_array($name, $groups[$key]['beispiele'], true)) {
+ $groups[$key]['beispiele'][] = $name;
+ }
+
+ $groups[$key]['normalisiert'][$this->normalise($name)] = true;
+ }
+
+ $result = [];
+
+ foreach ($groups as $group) {
+ if ($group['firmen'] < 2) {
+ continue;
+ }
+
+ $group['namen'] = count($group['beispiele']);
+ $group['namen_normalisiert'] = count($group['normalisiert']);
+ unset($group['normalisiert']);
+
+ $result[] = $group;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array{firmen: int, namen: int, namen_normalisiert: int, firmen_mit_pm: int} $group
+ */
+ private function classify(array $group, int $collectionThreshold): string
+ {
+ if ($group['namen'] >= self::JunkThreshold
+ && $group['firmen_mit_pm'] / max(1, $group['firmen']) < self::JunkPublishedRatio) {
+ return 'nie_veroeffentlicht';
+ }
+
+ if ($group['namen'] === 1) {
+ return 'dublette';
+ }
+
+ // Normalisiert derselbe Name: Rechtsform oder Schreibweise weicht ab,
+ // die Firma ist dieselbe. Genau der Fall „gleicher ODER ÄHNLICHER
+ // Firmenname" aus dem Regelwerk.
+ if ($group['namen_normalisiert'] === 1) {
+ return 'namensvariante';
+ }
+
+ return $group['namen_normalisiert'] >= $collectionThreshold ? 'sammeladresse' : 'vorschlag';
+ }
+
+ /**
+ * Firmenname auf seinen Kern reduziert: Kleinschreibung, Umlaute
+ * aufgelöst, Rechtsform und Satzzeichen entfernt.
+ */
+ private function normalise(string $name): string
+ {
+ $value = mb_strtolower($name);
+ $value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
+ $value = (string) preg_replace('/[^a-z0-9]+/', ' ', $value);
+ $value = trim((string) preg_replace('/\s+/', ' ', $value));
+
+ foreach (self::LegalForms as $form) {
+ if (str_ends_with($value, ' '.$form)) {
+ $value = trim(mb_substr($value, 0, -mb_strlen($form) - 1));
+ }
+ }
+
+ return $value;
+ }
+}
diff --git a/tests/Feature/CompanyMergeReportTest.php b/tests/Feature/CompanyMergeReportTest.php
new file mode 100644
index 00000000..cd62ddf3
--- /dev/null
+++ b/tests/Feature/CompanyMergeReportTest.php
@@ -0,0 +1,133 @@
+create(['email' => $email, 'name' => $name]);
+
+ if ($published) {
+ PressRelease::factory()->published()->create(['company_id' => $company->id]);
+ }
+
+ return $company;
+}
+
+function mergeClasses(int $threshold = 3): Collection
+{
+ return collect(app(CompanyMergeReport::class)->build($threshold)['klassen'])->keyBy('klasse');
+}
+
+test('identical names on one address are a mergeable duplicate', function () {
+ /** @var TestCase $this */
+ mergeCompany('info@muster.de', 'Muster GmbH');
+ mergeCompany('INFO@Muster.de', 'Muster GmbH');
+
+ $classes = mergeClasses();
+
+ // Groß-/Kleinschreibung der Adresse darf keine zweite Identität erzeugen.
+ expect($classes['dublette']['adressen'])->toBe(1)
+ ->and($classes['dublette']['firmen'])->toBe(2)
+ ->and($classes['dublette']['faellt_weg'])->toBe(1);
+});
+
+test('a differing legal form is the same company', function () {
+ /** @var TestCase $this */
+ mergeCompany('info@hoppe-net.de', 'Hoppe Unternehmensberatung');
+ mergeCompany('info@hoppe-net.de', 'Hoppe Unternehmensberatung GmbH');
+
+ // Genau der Fall „gleicher ODER ÄHNLICHER Firmenname" aus dem Regelwerk.
+ expect(mergeClasses()['namensvariante']['faellt_weg'])->toBe(1);
+});
+
+test('two genuinely different names stay below the threshold as a proposal', function () {
+ /** @var TestCase $this */
+ mergeCompany('sandra@buschgroup.com', 'Busch Group');
+ mergeCompany('sandra@buschgroup.com', 'Pfeiffer Vacuum');
+
+ $classes = mergeClasses(3);
+
+ // Keine automatische Aktion: Zwei Marken unter einem Pressekontakt sind
+ // nicht dasselbe Unternehmen, aber auch keine Agentur.
+ expect($classes['vorschlag']['adressen'])->toBe(1)
+ ->and($classes['vorschlag']['faellt_weg'])->toBe(0)
+ ->and($classes['sammeladresse']['adressen'])->toBe(0);
+});
+
+test('the threshold decides where a collection address begins', function () {
+ /** @var TestCase $this */
+ foreach (['Alpha', 'Beta', 'Gamma'] as $name) {
+ mergeCompany('presse@agentur.de', $name);
+ }
+
+ // E1 ist genau dieser Schwellwert — der Bericht macht ihn verstellbar,
+ // damit die Entscheidung an Zahlen und nicht an einem Gefühl hängt.
+ expect(mergeClasses(3)['sammeladresse']['adressen'])->toBe(1)
+ ->and(mergeClasses(4)['sammeladresse']['adressen'])->toBe(0)
+ ->and(mergeClasses(4)['vorschlag']['adressen'])->toBe(1);
+});
+
+test('many names without a single publication are flagged, not merged', function () {
+ /** @var TestCase $this */
+ for ($i = 1; $i <= 30; $i++) {
+ mergeCompany('yahoo@yahoo.com', "Firma {$i}");
+ }
+
+ $classes = mergeClasses();
+
+ // Der teuerste denkbare Fehler dieses Blocks: über eine Platzhalteradresse
+ // zusammenzuführen und tausende unbeteiligte Firmen zu verschmelzen.
+ expect($classes['nie_veroeffentlicht']['adressen'])->toBe(1)
+ ->and($classes['nie_veroeffentlicht']['firmen'])->toBe(30)
+ ->and($classes['nie_veroeffentlicht']['faellt_weg'])->toBe(0)
+ ->and($classes['sammeladresse']['adressen'])->toBe(0);
+});
+
+test('a large but working agency is not mistaken for a placeholder', function () {
+ /** @var TestCase $this */
+ for ($i = 1; $i <= 30; $i++) {
+ mergeCompany('pr@agentur.ag', "Kunde {$i}", published: true);
+ }
+
+ // Die Namensanzahl allein trennt nicht. Erst die Veröffentlichung tut es.
+ $classes = mergeClasses();
+
+ expect($classes['sammeladresse']['adressen'])->toBe(1)
+ ->and($classes['nie_veroeffentlicht']['adressen'])->toBe(0);
+});
+
+test('a single company on its own address is not part of the report', function () {
+ /** @var TestCase $this */
+ mergeCompany('allein@muster.de', 'Allein GmbH');
+
+ $data = app(CompanyMergeReport::class)->build();
+
+ expect($data['gesamt']['adressen_mehrfach'])->toBe(0);
+});
+
+test('the report writes nothing', function () {
+ /** @var TestCase $this */
+ mergeCompany('info@muster.de', 'Muster GmbH');
+ mergeCompany('info@muster.de', 'Muster GmbH');
+
+ app(CompanyMergeReport::class)->build();
+
+ // Für die rückwirkende Zusammenführung im Altbestand trägt das Portal die
+ // Verantwortung — deshalb steht der Trockenlauf davor und fasst nichts an.
+ expect(Company::query()->count())->toBe(2);
+});
+
+test('the command runs and reports the threshold in use', function () {
+ /** @var TestCase $this */
+ $this->artisan('companies:merge-report', ['--threshold' => 4])
+ ->expectsOutputToContain('ab 4 abweichenden Namen')
+ ->assertSuccessful();
+});