companies: Zusammenführung im Bestand, rückabwickelbar (Roadmap 1a.3)

companies:merge führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom
zusammen — nur über Adressen, die 1a.2 als Dublette oder Namensvariante
klassifiziert hat. Es bleibt die Firma mit den meisten jemals
veröffentlichten Meldungen; Meldungen, Kontakte, Nutzer- und
Zahlungsverknüpfungen wandern per company_id, jede Bewegung steht in
company_merge_moves. companies:unmerge spielt sie rückwärts ab.

77 % der Gruppen sind dieselbe Firma auf beiden Portalen (Altsystem: eine
Firma je Portal). Der Überlebende wird dann `both`; die Meldungen behalten
ihr Portal. Der alte Newsroom-Slug führt per 301 zum Überlebenden.
Slug-Kollisionen auf dem anderen Portal werden übersprungen, nicht
umbenannt. Company::slugExists() kennt jetzt `both`.

Dev-DB: 14 142 Adressen, 16 194 Firmen, 13 297 Meldungen in 2 min;
Rückabwicklung in 1:15 min exakt auf den Stand davor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 08:43:42 +00:00
parent 66d8398e97
commit 2aebb9d27e
17 changed files with 1508 additions and 7 deletions

View file

@ -108,6 +108,8 @@ Der Kern. Wandelt vorhandenen Zulauf in Konten und Kunden, ohne neuen Traffic zu
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 Magic Link kann trotzdem an beide gehen, das ist eine Berechtigungs- und keine Identitätsfrage.
**Entschieden 15.09.2026 (1a.3):** Es bleibt die Firma mit den meisten jemals veröffentlichten Meldungen, bei Gleichstand die ältere; leere Stammdaten werden aufgefüllt. Meldungen, Kontakte und Verknüpfungen wandern per `company_id`, jede Bewegung protokolliert (`company_merges`/`company_merge_moves`), rückabwickelbar mit `companies:unmerge`. Die wegfallende Firma wird soft-deleted, ihr Slug führt per 301 weiter. Der Überlebende wird `both`, wenn die Hälften auf verschiedenen Portalen lagen — das ist bei 77 % der Gruppen der Fall (Altsystem: eine Firma je Portal). Slug-Kollisionen werden übersprungen, nicht umbenannt. Details in `umsetzung-protokoll.md` Teil 3.
Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die rückwirkende Zusammenführung im Altbestand trägt sie das Portal, weil dort nicht angelegt, sondern verschmolzen wird. Deshalb 1a.1 vor 1a.3.
### 1b Newsroom als Produkt

View file

@ -701,3 +701,104 @@ Statuslogs des neuen Systems).
**Deploy:** Nach dem Deploy `companies:classify-addresses` erneut laufen
lassen — die 16 Adressen werden dabei umklassifiziert, Handentscheidungen
bleiben.
## 1a.3 — Zusammenführung im Bestand
```
php artisan companies:merge [--dry-run] [--limit=] [--email=] [--json]
php artisan companies:unmerge --merge=ID | --batch=UUID | --email= [--dry-run]
```
### Der Fakt, der den Entwurf bestimmt hat
Die meisten „Dubletten" sind **dieselbe Firma auf zwei Portalen**: Das
Altsystem hat jede Firma je Portal einmal angelegt.
| | Gruppen |
|---|---:|
| Adressgruppen gesamt (Dev-DB) | 18 860 |
| davon mit Firmen auf beiden Portalen | **14 519** (77 %) |
| Zweiergruppen | 15 754 |
| davon exakt PE + B24 | 12 605 |
| davon zusätzlich mit identischem Slug | 12 037 |
`Portal::Both` existiert im Code — Newsroom-Controller, PortalScope und
Slug-Auflösung behandeln es —, aber **keine Firma** trug es. Die
Zusammenführung ist also weniger „Dubletten löschen" als „zwei Portalhälften
zu einer Firma machen": Der Überlebende bekommt `both`, die Meldungen
behalten ihr Portal, der Newsroom zeigt auf jedem Portal die Meldungen dieses
Portals.
### Entscheidungen (15.09.2026, A/A/A + both)
1. **Es bleibt** die Firma mit den meisten jemals veröffentlichten Meldungen
(`published` oder `hits > 0`), bei Gleichstand die ältere. Leere
Stammdatenfelder (Website, Telefon, Fax, Adresse, Land, Logo, Boilerplate,
Inhaber) füllt sie aus den wegfallenden Firmen; belegte bleiben. Ist der
Überlebende inaktiv und eine wegfallende Firma aktiv, wird er aktiv.
2. **Meldungen, Kontakte, KI-Erklärungen, Konversionsereignisse** wandern per
`company_id`. `company_user` und Zahlungsoptionen werden zur Vereinigung;
ein Nutzer, der schon am Überlebenden stand, wird nicht doppelt — sein
alter Eintrag fällt weg und ist als `dropped` mit Rolle protokolliert.
Jede Bewegung steht in `company_merge_moves`; der Vorgang in
`company_merges` (Batch-ID, Überlebender, sein Portal vorher).
3. **Die wegfallende Firma** wird soft-deleted. Ihr alter Slug führt per
**301** zum Überlebenden (`NewsroomDetailPageData::mergedInto()`), sofern
der Slug abweicht — bei gleichem Slug findet die Auflösung den `both`-
Überlebenden direkt. Kein 410: Die Firma gibt es noch, nur woanders.
**Slug-Kollision:** Wird der Überlebende `both`, muss sein Slug auf dem
anderen Portal frei sein (die öffentliche Auflösung fragt
`portal IN (eigenes, both)`). Ist der Erstkandidat blockiert, kommt der
nächste dran; ist keiner frei, wird die Adresse **übersprungen** und
aufgelistet. Nicht umbenannt — die URL ist öffentlich. Vorab geschätzt 664
Gruppen, tatsächlich **5** — die Kollisionen sind fast immer einseitig
(Alt-Slugs sind je Portal durchnummeriert), und die andere Hälfte ist frei.
**410-entfernte Firmen** bleiben außen vor: Ihre Meldungen in den
Überlebenden zu heben, würde bewusst entfernte Inhalte wieder sichtbar
machen.
**Keine Schema-Änderung an bestehenden Tabellen.** Kein
`merged_from_company_id` auf `press_releases` (1 GB, einmaliger Vorgang),
kein `merged_into_company_id` auf `companies` (FULLTEXT) — beides kommt aus
den zwei neuen Tabellen. `press_release_search` enthält keine Firmendaten;
das Umhängen per Query-Builder braucht keinen Reindex.
### Nebenbefund und Fix: Slug-Eindeutigkeit kannte `both` nicht
`HasUniqueSlug` prüft bei Firmen nur `portal = X`. Eine neue PE-Firma hätte
denselben Slug wie eine `both`-Firma bekommen können — und die Newsroom-URL
hätte zwei Firmen gefunden. `Company::slugExists()` prüft jetzt
`portal IN (X, both)`, und eine `both`-Firma braucht den Slug überall frei.
Test in `HasUniqueSlugTest`.
### Lauf auf der Dev-DB (Kopie des Bestands)
| | |
|---|---:|
| Adressen zusammengeführt | 14 142 |
| Firmen im Überlebenden aufgegangen | 16 194 |
| Meldungen gewandert | 13 297 |
| Firmen jetzt `both` | 12 926 |
| Kontakte gewandert | 8 038 |
| Nutzer-Verknüpfungen weggefallen (schon am Überlebenden) | 15 467 |
| Stammdatenfelder aufgefüllt | 1 819 |
| übersprungen (Slug-Kollision) | 5 |
| Laufzeit merge / unmerge | 2:00 min / 1:15 min |
Nach `companies:unmerge --batch=…` stimmten Portalverteilung (42 287 / 66 892),
`company_user` (107 663) und Soft-Deletes (2 155) wieder exakt mit dem
Stand davor überein. Die Rückabwicklung ist verifiziert.
Zwei Performance-Punkte beim Bau: Die Kollisionsprüfung mit `portal != ?`
kostete 24 ms je Adresse (kein Index) — mit `portal IN (…)` greift der
Unique-Index (portal, slug), 0 ms. Und wegfallende Firmen werden per
Query-Builder soft-deleted, nicht per `$company->delete()`: Der Observer
würde je Firma den Admin-Cache leeren; der Lauf leert einmal am Ende.
**Deploy:** `migrate --force`, dann `companies:merge --dry-run`, dann
`companies:merge --limit=50` und die 50 im Admin ansehen, dann ohne Limit.
Erledigte Adressen zählen beim zweiten Lauf nicht gegen das Limit. Danach
`companies:classify-addresses` — die zusammengeführten Adressen fallen aus
`company_email_groups`, weil nur noch eine Firma darunter steht.