presseportale/app/Console/Commands/VerifyLegacyImport.php
Kevin Adametz 5b8bdf4182
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
12-05-2026 Frontend dev
2026-05-12 18:32:33 +02:00

408 lines
14 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class VerifyLegacyImport extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'legacy:verify
{--portal=all : Portal (presseecho|businessportal24|all)}
{--skip-legacy : Legacy-DB-Checks überspringen}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verifiziert den Legacy-Import und schreibt einen maschinenlesbaren Report.';
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
private const ENTITY_MAP = [
'users' => [
'legacy_table' => 'sf_guard_user',
'target_table' => 'users',
'target_column' => 'legacy_id',
],
'companies' => [
'legacy_table' => 'company',
'target_table' => 'companies',
'target_column' => 'legacy_id',
],
'contacts' => [
'legacy_table' => 'contact',
'target_table' => 'contacts',
'target_column' => 'legacy_id',
],
'categories' => [
'legacy_table' => 'category',
'target_table' => 'categories',
'target_column' => 'legacy_id',
],
'press_releases' => [
'legacy_table' => 'press_release',
'target_table' => 'press_releases',
'target_column' => 'legacy_id',
],
];
/**
* Execute the console command.
*/
public function handle(): int
{
$portalOption = (string) $this->option('portal');
$skipLegacy = (bool) $this->option('skip-legacy');
$portals = $portalOption === 'all'
? array_keys(self::PORTAL_CONNECTIONS)
: [$portalOption];
if (array_diff($portals, array_keys(self::PORTAL_CONNECTIONS)) !== []) {
$this->error("Unbekanntes Portal: {$portalOption}");
return self::FAILURE;
}
$report = [
'generated_at' => now()->toIso8601String(),
'portal' => $portalOption,
'skip_legacy' => $skipLegacy,
'checks' => [],
'summary' => [
'failures' => 0,
'warnings' => 0,
],
];
$this->info('Legacy-Import-Verifikation');
$this->newLine();
if (! $skipLegacy) {
$this->verifyLegacyCounts($report, $portals);
$this->verifyLegacyInvoices($report, $portals);
} else {
$this->warn('Legacy-DB-Checks werden übersprungen.');
}
$this->verifyImportMapTargets($report, $portals);
$this->verifyTargetForeignKeys($report);
$this->verifyUserMerges($report);
$this->verifyGrandfatheredSubscriptions($report);
$this->renderSummary($report);
if (! (bool) $this->option('no-report')) {
$path = 'migration/verify-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return $report['summary']['failures'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param array<string, mixed> $report
* @param list<string> $portals
*/
private function verifyLegacyCounts(array &$report, array $portals): void
{
$rows = [];
foreach ($portals as $portal) {
foreach (self::ENTITY_MAP as $entity => $config) {
$legacyCount = $this->legacyCount($portal, $config['legacy_table']);
$targetCount = DB::table($config['target_table'])
->where('legacy_portal', $portal)
->whereNotNull($config['target_column'])
->count();
$mappedCount = DB::table('legacy_import_map')
->where('legacy_portal', $portal)
->where('legacy_table', $config['legacy_table'])
->where('target_table', $config['target_table'])
->count();
$rows[] = [
'portal' => $portal,
'entity' => $entity,
'legacy_count' => $legacyCount,
'target_count' => $targetCount,
'mapped_count' => $mappedCount,
'delta' => $legacyCount - $targetCount,
];
}
}
$this->addCheck($report, 'legacy_counts', 'ok', 'Legacy- und Ziel-Counts erfasst.', $rows);
}
/**
* @param array<string, mixed> $report
* @param list<string> $portals
*/
private function verifyLegacyInvoices(array &$report, array $portals): void
{
$rows = [];
$failures = 0;
foreach ($portals as $portal) {
$legacy = DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('invoice')
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount')
->first();
$target = DB::table('legacy_invoices')
->where('legacy_portal', $portal)
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount_cents), 0) as amount_cents')
->first();
$legacyAmountCents = (int) round((float) $legacy->amount * 100);
$targetAmountCents = (int) $target->amount_cents;
$countDelta = (int) $legacy->count - (int) $target->count;
$amountDelta = $legacyAmountCents - $targetAmountCents;
if ($countDelta !== 0 || $amountDelta !== 0) {
$failures++;
}
$rows[] = [
'portal' => $portal,
'legacy_count' => (int) $legacy->count,
'target_count' => (int) $target->count,
'count_delta' => $countDelta,
'legacy_amount_cents' => $legacyAmountCents,
'target_amount_cents' => $targetAmountCents,
'amount_delta_cents' => $amountDelta,
];
}
$this->addCheck(
$report,
'legacy_invoices',
$failures === 0 ? 'ok' : 'fail',
$failures === 0 ? 'Legacy-Rechnungen stimmen mit dem Archiv überein.' : 'Legacy-Rechnungsarchiv weicht von der Quelle ab.',
$rows,
);
}
/**
* @param array<string, mixed> $report
* @param list<string> $portals
*/
private function verifyImportMapTargets(array &$report, array $portals): void
{
$rows = [];
$failures = 0;
foreach (self::ENTITY_MAP as $entity => $config) {
foreach ($portals as $portal) {
$missingTargets = DB::table('legacy_import_map as map')
->leftJoin($config['target_table'].' as target', 'target.id', '=', 'map.target_id')
->where('map.legacy_portal', $portal)
->where('map.legacy_table', $config['legacy_table'])
->where('map.target_table', $config['target_table'])
->whereNull('target.id')
->count();
if ($missingTargets > 0) {
$failures++;
}
$rows[] = [
'portal' => $portal,
'entity' => $entity,
'missing_targets' => $missingTargets,
];
}
}
$this->addCheck(
$report,
'import_map_targets',
$failures === 0 ? 'ok' : 'fail',
$failures === 0 ? 'Alle Import-Map-Ziele existieren.' : 'Import-Map enthält verwaiste Ziel-IDs.',
$rows,
);
}
/**
* @param array<string, mixed> $report
*/
private function verifyTargetForeignKeys(array &$report): void
{
$checks = [
'companies.owner_user_id' => DB::table('companies as c')
->leftJoin('users as u', 'u.id', '=', 'c.owner_user_id')
->whereNotNull('c.owner_user_id')
->whereNull('u.id'),
'contacts.company_id' => DB::table('contacts as c')
->leftJoin('companies as co', 'co.id', '=', 'c.company_id')
->whereNull('co.id'),
'press_releases.user_id' => DB::table('press_releases as pr')
->leftJoin('users as u', 'u.id', '=', 'pr.user_id')
->whereNull('u.id'),
'press_releases.company_id' => DB::table('press_releases as pr')
->leftJoin('companies as c', 'c.id', '=', 'pr.company_id')
->whereNotNull('pr.company_id')
->whereNull('c.id'),
'press_releases.category_id' => DB::table('press_releases as pr')
->leftJoin('categories as c', 'c.id', '=', 'pr.category_id')
->whereNull('c.id'),
'company_user.company_id' => DB::table('company_user as pivot')
->leftJoin('companies as c', 'c.id', '=', 'pivot.company_id')
->whereNull('c.id'),
'company_user.user_id' => DB::table('company_user as pivot')
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
->whereNull('u.id'),
'contact_user.contact_id' => DB::table('contact_user as pivot')
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
->whereNull('c.id'),
'contact_user.user_id' => DB::table('contact_user as pivot')
->leftJoin('users as u', 'u.id', '=', 'pivot.user_id')
->whereNull('u.id'),
'press_release_contact.press_release_id' => DB::table('press_release_contact as pivot')
->leftJoin('press_releases as pr', 'pr.id', '=', 'pivot.press_release_id')
->whereNull('pr.id'),
'press_release_contact.contact_id' => DB::table('press_release_contact as pivot')
->leftJoin('contacts as c', 'c.id', '=', 'pivot.contact_id')
->whereNull('c.id'),
'legacy_invoices.user_id' => DB::table('legacy_invoices as li')
->leftJoin('users as u', 'u.id', '=', 'li.user_id')
->whereNotNull('li.user_id')
->whereNull('u.id'),
];
$rows = [];
$failures = 0;
foreach ($checks as $name => $query) {
$count = $query->count();
if ($count > 0) {
$failures++;
}
$rows[] = [
'relation' => $name,
'dangling_count' => $count,
];
}
$this->addCheck(
$report,
'target_foreign_keys',
$failures === 0 ? 'ok' : 'fail',
$failures === 0 ? 'Keine verwaisten Ziel-Foreign-Keys gefunden.' : 'Zieltabellen enthalten verwaiste Foreign Keys.',
$rows,
);
}
/**
* @param array<string, mixed> $report
*/
private function verifyUserMerges(array &$report): void
{
$bothUsers = DB::table('users')
->where('portal', Portal::Both->value)
->count();
$importMapsForBothUsers = DB::table('legacy_import_map as map')
->join('users as users', 'users.id', '=', 'map.target_id')
->where('map.legacy_table', 'sf_guard_user')
->where('map.target_table', 'users')
->where('users.portal', Portal::Both->value)
->count();
$this->addCheck($report, 'user_merges', 'ok', 'Portalübergreifende User-Merges erfasst.', [
'users_with_portal_both' => $bothUsers,
'legacy_maps_to_portal_both_users' => $importMapsForBothUsers,
]);
}
/**
* @param array<string, mixed> $report
*/
private function verifyGrandfatheredSubscriptions(array &$report): void
{
$rows = DB::table('user_payment_options')
->selectRaw('status, COUNT(*) as count, MIN(grandfathered_until) as earliest_grandfathered_until, MAX(grandfathered_until) as latest_grandfathered_until')
->where('status', 'grandfathered')
->groupBy('status')
->get()
->map(fn (object $row): array => [
'status' => $row->status,
'count' => (int) $row->count,
'earliest_grandfathered_until' => $row->earliest_grandfathered_until,
'latest_grandfathered_until' => $row->latest_grandfathered_until,
])
->all();
$this->addCheck($report, 'grandfathered_subscriptions', 'ok', 'Grandfathered Subscriptions erfasst.', $rows);
}
private function legacyCount(string $portal, string $table): int
{
return DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table($table)
->count();
}
/**
* @param array<string, mixed> $report
* @param array<mixed> $details
*/
private function addCheck(array &$report, string $name, string $status, string $message, array $details): void
{
$report['checks'][$name] = [
'status' => $status,
'message' => $message,
'details' => $details,
];
if ($status === 'fail') {
$report['summary']['failures']++;
}
if ($status === 'warning') {
$report['summary']['warnings']++;
}
}
/**
* @param array<string, mixed> $report
*/
private function renderSummary(array $report): void
{
foreach ($report['checks'] as $name => $check) {
$status = match ($check['status']) {
'ok' => '<info>OK</info>',
'warning' => '<comment>WARN</comment>',
default => '<error>FAIL</error>',
};
$this->line("{$status} {$name}: {$check['message']}");
}
$this->newLine();
$this->table(
['Status', 'Anzahl'],
[
['Fehler', $report['summary']['failures']],
['Warnungen', $report['summary']['warnings']],
],
);
}
}