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:
parent
66d8398e97
commit
2aebb9d27e
17 changed files with 1508 additions and 7 deletions
178
app/Console/Commands/MergeCompanies.php
Normal file
178
app/Console/Commands/MergeCompanies.php
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\CompanyAddressClass;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyEmailGroup;
|
||||
use App\Services\Company\CompanyMergePlan;
|
||||
use App\Services\Company\CompanyMergeService;
|
||||
use App\Services\Company\CompanyMergeSkip;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Zusammenführung im Bestand (Roadmap 1a.3).
|
||||
*
|
||||
* Läuft über `company_email_groups` und fasst jede zusammenführbare Adresse
|
||||
* an — Dublette und Namensvariante, eine Handentscheidung übersteuert. Jede
|
||||
* Adresse ist eine eigene Transaktion mit protokollierten Bewegungen;
|
||||
* `companies:unmerge` kehrt sie um.
|
||||
*
|
||||
* `--limit` ist für den ersten scharfen Lauf gedacht: erst 50 Adressen, dann
|
||||
* ansehen, dann der Rest. Alle Vorgänge eines Laufs teilen eine Batch-ID.
|
||||
*/
|
||||
class MergeCompanies extends Command
|
||||
{
|
||||
protected $signature = 'companies:merge
|
||||
{--dry-run : Nur zeigen, was geschähe}
|
||||
{--limit= : Höchstens so viele Adressen anfassen}
|
||||
{--email= : Nur diese eine Adresse}
|
||||
{--json : Ausgabe als JSON}';
|
||||
|
||||
protected $description = 'Führt Firmen mit derselben Firmen-E-Mail zu einem Newsroom zusammen (nur zusammenführbare Adressen).';
|
||||
|
||||
public function handle(CompanyMergeService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$batch = (string) Str::uuid();
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
|
||||
$groups = $this->groups();
|
||||
|
||||
$stats = ['adressen' => 0, 'zusammengefuehrt' => 0, 'firmen_weg' => 0, 'meldungen' => 0, 'both' => 0, 'uebersprungen' => []];
|
||||
$plans = [];
|
||||
$skips = [];
|
||||
|
||||
foreach ($groups->lazy() as $group) {
|
||||
if ($limit !== null && $stats['adressen'] >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$result = $service->plan($group);
|
||||
|
||||
if ($result instanceof CompanyMergeSkip) {
|
||||
// Eine Adresse, unter der nichts (mehr) zusammenzuführen ist,
|
||||
// zählt nicht gegen das Limit — sonst füllt sie beim zweiten
|
||||
// Lauf die ersten 50 Plätze.
|
||||
if ($result->reason !== CompanyMergeSkip::NothingToMerge) {
|
||||
$skips[] = $result;
|
||||
}
|
||||
$stats['uebersprungen'][$result->reason] = ($stats['uebersprungen'][$result->reason] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$stats['adressen']++;
|
||||
$stats['firmen_weg'] += count($result->losers);
|
||||
$stats['meldungen'] += $result->pressReleases;
|
||||
$stats['both'] += $result->targetPortal->value === 'both' && $result->survivor->portal->value !== 'both' ? 1 : 0;
|
||||
|
||||
if ($dryRun) {
|
||||
if (count($plans) < 30) {
|
||||
$plans[] = $result;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$service->merge($result, $batch, flushCaches: false);
|
||||
$stats['zusammengefuehrt']++;
|
||||
}
|
||||
|
||||
if (! $dryRun && $stats['zusammengefuehrt'] > 0) {
|
||||
$service->flushCaches();
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'dry_run' => $dryRun,
|
||||
'batch' => $dryRun ? null : $batch,
|
||||
'statistik' => $stats,
|
||||
'plaene' => array_map(fn (CompanyMergePlan $plan): array => $this->describe($plan), $plans),
|
||||
'uebersprungen' => array_map(fn (CompanyMergeSkip $skip): array => ['email' => $skip->email, 'grund' => $skip->reason, 'details' => $skip->details], $skips),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s%d Adressen, %d Firmen gehen im Überlebenden auf, %d Meldungen wandern, %d Firmen werden portalübergreifend (both).',
|
||||
$dryRun ? '[Dry-Run] ' : '',
|
||||
$stats['adressen'],
|
||||
$stats['firmen_weg'],
|
||||
$stats['meldungen'],
|
||||
$stats['both'],
|
||||
));
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->line(sprintf(' Batch %s — Rückabwicklung: companies:unmerge --batch=%s', $batch, $batch));
|
||||
}
|
||||
|
||||
foreach ($stats['uebersprungen'] as $reason => $count) {
|
||||
$this->line(sprintf(' übersprungen (%s): %d', $reason, $count));
|
||||
}
|
||||
|
||||
if ($plans !== []) {
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['Adresse', 'bleibt', 'Portal', 'geht auf', 'Meldungen'],
|
||||
array_map(fn (CompanyMergePlan $plan): array => [
|
||||
$plan->email,
|
||||
sprintf('#%d %s', $plan->survivor->getKey(), Str::limit($plan->survivor->name, 40)),
|
||||
$plan->targetPortal->value,
|
||||
implode(', ', array_map(fn (Company $loser): string => sprintf('#%d %s', $loser->getKey(), Str::limit($loser->name, 30)), $plan->losers)),
|
||||
$plan->pressReleases,
|
||||
], $plans),
|
||||
);
|
||||
}
|
||||
|
||||
if ($skips !== []) {
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['übersprungen', 'Grund', 'Details'],
|
||||
array_map(fn (CompanyMergeSkip $skip): array => [$skip->email, $skip->reason, implode(', ', $skip->details)], array_slice($skips, 0, 50)),
|
||||
);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zusammenführbare Adressen: berechnete Klasse, sofern keine
|
||||
* Handentscheidung dagegen steht — oder Handentscheidung dafür.
|
||||
*
|
||||
* @return Builder<CompanyEmailGroup>
|
||||
*/
|
||||
private function groups(): Builder
|
||||
{
|
||||
$mergeable = array_map(
|
||||
fn (CompanyAddressClass $class): string => $class->value,
|
||||
array_filter(CompanyAddressClass::cases(), fn (CompanyAddressClass $class): bool => $class->isMergeable()),
|
||||
);
|
||||
|
||||
return CompanyEmailGroup::query()
|
||||
->when($this->option('email'), fn (Builder $query, string $email) => $query->where('email', Company::emailKey($email)))
|
||||
->where(function (Builder $query) use ($mergeable): void {
|
||||
$query->whereIn('manual_classification', $mergeable)
|
||||
->orWhere(fn (Builder $computed) => $computed->whereNull('manual_classification')->whereIn('classification', $mergeable));
|
||||
})
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function describe(CompanyMergePlan $plan): array
|
||||
{
|
||||
return [
|
||||
'email' => $plan->email,
|
||||
'bleibt' => ['id' => $plan->survivor->getKey(), 'name' => $plan->survivor->name, 'portal' => $plan->survivor->portal->value, 'slug' => $plan->survivor->slug],
|
||||
'portal_danach' => $plan->targetPortal->value,
|
||||
'geht_auf' => array_map(fn (Company $loser): array => ['id' => $loser->getKey(), 'name' => $loser->name, 'portal' => $loser->portal->value, 'slug' => $loser->slug], $plan->losers),
|
||||
'ausgenommen_410' => array_map(fn (Company $company): int => $company->getKey(), $plan->excluded),
|
||||
'meldungen' => $plan->pressReleases,
|
||||
];
|
||||
}
|
||||
}
|
||||
67
app/Console/Commands/UnmergeCompanies.php
Normal file
67
app/Console/Commands/UnmergeCompanies.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CompanyMerge;
|
||||
use App\Services\Company\CompanyMergeService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Rückabwicklung einer Zusammenführung (Roadmap 1a.3): einen Vorgang, alle
|
||||
* eines Laufs, oder alle einer Adresse. Spielt die protokollierten Bewegungen
|
||||
* rückwärts ab; was seither neu beim Überlebenden entstand, bleibt dort.
|
||||
*/
|
||||
class UnmergeCompanies extends Command
|
||||
{
|
||||
protected $signature = 'companies:unmerge
|
||||
{--merge= : ID eines Vorgangs aus company_merges}
|
||||
{--batch= : Alle Vorgänge eines Laufs}
|
||||
{--email= : Alle Vorgänge unter dieser Adresse}
|
||||
{--dry-run : Nur zählen}';
|
||||
|
||||
protected $description = 'Macht eine Newsroom-Zusammenführung rückgängig.';
|
||||
|
||||
public function handle(CompanyMergeService $service): int
|
||||
{
|
||||
if (! $this->option('merge') && ! $this->option('batch') && ! $this->option('email')) {
|
||||
$this->error('Bitte --merge=, --batch= oder --email= angeben.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$query = CompanyMerge::query()
|
||||
->whereNull('reverted_at')
|
||||
->when($this->option('merge'), fn (Builder $query, string $id) => $query->whereKey((int) $id))
|
||||
->when($this->option('batch'), fn (Builder $query, string $batch) => $query->where('batch', $batch))
|
||||
->when($this->option('email'), fn (Builder $query, string $email) => $query->where('email', mb_strtolower(trim($email))))
|
||||
->orderByDesc('id');
|
||||
|
||||
$merges = $query->get();
|
||||
|
||||
if ($merges->isEmpty()) {
|
||||
$this->warn('Kein offener Vorgang gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$companies = $merges->sum('merged_companies_count');
|
||||
$releases = $merges->sum('moved_press_releases_count');
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info(sprintf('[Dry-Run] %d Vorgänge würden rückabgewickelt: %d Firmen wiederhergestellt, %d Meldungen zurück.', $merges->count(), $companies, $releases));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($merges as $merge) {
|
||||
$service->revert($merge, flushCaches: false);
|
||||
}
|
||||
|
||||
$service->flushCaches();
|
||||
|
||||
$this->info(sprintf('%d Vorgänge rückabgewickelt: %d Firmen wiederhergestellt, %d Meldungen zurück.', $merges->count(), $companies, $releases));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
app/Enums/CompanyMergeSubject.php
Normal file
35
app/Enums/CompanyMergeSubject.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Was bei einer Newsroom-Zusammenführung (Roadmap 1a.3) verschoben wurde.
|
||||
* Jede Zeile in `company_merge_moves` trägt genau eine dieser Arten; die
|
||||
* Rückabwicklung entscheidet daran, wie sie den Datensatz zurückholt.
|
||||
*/
|
||||
enum CompanyMergeSubject: string
|
||||
{
|
||||
/** `press_releases.company_id`, subject_id = Meldung */
|
||||
case PressRelease = 'press_release';
|
||||
|
||||
/** `contacts.company_id`, subject_id = Kontakt */
|
||||
case Contact = 'contact';
|
||||
|
||||
/** `press_release_ai_declarations.company_id`, subject_id = Erklärung */
|
||||
case AiDeclaration = 'ai_declaration';
|
||||
|
||||
/** `conversion_events.company_id`, subject_id = Ereignis */
|
||||
case ConversionEvent = 'conversion_event';
|
||||
|
||||
/** `company_user`, subject_id = Nutzer; previous_value = Rolle; dropped, wenn der Nutzer am Überlebenden schon stand */
|
||||
case CompanyUser = 'company_user';
|
||||
|
||||
/** `user_payment_option_company`, subject_id = Zahlungsoption; previous_value = is_active; dropped analog */
|
||||
case PaymentOption = 'payment_option';
|
||||
|
||||
/** Stammdatenfeld am Überlebenden aus einer wegfallenden Firma gefüllt; field = Spalte, previous_value = alter Wert */
|
||||
case Field = 'field';
|
||||
|
||||
/** Die wegfallende Firma selbst (Soft Delete), subject_id = from_company_id */
|
||||
case Company = 'company';
|
||||
}
|
||||
|
|
@ -6,16 +6,25 @@ use App\Http\Controllers\Controller;
|
|||
use App\Services\Web\NewsroomDetailPageData;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class NewsroomDetailController extends Controller
|
||||
{
|
||||
// $edition stammt aus dem Gruppen-Prefix {edition} und steht positionsbedingt
|
||||
// vor {slug}; es wird hier nicht gebraucht, muss aber in der Signatur stehen.
|
||||
public function __invoke(string $edition, string $slug, NewsroomDetailPageData $pageData): View|Response
|
||||
public function __invoke(string $edition, string $slug, NewsroomDetailPageData $pageData): View|Response|RedirectResponse
|
||||
{
|
||||
$data = $pageData->build(WebPortalContext::fromRequest(), $slug);
|
||||
|
||||
// Firma in einer Zusammenführung aufgegangen → dauerhaft zum
|
||||
// bleibenden Newsroom (Roadmap 1a.3).
|
||||
// Host aus dem Request wie bei den anderen 301-Weiterleitungen in
|
||||
// routes/web.php — route() allein trüge den konfigurierten Root-Host.
|
||||
if (isset($data['redirect'])) {
|
||||
return redirect(request()->getSchemeAndHttpHost().route('newsroom', ['slug' => $data['redirect']], absolute: false), 301);
|
||||
}
|
||||
|
||||
// Bewusst entfernter Newsroom → Tombstone mit 410 Gone (ohne Inhalte).
|
||||
// Vollständig gelöschte Firmen bleiben bei 404.
|
||||
if (isset($data['tombstone'])) {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,36 @@ class Company extends Model
|
|||
return 'company';
|
||||
}
|
||||
|
||||
/**
|
||||
* Portalübergreifende Firmen (`both`, seit der Zusammenführung 1a.3)
|
||||
* sind auf JEDEM Portal erreichbar — die öffentliche Auflösung fragt
|
||||
* `portal IN (eigenes, both)`. Ein Slug ist deshalb nur frei, wenn ihn
|
||||
* weder eine Firma desselben Portals noch eine `both`-Firma trägt; und
|
||||
* eine `both`-Firma braucht ihn auf allen Portalen frei. Der reine
|
||||
* Portal-Scope des Traits sähe die Überschneidung nicht.
|
||||
*
|
||||
* @param array<string, mixed> $scope
|
||||
*/
|
||||
protected function slugExists(string $slug, array $scope): bool
|
||||
{
|
||||
$portal = array_key_exists('portal', $scope) ? $scope['portal'] : $this->getAttribute('portal');
|
||||
$portal = $portal instanceof Portal ? $portal->value : $portal;
|
||||
|
||||
$query = static::query()
|
||||
->withoutGlobalScopes()
|
||||
->where($this->slugColumn(), $slug);
|
||||
|
||||
if ($this->exists) {
|
||||
$query->whereKeyNot($this->getKey());
|
||||
}
|
||||
|
||||
if ($portal !== null && $portal !== Portal::Both->value) {
|
||||
$query->whereIn('portal', [$portal, Portal::Both->value]);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope(new PortalScope);
|
||||
|
|
|
|||
60
app/Models/CompanyMerge.php
Normal file
60
app/Models/CompanyMerge.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Ein Zusammenführungsvorgang je Firmen-E-Mail (Roadmap 1a.3).
|
||||
*
|
||||
* Die Bewegungen (`moves`) sind die gespeicherte Ursprungszuordnung: Sie
|
||||
* reichen aus, den Vorgang exakt umzukehren. `reverted_at` markiert, dass das
|
||||
* geschehen ist — der Vorgang bleibt als Nachweis stehen.
|
||||
*/
|
||||
class CompanyMerge extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'batch',
|
||||
'email',
|
||||
'survivor_company_id',
|
||||
'survivor_portal_before',
|
||||
'merged_companies_count',
|
||||
'moved_press_releases_count',
|
||||
'reverted_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'survivor_portal_before' => Portal::class,
|
||||
'reverted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Company, $this>
|
||||
*/
|
||||
public function survivor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'survivor_company_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<CompanyMergeMove, $this>
|
||||
*/
|
||||
public function moves(): HasMany
|
||||
{
|
||||
return $this->hasMany(CompanyMergeMove::class);
|
||||
}
|
||||
|
||||
public function isReverted(): bool
|
||||
{
|
||||
return $this->reverted_at !== null;
|
||||
}
|
||||
}
|
||||
46
app/Models/CompanyMergeMove.php
Normal file
46
app/Models/CompanyMergeMove.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Ein verschobener Datensatz innerhalb einer Zusammenführung (Roadmap 1a.3).
|
||||
* Nur über `CompanyMerge` sinnvoll; ohne Zeitstempel, weil der am Vorgang
|
||||
* steht.
|
||||
*/
|
||||
class CompanyMergeMove extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'company_merge_id',
|
||||
'subject',
|
||||
'subject_id',
|
||||
'from_company_id',
|
||||
'field',
|
||||
'previous_value',
|
||||
'dropped',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'subject' => CompanyMergeSubject::class,
|
||||
'dropped' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<CompanyMerge, $this>
|
||||
*/
|
||||
public function merge(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CompanyMerge::class, 'company_merge_id');
|
||||
}
|
||||
}
|
||||
26
app/Services/Company/CompanyMergePlan.php
Normal file
26
app/Services/Company/CompanyMergePlan.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
|
||||
/**
|
||||
* Was eine Zusammenführung tun würde — ohne es zu tun. Der Trockenlauf zeigt
|
||||
* den Plan, der scharfe Lauf führt ihn aus; beide sehen dasselbe.
|
||||
*/
|
||||
final readonly class CompanyMergePlan
|
||||
{
|
||||
/**
|
||||
* @param list<Company> $losers die Firmen, die im Überlebenden aufgehen
|
||||
* @param list<Company> $excluded Firmen der Adresse, die bewusst außen vor bleiben (410-entfernt)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public Company $survivor,
|
||||
public Portal $targetPortal,
|
||||
public array $losers,
|
||||
public array $excluded,
|
||||
public int $pressReleases,
|
||||
) {}
|
||||
}
|
||||
479
app/Services/Company/CompanyMergeService.php
Normal file
479
app/Services/Company/CompanyMergeService.php
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyEmailGroup;
|
||||
use App\Models\CompanyMerge;
|
||||
use App\Models\CompanyMergeMove;
|
||||
use App\Models\Contact;
|
||||
use App\Models\ConversionEvent;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseAiDeclaration;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Zusammenführung mehrerer Firmen zu einem Newsroom (Roadmap 1a.3).
|
||||
*
|
||||
* Schlüssel ist die Firmen-E-Mail; ob über eine Adresse zusammengeführt werden
|
||||
* darf, sagt `CompanyEmailGroup::isMergeable()` — nie diese Klasse. Sie
|
||||
* entscheidet nur noch, WIE: welche Firma bleibt, was zu ihr wandert, und sie
|
||||
* schreibt jede Bewegung nach `company_merge_moves`, damit `revert()` den
|
||||
* Vorgang exakt umkehren kann.
|
||||
*
|
||||
* Drei Regeln, am 15.09.2026 so entschieden:
|
||||
*
|
||||
* 1. Es bleibt die Firma mit den meisten jemals veröffentlichten Meldungen,
|
||||
* bei Gleichstand die ältere. Leere Stammdatenfelder füllt sie aus den
|
||||
* wegfallenden Firmen auf; belegte bleiben.
|
||||
* 2. Meldungen, Kontakte, Nutzer- und Zahlungsverknüpfungen wandern per
|
||||
* `company_id`; jede Bewegung wird protokolliert. Die Meldungen behalten
|
||||
* ihr Portal — nur die Firma wird `both`, wenn ihre Hälften auf
|
||||
* verschiedenen Portalen lagen. Das ist der Normalfall: Das Altsystem hat
|
||||
* jede Firma je Portal einmal angelegt, 77 % der Gruppen sind solche Paare.
|
||||
* 3. Die wegfallende Firma wird soft-deleted. Ihr alter Newsroom-Slug führt
|
||||
* per 301 zum Überlebenden (siehe NewsroomDetailPageData). Kein 410 — die
|
||||
* Firma gibt es ja noch, nur woanders.
|
||||
*
|
||||
* Wird der Überlebende `both`, muss sein Slug auf dem anderen Portal frei
|
||||
* sein, sonst fände die öffentliche Auflösung zwei Firmen. Ist er das bei
|
||||
* keinem Kandidaten, wird die Adresse übersprungen — nicht umbenannt, die URL
|
||||
* ist öffentlich.
|
||||
*/
|
||||
class CompanyMergeService
|
||||
{
|
||||
/**
|
||||
* Stammdaten, die der Überlebende aus wegfallenden Firmen auffüllt, wenn
|
||||
* sie bei ihm leer sind. Bewusst ohne `name`, `slug`, `email`, `portal` —
|
||||
* das ist seine Identität — und ohne `type`.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const FillableFields = [
|
||||
'website', 'phone', 'fax', 'address', 'country_code', 'logo_path', 'logo_variants', 'boilerplate', 'owner_user_id',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityLog $activityLog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Was für diese Adresse geschehen würde. Liest nur.
|
||||
*
|
||||
* @param Collection<int, Company>|null $members die Firmen der Adresse, wenn der Aufrufer sie schon geladen hat
|
||||
*/
|
||||
public function plan(CompanyEmailGroup $group, ?Collection $members = null): CompanyMergePlan|CompanyMergeSkip
|
||||
{
|
||||
if (! $group->isMergeable()) {
|
||||
return new CompanyMergeSkip($group->email, CompanyMergeSkip::NotMergeable, [$group->effectiveClassification()->value]);
|
||||
}
|
||||
|
||||
$members ??= $this->membersOf($group->email);
|
||||
|
||||
// 410-entfernte Firmen bleiben außen vor: Ihre Meldungen in den
|
||||
// Überlebenden zu heben, würde bewusst entfernte Inhalte wieder
|
||||
// sichtbar machen.
|
||||
[$excluded, $candidates] = $members->partition(fn (Company $company): bool => $company->gone_at !== null);
|
||||
|
||||
if ($candidates->count() < 2) {
|
||||
return new CompanyMergeSkip($group->email, CompanyMergeSkip::NothingToMerge, $excluded->isNotEmpty() ? ['410-entfernt: '.$excluded->count()] : []);
|
||||
}
|
||||
|
||||
$ranked = $this->rankSurvivors($candidates);
|
||||
$portals = $candidates->map(fn (Company $company): string => $company->portal->value)->unique()->values()->all();
|
||||
$blocked = [];
|
||||
|
||||
foreach ($ranked as $survivor) {
|
||||
$targetPortal = $this->targetPortal($survivor, $portals);
|
||||
|
||||
if ($targetPortal === Portal::Both && $survivor->portal !== Portal::Both
|
||||
&& $this->slugTakenElsewhere($survivor, $candidates)) {
|
||||
$blocked[] = $survivor->slug;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$losers = $ranked->reject(fn (Company $company): bool => $company->is($survivor))->values();
|
||||
|
||||
return new CompanyMergePlan(
|
||||
email: $group->email,
|
||||
survivor: $survivor,
|
||||
targetPortal: $targetPortal,
|
||||
losers: $losers->all(),
|
||||
excluded: $excluded->values()->all(),
|
||||
pressReleases: $this->pressReleasesOf($losers->modelKeys())->count(),
|
||||
);
|
||||
}
|
||||
|
||||
return new CompanyMergeSkip($group->email, CompanyMergeSkip::SlugCollision, array_unique($blocked));
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt den Plan aus. Alles in einer Transaktion je Adresse: Entweder die
|
||||
* Firma ist zusammengeführt und jede Bewegung protokolliert, oder nichts
|
||||
* ist passiert.
|
||||
*
|
||||
* @param bool $flushCaches false, wenn der Aufrufer viele Adressen nacheinander verarbeitet und einmal am Ende leert
|
||||
*/
|
||||
public function merge(CompanyMergePlan $plan, string $batch, bool $flushCaches = true): CompanyMerge
|
||||
{
|
||||
$merge = DB::transaction(function () use ($plan, $batch): CompanyMerge {
|
||||
$survivor = $plan->survivor;
|
||||
|
||||
$merge = CompanyMerge::query()->create([
|
||||
'batch' => $batch,
|
||||
'email' => $plan->email,
|
||||
'survivor_company_id' => $survivor->getKey(),
|
||||
'survivor_portal_before' => $survivor->portal->value,
|
||||
'merged_companies_count' => count($plan->losers),
|
||||
'moved_press_releases_count' => 0,
|
||||
]);
|
||||
|
||||
$moved = 0;
|
||||
$fills = [];
|
||||
|
||||
foreach ($plan->losers as $loser) {
|
||||
$moved += $this->moveReferences($merge, $loser, $survivor);
|
||||
$this->moveUserLinks($merge, $loser, $survivor);
|
||||
$this->movePaymentOptions($merge, $loser, $survivor);
|
||||
$fills += $this->collectFills($merge, $loser, $survivor, array_keys($fills));
|
||||
|
||||
$this->record($merge, CompanyMergeSubject::Company, $loser->getKey(), $loser);
|
||||
|
||||
// Query-Builder statt $loser->delete(): Der Observer würde je
|
||||
// Firma den Admin-Cache leeren, bei zehntausenden Firmen je
|
||||
// Lauf. Der Aufrufer leert einmal am Ende.
|
||||
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($loser->getKey())->update(['deleted_at' => now()]);
|
||||
}
|
||||
|
||||
$survivorChanges = $fills;
|
||||
|
||||
if ($plan->targetPortal !== $survivor->portal) {
|
||||
$survivorChanges['portal'] = $plan->targetPortal->value;
|
||||
}
|
||||
|
||||
if (! $survivor->is_active && collect($plan->losers)->contains(fn (Company $loser): bool => (bool) $loser->is_active)) {
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $plan->losers[0], 'is_active', '0');
|
||||
$survivorChanges['is_active'] = true;
|
||||
}
|
||||
|
||||
if ($survivorChanges !== []) {
|
||||
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivor->getKey())->update($survivorChanges);
|
||||
}
|
||||
|
||||
$merge->update(['moved_press_releases_count' => $moved]);
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::Correction,
|
||||
sprintf('Newsroom „%s“: %d Firma/Firmen unter %s zusammengeführt, %d Meldungen übernommen', $survivor->name, count($plan->losers), $plan->email, $moved),
|
||||
subject: $survivor,
|
||||
meta: ['company_merge_id' => $merge->getKey(), 'batch' => $batch],
|
||||
actorLabel: 'companies:merge',
|
||||
);
|
||||
|
||||
return $merge;
|
||||
});
|
||||
|
||||
if ($flushCaches) {
|
||||
$this->flushCaches();
|
||||
}
|
||||
|
||||
return $merge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt die Bewegungen rückwärts ab. Was seit der Zusammenführung NEU
|
||||
* beim Überlebenden entstanden ist, bleibt bei ihm — die Rückabwicklung
|
||||
* kennt nur, was sie selbst verschoben hat.
|
||||
*/
|
||||
public function revert(CompanyMerge $merge, bool $flushCaches = true): void
|
||||
{
|
||||
if ($merge->isReverted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$merge->loadMissing('survivor');
|
||||
|
||||
DB::transaction(function () use ($merge): void {
|
||||
$survivorId = $merge->survivor_company_id;
|
||||
$moves = $merge->moves()->orderByDesc('id')->get();
|
||||
|
||||
foreach ($moves as $move) {
|
||||
$this->undo($move, $survivorId);
|
||||
}
|
||||
|
||||
Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)
|
||||
->update(['portal' => $merge->survivor_portal_before->value]);
|
||||
|
||||
$merge->update(['reverted_at' => now()]);
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::Correction,
|
||||
sprintf('Zusammenführung unter %s rückabgewickelt (%d Firmen, %d Meldungen)', $merge->email, $merge->merged_companies_count, $merge->moved_press_releases_count),
|
||||
subject: $merge->survivor,
|
||||
meta: ['company_merge_id' => $merge->getKey(), 'batch' => $merge->batch],
|
||||
actorLabel: 'companies:unmerge',
|
||||
);
|
||||
});
|
||||
|
||||
if ($flushCaches) {
|
||||
$this->flushCaches();
|
||||
}
|
||||
}
|
||||
|
||||
public function flushCaches(): void
|
||||
{
|
||||
WebCaches::forgetPublicPages();
|
||||
app(AdminPerformanceCache::class)->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle lebenden Firmen unter dieser Adresse, portalübergreifend.
|
||||
*
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
public function membersOf(string $email): Collection
|
||||
{
|
||||
// Der Index auf `email` ist case-insensitiv (utf8mb4_unicode_ci); der
|
||||
// PHP-Filter danach ist die Rückversicherung, dass genau die Firmen
|
||||
// kommen, die die Klassifizierung unter diesem Schlüssel gezählt hat.
|
||||
return Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->where('email', $email)
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->filter(fn (Company $company): bool => Company::emailKey($company->email) === $email)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kandidaten nach Regel 1: meiste jemals veröffentlichte Meldungen, bei
|
||||
* Gleichstand die ältere (kleinere ID). „Jemals" schließt Meldungen mit
|
||||
* Aufrufen ein — der Altbestand trägt Hunderttausende offline genommene
|
||||
* Meldungen unter `rejected`, die online waren.
|
||||
*
|
||||
* @param Collection<int, Company> $candidates
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
private function rankSurvivors(Collection $candidates): Collection
|
||||
{
|
||||
$published = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('company_id', $candidates->modelKeys())
|
||||
->where(fn (Builder $query) => $query->where('status', 'published')->orWhere('hits', '>', 0))
|
||||
->selectRaw('company_id, count(*) as n')
|
||||
->groupBy('company_id')
|
||||
->pluck('n', 'company_id');
|
||||
|
||||
return $candidates
|
||||
->sortBy([
|
||||
fn (Company $a, Company $b): int => ($published[$b->getKey()] ?? 0) <=> ($published[$a->getKey()] ?? 0),
|
||||
fn (Company $a, Company $b): int => $a->getKey() <=> $b->getKey(),
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $portals Portale aller Kandidaten
|
||||
*/
|
||||
private function targetPortal(Company $survivor, array $portals): Portal
|
||||
{
|
||||
if ($survivor->portal === Portal::Both || count($portals) > 1) {
|
||||
return Portal::Both;
|
||||
}
|
||||
|
||||
return $survivor->portal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trägt eine fremde, lebende Firma auf einem anderen Portal denselben
|
||||
* Slug? Dann fände die öffentliche Auflösung (`portal IN (eigenes, both)`)
|
||||
* nach dem Wechsel auf `both` zwei Firmen. Die eigenen Gruppenmitglieder
|
||||
* zählen nicht — die sind danach soft-deleted.
|
||||
*
|
||||
* @param Collection<int, Company> $group
|
||||
*/
|
||||
private function slugTakenElsewhere(Company $survivor, Collection $group): bool
|
||||
{
|
||||
// Die anderen Portale ausdrücklich aufzählen statt `portal != ?`:
|
||||
// Nur so greift der Unique-Index (portal, slug) — der Unterschied ist
|
||||
// 24 ms gegen 0 ms je Adresse, bei vierzehntausend Adressen sechs
|
||||
// Minuten gegen eine Sekunde.
|
||||
$otherPortals = array_values(array_diff(
|
||||
array_map(fn (Portal $portal): string => $portal->value, Portal::cases()),
|
||||
[$survivor->portal->value],
|
||||
));
|
||||
|
||||
return Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', $otherPortals)
|
||||
->where('slug', $survivor->slug)
|
||||
->whereKeyNot($group->modelKeys())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $companyIds
|
||||
* @return Builder<PressRelease>
|
||||
*/
|
||||
private function pressReleasesOf(array $companyIds): Builder
|
||||
{
|
||||
return PressRelease::query()->withoutGlobalScopes()->whereIn('company_id', $companyIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldungen, Kontakte, KI-Erklärungen und Konversionsereignisse per
|
||||
* `company_id` zum Überlebenden. Jede ID wird vorher protokolliert; das
|
||||
* Update läuft über den Query-Builder (keine Model-Events — die
|
||||
* Suchspiegelung enthält keine Firmendaten, ein Reindex ist unnötig).
|
||||
*
|
||||
* @return int verschobene Meldungen
|
||||
*/
|
||||
private function moveReferences(CompanyMerge $merge, Company $loser, Company $survivor): int
|
||||
{
|
||||
$moved = 0;
|
||||
|
||||
$tables = [
|
||||
[CompanyMergeSubject::PressRelease, $this->pressReleasesOf([$loser->getKey()])],
|
||||
[CompanyMergeSubject::Contact, Contact::query()->withoutGlobalScopes()->where('company_id', $loser->getKey())],
|
||||
[CompanyMergeSubject::AiDeclaration, PressReleaseAiDeclaration::query()->where('company_id', $loser->getKey())],
|
||||
[CompanyMergeSubject::ConversionEvent, ConversionEvent::query()->where('company_id', $loser->getKey())],
|
||||
];
|
||||
|
||||
foreach ($tables as [$subject, $query]) {
|
||||
$ids = $query->pluck('id')->all();
|
||||
|
||||
if ($ids === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_chunk($ids, 500) as $chunk) {
|
||||
CompanyMergeMove::query()->insert(array_map(fn (int $id): array => [
|
||||
'company_merge_id' => $merge->getKey(),
|
||||
'subject' => $subject->value,
|
||||
'subject_id' => $id,
|
||||
'from_company_id' => $loser->getKey(),
|
||||
'field' => null,
|
||||
'previous_value' => null,
|
||||
'dropped' => false,
|
||||
], $chunk));
|
||||
}
|
||||
|
||||
$query->whereIn('id', $ids)->update(['company_id' => $survivor->getKey()]);
|
||||
|
||||
if ($subject === CompanyMergeSubject::PressRelease) {
|
||||
$moved = count($ids);
|
||||
}
|
||||
}
|
||||
|
||||
return $moved;
|
||||
}
|
||||
|
||||
/**
|
||||
* `company_user` als Vereinigung: Wer nur bei der wegfallenden Firma
|
||||
* stand, wandert; wer schon beim Überlebenden steht, wird dort nicht
|
||||
* doppelt — sein alter Eintrag fällt weg und wird als `dropped` mit Rolle
|
||||
* protokolliert, damit die Rückabwicklung ihn neu anlegt.
|
||||
*/
|
||||
private function moveUserLinks(CompanyMerge $merge, Company $loser, Company $survivor): void
|
||||
{
|
||||
$existing = DB::table('company_user')->where('company_id', $survivor->getKey())->pluck('user_id')->flip();
|
||||
|
||||
foreach (DB::table('company_user')->where('company_id', $loser->getKey())->get() as $link) {
|
||||
$dropped = isset($existing[$link->user_id]);
|
||||
$this->record($merge, CompanyMergeSubject::CompanyUser, (int) $link->user_id, $loser, null, (string) $link->role, $dropped);
|
||||
|
||||
$query = DB::table('company_user')->where('company_id', $loser->getKey())->where('user_id', $link->user_id);
|
||||
$dropped ? $query->delete() : $query->update(['company_id' => $survivor->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function movePaymentOptions(CompanyMerge $merge, Company $loser, Company $survivor): void
|
||||
{
|
||||
$existing = DB::table('user_payment_option_company')->where('company_id', $survivor->getKey())->pluck('user_payment_option_id')->flip();
|
||||
|
||||
foreach (DB::table('user_payment_option_company')->where('company_id', $loser->getKey())->get() as $link) {
|
||||
$dropped = isset($existing[$link->user_payment_option_id]);
|
||||
$this->record($merge, CompanyMergeSubject::PaymentOption, (int) $link->user_payment_option_id, $loser, null, (string) (int) $link->is_active, $dropped);
|
||||
|
||||
$query = DB::table('user_payment_option_company')->where('company_id', $loser->getKey())->where('user_payment_option_id', $link->user_payment_option_id);
|
||||
$dropped ? $query->delete() : $query->update(['company_id' => $survivor->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leere Felder des Überlebenden aus dieser wegfallenden Firma. Erste
|
||||
* Firma, die einen Wert hat, gewinnt — die Reihenfolge ist die Rangfolge
|
||||
* aus Regel 1.
|
||||
*
|
||||
* @param list<string> $alreadyFilled
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function collectFills(CompanyMerge $merge, Company $loser, Company $survivor, array $alreadyFilled): array
|
||||
{
|
||||
$fills = [];
|
||||
|
||||
foreach (self::FillableFields as $field) {
|
||||
if (in_array($field, $alreadyFilled, true) || ! blank($survivor->getRawOriginal($field)) || blank($loser->getRawOriginal($field))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->record($merge, CompanyMergeSubject::Field, null, $loser, $field, $survivor->getRawOriginal($field));
|
||||
$fills[$field] = $loser->getRawOriginal($field);
|
||||
}
|
||||
|
||||
return $fills;
|
||||
}
|
||||
|
||||
private function record(CompanyMerge $merge, CompanyMergeSubject $subject, ?int $subjectId, Company $from, ?string $field = null, ?string $previous = null, bool $dropped = false): void
|
||||
{
|
||||
CompanyMergeMove::query()->create([
|
||||
'company_merge_id' => $merge->getKey(),
|
||||
'subject' => $subject,
|
||||
'subject_id' => $subjectId,
|
||||
'from_company_id' => $from->getKey(),
|
||||
'field' => $field,
|
||||
'previous_value' => $previous,
|
||||
'dropped' => $dropped,
|
||||
]);
|
||||
}
|
||||
|
||||
private function undo(CompanyMergeMove $move, int $survivorId): void
|
||||
{
|
||||
$from = $move->from_company_id;
|
||||
|
||||
match ($move->subject) {
|
||||
CompanyMergeSubject::PressRelease => PressRelease::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::Contact => Contact::query()->withoutGlobalScopes()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::AiDeclaration => PressReleaseAiDeclaration::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::ConversionEvent => ConversionEvent::query()->whereKey($move->subject_id)->where('company_id', $survivorId)->update(['company_id' => $from]),
|
||||
CompanyMergeSubject::CompanyUser => $this->undoPivot('company_user', 'user_id', $move, $survivorId, ['role' => $move->previous_value]),
|
||||
CompanyMergeSubject::PaymentOption => $this->undoPivot('user_payment_option_company', 'user_payment_option_id', $move, $survivorId, ['is_active' => (bool) $move->previous_value]),
|
||||
CompanyMergeSubject::Field => Company::query()->withoutGlobalScope(PortalScope::class)->whereKey($survivorId)->update([(string) $move->field => $move->previous_value]),
|
||||
CompanyMergeSubject::Company => Company::withTrashed()->withoutGlobalScope(PortalScope::class)->whereKey($from)->update(['deleted_at' => null]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private function undoPivot(string $table, string $keyColumn, CompanyMergeMove $move, int $survivorId, array $extra): void
|
||||
{
|
||||
if ($move->dropped) {
|
||||
DB::table($table)->insertOrIgnore(['company_id' => $move->from_company_id, $keyColumn => $move->subject_id, 'created_at' => now(), 'updated_at' => now()] + $extra);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table($table)->where('company_id', $survivorId)->where($keyColumn, $move->subject_id)->update(['company_id' => $move->from_company_id]);
|
||||
}
|
||||
}
|
||||
26
app/Services/Company/CompanyMergeSkip.php
Normal file
26
app/Services/Company/CompanyMergeSkip.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Company;
|
||||
|
||||
/**
|
||||
* Eine Adresse, die im Lauf übersprungen wurde, mit dem Grund. Gründe sind
|
||||
* kurze, feste Schlüssel, damit der Lauf sie zählen und der Mensch die Liste
|
||||
* danach filtern kann.
|
||||
*/
|
||||
final readonly class CompanyMergeSkip
|
||||
{
|
||||
public const NotMergeable = 'nicht_zusammenfuehrbar';
|
||||
|
||||
public const NothingToMerge = 'nichts_zusammenzufuehren';
|
||||
|
||||
public const SlugCollision = 'slug_kollision';
|
||||
|
||||
/**
|
||||
* @param list<string> $details
|
||||
*/
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public string $reason,
|
||||
public array $details = [],
|
||||
) {}
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
namespace App\Services\Web;
|
||||
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyMergeMove;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Support\WebPageCache;
|
||||
|
|
@ -67,6 +69,50 @@ class NewsroomDetailPageData
|
|||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug einer Firma, die in einer Zusammenführung (Roadmap 1a.3) in einer
|
||||
* anderen aufgegangen ist → Slug des Überlebenden, sofern der abweicht.
|
||||
* Die alte URL ist öffentlich und verlinkt; sie führt per 301 weiter statt
|
||||
* ins Leere. Bei gleichem Slug kommt dieser Zweig gar nicht vor — dann hat
|
||||
* die Auflösung oben den Überlebenden (`both`) schon gefunden.
|
||||
*
|
||||
* @param list<string> $portalValues
|
||||
*/
|
||||
private function mergedInto(array $portalValues, string $slug): ?string
|
||||
{
|
||||
$mergedIds = Company::onlyTrashed()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', $portalValues)
|
||||
->where('slug', $slug)
|
||||
->pluck('id');
|
||||
|
||||
if ($mergedIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$survivorId = CompanyMergeMove::query()
|
||||
->where('subject', CompanyMergeSubject::Company->value)
|
||||
->whereIn('subject_id', $mergedIds)
|
||||
->whereHas('merge', fn (Builder $query) => $query->whereNull('reverted_at'))
|
||||
->with('merge:id,survivor_company_id')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->merge
|
||||
?->survivor_company_id;
|
||||
|
||||
if ($survivorId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($survivorId)
|
||||
->where('is_active', true)
|
||||
->value('slug');
|
||||
|
||||
return $target !== null && $target !== $slug ? $target : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
@ -84,6 +130,12 @@ class NewsroomDetailPageData
|
|||
->first();
|
||||
|
||||
if (! $company) {
|
||||
$target = $this->mergedInto($portalValues, $slug);
|
||||
|
||||
if ($target !== null) {
|
||||
return ['redirect' => $target];
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Protokoll der Newsroom-Zusammenführung (Roadmap 1a.3).
|
||||
*
|
||||
* `company_merges` ist ein Vorgang je Adresse: welche Firma geblieben ist und
|
||||
* welches Portal sie vorher trug. `company_merge_moves` hält je verschobenem
|
||||
* Datensatz fest, woher er kam — das ist die gespeicherte Ursprungszuordnung,
|
||||
* über die `companies:unmerge` den Vorgang exakt rückabwickelt.
|
||||
*
|
||||
* Bewusst zwei eigene Tabellen statt einer Spalte `merged_from_company_id` an
|
||||
* `press_releases`: Die ist über 1 GB groß, ein ADD COLUMN dort nur für einen
|
||||
* einmaligen Vorgang lohnt nicht, und Kontakte, Nutzer-Verknüpfungen und
|
||||
* Zahlungsoptionen bräuchten dieselbe Spalte noch einmal. Hier landen alle in
|
||||
* einer Liste.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('company_merges', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('batch', 36)->index();
|
||||
$table->string('email', 190)->index();
|
||||
$table->foreignId('survivor_company_id')->constrained('companies')->restrictOnDelete();
|
||||
$table->string('survivor_portal_before', 32);
|
||||
$table->unsignedInteger('merged_companies_count');
|
||||
$table->unsignedInteger('moved_press_releases_count');
|
||||
$table->timestamp('reverted_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('company_merge_moves', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('company_merge_id')->constrained('company_merges')->cascadeOnDelete();
|
||||
$table->string('subject', 32);
|
||||
$table->unsignedBigInteger('subject_id')->nullable();
|
||||
$table->foreignId('from_company_id')->constrained('companies')->restrictOnDelete();
|
||||
$table->string('field', 64)->nullable();
|
||||
$table->text('previous_value')->nullable();
|
||||
$table->boolean('dropped')->default(false);
|
||||
|
||||
$table->index(['subject', 'subject_id'], 'company_merge_moves_subject_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('company_merge_moves');
|
||||
Schema::dropIfExists('company_merges');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -636,12 +636,6 @@ parameters:
|
|||
count: 1
|
||||
path: app/Models/Category.php
|
||||
|
||||
-
|
||||
message: '#^PHPDoc tag @var with type Illuminate\\Database\\Eloquent\\Model is not subtype of native type \$this\(App\\Models\\Company\)\.$#'
|
||||
identifier: varTag.nativeType
|
||||
count: 1
|
||||
path: app/Models/Company.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access on non\-nullable type App\\Enums\\Portal\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
|
|
|
|||
325
tests/Feature/CompanyMergeTest.php
Normal file
325
tests/Feature/CompanyMergeTest.php
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CompanyAddressClass;
|
||||
use App\Enums\CompanyMergeSubject;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\CompanyEmailGroup;
|
||||
use App\Models\CompanyMerge;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Company\CompanyMergePlan;
|
||||
use App\Services\Company\CompanyMergeService;
|
||||
use App\Services\Company\CompanyMergeSkip;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Zusammenführung im Bestand (Roadmap 1a.3): rückabwickelbar, mit
|
||||
* gespeicherter Ursprungszuordnung. Entschieden am 15.09.2026: Es bleibt die
|
||||
* Firma mit den meisten Meldungen, alles wandert per company_id, die
|
||||
* wegfallende Firma wird soft-deleted und ihr Slug führt per 301 weiter.
|
||||
*/
|
||||
function mergeGroup(string $email, CompanyAddressClass $class = CompanyAddressClass::Duplicate, ?CompanyAddressClass $manual = null): CompanyEmailGroup
|
||||
{
|
||||
return CompanyEmailGroup::query()->create([
|
||||
'email' => Company::emailKey($email),
|
||||
'classification' => $class,
|
||||
'manual_classification' => $manual,
|
||||
'companies_count' => 2,
|
||||
'distinct_names' => 1,
|
||||
'published_companies_count' => 1,
|
||||
'threshold' => 3,
|
||||
'classified_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
function mergeMember(string $email, Portal $portal, int $published = 0, array $attributes = []): Company
|
||||
{
|
||||
$company = Company::factory()->create(['email' => $email, 'portal' => $portal->value, 'name' => 'Muster GmbH'] + $attributes);
|
||||
|
||||
PressRelease::factory()->published()->forPortal($portal)->count($published)->create(['company_id' => $company->id]);
|
||||
|
||||
return $company;
|
||||
}
|
||||
|
||||
function planFor(string $email): CompanyMergePlan|CompanyMergeSkip
|
||||
{
|
||||
return app(CompanyMergeService::class)->plan(CompanyEmailGroup::forEmail($email));
|
||||
}
|
||||
|
||||
test('the company with more publications survives and becomes both when halves lie on different portals', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$pe = mergeMember('info@muster.de', Portal::Presseecho, 2);
|
||||
$b24 = mergeMember('info@muster.de', Portal::Businessportal24, 5);
|
||||
|
||||
$plan = planFor('info@muster.de');
|
||||
|
||||
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
|
||||
->and($plan->survivor->is($b24))->toBeTrue()
|
||||
->and($plan->targetPortal)->toBe(Portal::Both)
|
||||
->and($plan->pressReleases)->toBe(2);
|
||||
|
||||
$merge = app(CompanyMergeService::class)->merge($plan, 'test-batch');
|
||||
|
||||
// Die Meldungen behalten ihr Portal — nur die Firma wird portalübergreifend.
|
||||
expect($b24->fresh()->portal)->toBe(Portal::Both)
|
||||
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->count())->toBe(7)
|
||||
->and(PressRelease::withoutGlobalScopes()->where('company_id', $b24->id)->where('portal', 'presseecho')->count())->toBe(2)
|
||||
->and(Company::withTrashed()->find($pe->id)->trashed())->toBeTrue()
|
||||
->and($merge->moved_press_releases_count)->toBe(2)
|
||||
->and($merge->moves()->where('subject', CompanyMergeSubject::PressRelease->value)->count())->toBe(2)
|
||||
->and($merge->moves()->where('subject', CompanyMergeSubject::Company->value)->value('subject_id'))->toBe($pe->id);
|
||||
});
|
||||
|
||||
test('on a tie the older company survives', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$older = mergeMember('info@muster.de', Portal::Presseecho, 1);
|
||||
mergeMember('info@muster.de', Portal::Presseecho, 1);
|
||||
|
||||
$plan = planFor('info@muster.de');
|
||||
|
||||
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
|
||||
->and($plan->survivor->is($older))->toBeTrue()
|
||||
->and($plan->targetPortal)->toBe(Portal::Presseecho);
|
||||
});
|
||||
|
||||
test('releases taken offline in the legacy system count for the survivor choice', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$fresh = mergeMember('info@muster.de', Portal::Presseecho, 1);
|
||||
$legacy = mergeMember('info@muster.de', Portal::Businessportal24);
|
||||
PressRelease::factory()->count(3)->create(['company_id' => $legacy->id, 'status' => 'rejected', 'hits' => 50]);
|
||||
|
||||
expect(planFor('info@muster.de')->survivor->is($legacy))->toBeTrue();
|
||||
});
|
||||
|
||||
test('empty profile fields are filled from the merged company, filled ones stay', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 3, ['website' => null, 'phone' => '030 1', 'logo_path' => null]);
|
||||
mergeMember('info@muster.de', Portal::Businessportal24, 0, ['website' => 'https://muster.de', 'phone' => '030 2', 'logo_path' => 'logos/muster.png']);
|
||||
|
||||
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
|
||||
|
||||
$survivor->refresh();
|
||||
|
||||
expect($survivor->website)->toBe('https://muster.de')
|
||||
->and($survivor->logo_path)->toBe('logos/muster.png')
|
||||
->and($survivor->phone)->toBe('030 1')
|
||||
->and($merge->moves()->where('subject', CompanyMergeSubject::Field->value)->pluck('field')->sort()->values()->all())->toBe(['logo_path', 'website']);
|
||||
});
|
||||
|
||||
test('user links become the union without duplicates and contacts move along', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1);
|
||||
$loser = mergeMember('info@muster.de', Portal::Businessportal24);
|
||||
$shared = User::factory()->create();
|
||||
$only = User::factory()->create();
|
||||
DB::table('company_user')->insert([
|
||||
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
|
||||
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
|
||||
['company_id' => $loser->id, 'user_id' => $only->id, 'role' => 'member'],
|
||||
]);
|
||||
$contact = Contact::factory()->create(['company_id' => $loser->id]);
|
||||
|
||||
$merge = app(CompanyMergeService::class)->merge(planFor('info@muster.de'), 'b');
|
||||
|
||||
expect(DB::table('company_user')->where('company_id', $survivor->id)->pluck('user_id')->sort()->values()->all())->toBe(collect([$shared->id, $only->id])->sort()->values()->all())
|
||||
->and(DB::table('company_user')->where('company_id', $loser->id)->count())->toBe(0)
|
||||
->and($contact->fresh()->company_id)->toBe($survivor->id)
|
||||
->and($merge->moves()->where('subject', CompanyMergeSubject::CompanyUser->value)->where('dropped', true)->value('subject_id'))->toBe($shared->id);
|
||||
});
|
||||
|
||||
test('unmerge restores exactly what was moved and leaves what came later', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$survivor = mergeMember('info@muster.de', Portal::Presseecho, 1, ['website' => null]);
|
||||
$loser = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['website' => 'https://muster.de']);
|
||||
$loserReleases = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->pluck('id');
|
||||
$shared = User::factory()->create();
|
||||
DB::table('company_user')->insert([
|
||||
['company_id' => $survivor->id, 'user_id' => $shared->id, 'role' => 'owner'],
|
||||
['company_id' => $loser->id, 'user_id' => $shared->id, 'role' => 'member'],
|
||||
]);
|
||||
|
||||
$service = app(CompanyMergeService::class);
|
||||
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
|
||||
|
||||
// Nach der Zusammenführung entsteht eine neue Meldung beim Überlebenden.
|
||||
$later = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(['company_id' => $survivor->id]);
|
||||
|
||||
$service->revert($merge);
|
||||
|
||||
$survivor->refresh();
|
||||
|
||||
expect($survivor->portal)->toBe(Portal::Presseecho)
|
||||
->and($survivor->website)->toBeNull()
|
||||
->and(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse()
|
||||
->and(PressRelease::withoutGlobalScopes()->whereIn('id', $loserReleases)->pluck('company_id')->unique()->all())->toBe([$loser->id])
|
||||
->and($later->fresh()->company_id)->toBe($survivor->id)
|
||||
->and(DB::table('company_user')->where('company_id', $loser->id)->where('user_id', $shared->id)->value('role'))->toBe('member')
|
||||
->and(DB::table('company_user')->where('company_id', $survivor->id)->count())->toBe(1)
|
||||
->and($merge->fresh()->isReverted())->toBeTrue();
|
||||
|
||||
// Ein zweites Mal tut nichts.
|
||||
$service->revert($merge->fresh());
|
||||
expect(Company::withoutGlobalScopes()->find($loser->id)->trashed())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a slug taken by a foreign company on the other portal skips the address', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
// Beide Hälften tragen verschiedene Slugs, und jeder ist auf dem jeweils
|
||||
// anderen Portal von einer fremden Firma belegt — keiner kann `both` werden.
|
||||
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster-a']);
|
||||
mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-b']);
|
||||
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster-a']);
|
||||
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'presseecho', 'slug' => 'muster-b']);
|
||||
|
||||
$plan = planFor('info@muster.de');
|
||||
|
||||
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
|
||||
->and($plan->reason)->toBe(CompanyMergeSkip::SlugCollision)
|
||||
->and(Company::withoutGlobalScopes()->where('email', 'info@muster.de')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('when the best candidate collides but another does not, the other survives', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
mergeMember('info@muster.de', Portal::Presseecho, 3, ['slug' => 'muster']);
|
||||
$free = mergeMember('info@muster.de', Portal::Businessportal24, 1, ['slug' => 'muster-gmbh']);
|
||||
// „muster" ist auf B24 fremd belegt → die PE-Hälfte kann nicht `both` werden,
|
||||
// die B24-Hälfte mit „muster-gmbh" schon.
|
||||
Company::factory()->create(['email' => 'anders@fremd.de', 'portal' => 'businessportal24', 'slug' => 'muster']);
|
||||
|
||||
$plan = planFor('info@muster.de');
|
||||
|
||||
expect($plan)->toBeInstanceOf(CompanyMergePlan::class)
|
||||
->and($plan->survivor->is($free))->toBeTrue();
|
||||
});
|
||||
|
||||
test('only mergeable classes are touched and a manual decision overrides', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('agentur@sammel.de', CompanyAddressClass::CollectionAddress);
|
||||
mergeGroup('doch@agentur.de', CompanyAddressClass::CollectionAddress, manual: CompanyAddressClass::Duplicate);
|
||||
mergeGroup('nicht@dublette.de', CompanyAddressClass::Duplicate, manual: CompanyAddressClass::CollectionAddress);
|
||||
foreach (['agentur@sammel.de', 'doch@agentur.de', 'nicht@dublette.de'] as $email) {
|
||||
mergeMember($email, Portal::Presseecho, 1);
|
||||
mergeMember($email, Portal::Businessportal24);
|
||||
}
|
||||
|
||||
$this->artisan('companies:merge')->assertSuccessful();
|
||||
|
||||
expect(CompanyMerge::query()->pluck('email')->all())->toBe(['doch@agentur.de'])
|
||||
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'agentur@sammel.de')->count())->toBe(2)
|
||||
->and(Company::withoutGlobalScopes()->whereNull('deleted_at')->where('email', 'nicht@dublette.de')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('companies removed with 410 stay out of the merge', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
mergeMember('info@muster.de', Portal::Presseecho, 1);
|
||||
$gone = mergeMember('info@muster.de', Portal::Businessportal24, 4, ['gone_at' => now()]);
|
||||
|
||||
$plan = planFor('info@muster.de');
|
||||
|
||||
// Nur eine lebende Firma übrig → nichts zu tun; die entfernte bleibt entfernt.
|
||||
expect($plan)->toBeInstanceOf(CompanyMergeSkip::class)
|
||||
->and($plan->reason)->toBe(CompanyMergeSkip::NothingToMerge)
|
||||
->and($gone->fresh()->gone_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('dry run and limit write nothing and count correctly', function () {
|
||||
/** @var TestCase $this */
|
||||
foreach (['a@muster.de', 'b@muster.de', 'c@muster.de'] as $email) {
|
||||
mergeGroup($email);
|
||||
mergeMember($email, Portal::Presseecho, 1);
|
||||
mergeMember($email, Portal::Businessportal24);
|
||||
}
|
||||
|
||||
$this->artisan('companies:merge', ['--dry-run' => true])
|
||||
->expectsOutputToContain('[Dry-Run] 3 Adressen')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(CompanyMerge::query()->count())->toBe(0)
|
||||
->and(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0);
|
||||
|
||||
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
|
||||
|
||||
expect(CompanyMerge::query()->count())->toBe(2)
|
||||
->and(CompanyMerge::query()->distinct()->count('batch'))->toBe(1);
|
||||
|
||||
// Der zweite Lauf holt den Rest — die erledigten Adressen zählen nicht
|
||||
// gegen das Limit.
|
||||
$this->artisan('companies:merge', ['--limit' => 2])->assertSuccessful();
|
||||
|
||||
expect(CompanyMerge::query()->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('unmerge by batch reverts every merge of that run', function () {
|
||||
/** @var TestCase $this */
|
||||
foreach (['a@muster.de', 'b@muster.de'] as $email) {
|
||||
mergeGroup($email);
|
||||
mergeMember($email, Portal::Presseecho, 1);
|
||||
mergeMember($email, Portal::Businessportal24);
|
||||
}
|
||||
|
||||
$this->artisan('companies:merge')->assertSuccessful();
|
||||
$batch = CompanyMerge::query()->value('batch');
|
||||
|
||||
$this->artisan('companies:unmerge', ['--batch' => $batch])
|
||||
->expectsOutputToContain('2 Vorgänge rückabgewickelt')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Company::withoutGlobalScopes()->whereNotNull('deleted_at')->count())->toBe(0)
|
||||
->and(CompanyMerge::query()->whereNull('reverted_at')->count())->toBe(0)
|
||||
->and(Company::withoutGlobalScopes()->where('portal', 'both')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('the old newsroom slug redirects permanently to the survivor', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
$survivor = mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster-gmbh']);
|
||||
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
|
||||
$release = PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->first();
|
||||
$release->update(['published_at' => now()->subDay()]);
|
||||
|
||||
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
|
||||
|
||||
$service = app(CompanyMergeService::class);
|
||||
$merge = $service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
|
||||
|
||||
// Alte URL → 301 auf den Überlebenden, der auf diesem Portal über `both`
|
||||
// erreichbar ist und die Meldung dieses Portals zeigt.
|
||||
$this->get('https://presseecho.test/de/newsroom/muster')
|
||||
->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh')
|
||||
->assertStatus(301);
|
||||
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertOk()->assertSeeText($release->title);
|
||||
|
||||
// Nach der Rückabwicklung antwortet die alte URL wieder selbst.
|
||||
$service->revert($merge);
|
||||
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
|
||||
$this->get('https://presseecho.test/de/newsroom/muster-gmbh')->assertNotFound();
|
||||
});
|
||||
|
||||
test('the same slug on both portals needs no redirect', function () {
|
||||
/** @var TestCase $this */
|
||||
mergeGroup('info@muster.de');
|
||||
mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster']);
|
||||
$loser = mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
|
||||
PressRelease::withoutGlobalScopes()->where('company_id', $loser->id)->update(['published_at' => now()->subDay()]);
|
||||
|
||||
$service = app(CompanyMergeService::class);
|
||||
$service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
|
||||
|
||||
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
|
||||
$this->get('https://businessportal24.test/de/newsroom/muster')->assertOk();
|
||||
});
|
||||
|
|
@ -97,6 +97,21 @@ test('company slug is unique within portal scope', function () {
|
|||
expect($other)->toBe('acme-gmbh');
|
||||
});
|
||||
|
||||
test('a cross-portal company blocks its slug on every portal and needs it free everywhere', function () {
|
||||
/** @var TestCase $this */
|
||||
// Seit der Zusammenführung (Roadmap 1a.3) gibt es Firmen mit `both`; die
|
||||
// öffentliche Auflösung fragt `portal IN (eigenes, both)`. Ein neuer
|
||||
// PE-Slug darf deshalb nicht mit einer both-Firma zusammenfallen — sonst
|
||||
// fände die Newsroom-URL zwei Firmen.
|
||||
Company::factory()->create(['portal' => Portal::Both->value, 'slug' => 'acme-gmbh']);
|
||||
Company::factory()->create(['portal' => Portal::Presseecho->value, 'slug' => 'beta-gmbh']);
|
||||
|
||||
expect((new Company)->generateUniqueSlug('Acme GmbH', ['portal' => Portal::Presseecho->value]))->toBe('acme-gmbh-2')
|
||||
->and((new Company)->generateUniqueSlug('Acme GmbH', ['portal' => Portal::Businessportal24->value]))->toBe('acme-gmbh-2')
|
||||
->and((new Company)->generateUniqueSlug('Beta GmbH', ['portal' => Portal::Both->value]))->toBe('beta-gmbh-2')
|
||||
->and((new Company)->generateUniqueSlug('Beta GmbH', ['portal' => Portal::Businessportal24->value]))->toBe('beta-gmbh');
|
||||
});
|
||||
|
||||
test('blank source falls back to model defined fallback', function () {
|
||||
/** @var TestCase $this */
|
||||
$prSlug = (new PressRelease)->generateUniqueSlug('---', [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue