presseportale/app/Console/Commands/MergeCompanies.php
Kevin Adametz 235c20ce1d companies: Zusammenführungen zum Nachprüfen auflisten
companies:merges --batch= zeigt je Vorgang Überlebenden, Newsroom-URLs auf
beiden Portalen, aufgegangene Firmen und deren alte URLs (301). Der scharfe
Lauf gibt die ersten 30 Vorgänge jetzt ebenfalls als Tabelle aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:10:54 +00:00

180 lines
7.2 KiB
PHP

<?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 (count($plans) < 30) {
$plans[] = $result;
}
if ($dryRun) {
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', $batch));
$this->line(sprintf(' Prüfen: companies:merges --batch=%s', $batch));
$this->line(sprintf(' Rückabwicklung: companies:unmerge --batch=%s', $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,
];
}
}