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
982bd6eff4
commit
8b86b88614
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue