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>
This commit is contained in:
Kevin Adametz 2026-09-15 09:10:54 +00:00
parent 1368e2cf07
commit 202cf340f9
4 changed files with 151 additions and 5 deletions

View file

@ -0,0 +1,115 @@
<?php
namespace App\Console\Commands;
use App\Enums\CompanyMergeSubject;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\CompanyMerge;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
/**
* Zeigt, was eine Zusammenführung (Roadmap 1a.3) getan hat — je Vorgang der
* Überlebende, die aufgegangenen Firmen und die URLs zum Nachprüfen: der
* Newsroom auf beiden Portalen, die alten Slugs, die per 301 weiterführen.
*/
class ListCompanyMerges extends Command
{
protected $signature = 'companies:merges
{--batch= : Nur diesen Lauf}
{--email= : Nur diese Adresse}
{--limit=100 : Höchstens so viele Vorgänge}
{--json : Ausgabe als JSON}';
protected $description = 'Listet Zusammenführungen mit Überlebendem, aufgegangenen Firmen und Prüf-URLs.';
public function handle(): int
{
$merges = CompanyMerge::query()
->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', Company::emailKey($email)))
->with([
'survivor',
'moves' => fn ($query) => $query->where('subject', CompanyMergeSubject::Company->value),
])
->orderByDesc('id')
->limit(max(1, (int) $this->option('limit')))
->get();
if ($merges->isEmpty()) {
$this->warn('Keine Vorgänge gefunden.');
return self::SUCCESS;
}
$losers = Company::withTrashed()
->withoutGlobalScopes()
->whereIn('id', $merges->flatMap(fn (CompanyMerge $merge) => $merge->moves->pluck('subject_id'))->all())
->get(['id', 'name', 'slug', 'portal'])
->keyBy('id');
$rows = $merges->map(function (CompanyMerge $merge) use ($losers): array {
$survivor = $merge->survivor;
$gone = $merge->moves->map(fn ($move) => $losers->get($move->subject_id))->filter();
return [
'id' => $merge->getKey(),
'batch' => $merge->batch,
'email' => $merge->email,
'rueckabgewickelt' => $merge->reverted_at?->toDateTimeString(),
'bleibt' => ['id' => $survivor->getKey(), 'name' => $survivor->name, 'slug' => $survivor->slug, 'portal' => $survivor->portal->value],
'newsroom' => $this->newsroomUrls($survivor->portal, $survivor->slug),
'aufgegangen' => $gone->map(fn (Company $loser): array => [
'id' => $loser->getKey(),
'name' => $loser->name,
'slug' => $loser->slug,
'portal' => $loser->portal->value,
// Nur bei abweichendem Slug gibt es eine Weiterleitung —
// bei gleichem findet die URL den Überlebenden direkt.
'alte_url' => $loser->slug !== $survivor->slug ? $this->newsroomUrls($loser->portal, $loser->slug) : [],
])->values()->all(),
'meldungen' => $merge->moved_press_releases_count,
];
});
if ($this->option('json')) {
$this->line((string) json_encode($rows->values()->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->table(
['#', 'Adresse', 'bleibt', 'Newsroom', 'aufgegangen (alte URL → 301)', 'PM'],
$rows->map(fn (array $row): array => [
$row['id'].($row['rueckabgewickelt'] ? ' (zurück)' : ''),
$row['email'],
sprintf('#%d %s', $row['bleibt']['id'], Str::limit($row['bleibt']['name'], 32)),
implode("\n", $row['newsroom']),
implode("\n", array_map(
fn (array $loser): string => sprintf('#%d %s%s', $loser['id'], Str::limit($loser['name'], 28), $loser['alte_url'] ? "\n ".implode("\n ", $loser['alte_url']) : ' (gleicher Slug)'),
$row['aufgegangen'],
)),
$row['meldungen'],
])->all(),
);
return self::SUCCESS;
}
/**
* Newsroom-URL je Portal, auf dem die Firma erreichbar ist.
*
* @return list<string>
*/
private function newsroomUrls(Portal $portal, string $slug): array
{
$portals = $portal === Portal::Both ? ['presseecho', 'businessportal24'] : [$portal->value];
return array_map(
fn (string $key): string => rtrim((string) config("domains.domains.{$key}.url"), '/').'/de/newsroom/'.$slug,
$portals,
);
}
}

View file

@ -69,11 +69,11 @@ class MergeCompanies extends Command
$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;
}
if ($dryRun) {
continue;
}
@ -107,7 +107,9 @@ class MergeCompanies extends Command
));
if (! $dryRun) {
$this->line(sprintf(' Batch %s — Rückabwicklung: companies:unmerge --batch=%s', $batch, $batch));
$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) {

View file

@ -797,6 +797,13 @@ 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.
**Prüfen:** `companies:merges --batch=<ID>` listet je Vorgang den
Überlebenden mit seinen Newsroom-URLs auf beiden Portalen, die aufgegangenen
Firmen und deren alte URLs, die per 301 weiterführen. Nachgerüstet nach dem
ersten Live-Lauf mit `--limit=50` — der scharfe Lauf zeigte nur die Batch-ID,
und die Frage „welche 50 sind das?" war berechtigt. Der scharfe Lauf zeigt
seither auch die ersten 30 Vorgänge als Tabelle.
**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

View file

@ -12,6 +12,7 @@ use App\Models\User;
use App\Services\Company\CompanyMergePlan;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyMergeSkip;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
@ -323,3 +324,24 @@ test('the same slug on both portals needs no redirect', function () {
$this->get('https://presseecho.test/de/newsroom/muster')->assertOk();
$this->get('https://businessportal24.test/de/newsroom/muster')->assertOk();
});
test('the merge list shows survivor, merged companies and the URLs to check', function () {
/** @var TestCase $this */
mergeGroup('info@muster.de');
mergeMember('info@muster.de', Portal::Businessportal24, 2, ['slug' => 'muster-gmbh']);
mergeMember('info@muster.de', Portal::Presseecho, 1, ['slug' => 'muster']);
$this->artisan('companies:merge')->assertSuccessful();
$batch = CompanyMerge::query()->value('batch');
Artisan::call('companies:merges', ['--batch' => $batch, '--json' => true]);
$listed = json_decode(Artisan::output(), true)[0];
expect($listed['newsroom'])->toBe(['https://presseecho.test/de/newsroom/muster-gmbh', 'https://businessportal24.test/de/newsroom/muster-gmbh'])
->and($listed['aufgegangen'][0]['alte_url'])->toBe(['https://presseecho.test/de/newsroom/muster'])
->and($listed['meldungen'])->toBe(1);
$this->artisan('companies:merges', ['--email' => 'INFO@muster.de'])
->expectsOutputToContain('muster-gmbh')
->assertSuccessful();
});