12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\LegacyApiAccessLogAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class AnalyzeLegacyApiAccessLogs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:analyze-legacy-access-logs
{paths* : Access-log files or glob patterns}
{--top=20 : Number of top entries per section}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Analysiert Legacy-API-Zugriffe aus Webserver-Access-Logs.';
/**
* Execute the console command.
*/
public function handle(LegacyApiAccessLogAnalyzer $analyzer): int
{
$paths = array_map('strval', (array) $this->argument('paths'));
$top = max(1, (int) $this->option('top'));
$report = $analyzer->analyze($paths, $top);
$this->info('Legacy-API-Access-Log-Auswertung');
$this->newLine();
$this->line("Dateien: {$report['summary']['files']}");
$this->line("Log-Zeilen: {$report['summary']['total_lines']}");
$this->line("Legacy-API-Requests: {$report['summary']['matched_requests']}");
$this->line("Requests mit api_key: {$report['summary']['legacy_key_requests']}");
$this->line("Eindeutige Client-IPs: {$report['summary']['unique_client_ips']}");
$this->line("Eindeutige API-Key-Fingerprints: {$report['summary']['unique_api_key_fingerprints']}");
if ($report['missing_paths'] !== []) {
$this->newLine();
$this->warn('Nicht lesbare Pfade:');
foreach ($report['missing_paths'] as $path) {
$this->line(" - {$path}");
}
}
$this->renderTopTable('Top Endpoints', $report['endpoints']);
$this->renderTopTable('Top Client-IPs', $report['client_ips']);
$this->renderTopTable('Statuscodes', $report['status_codes']);
if (! (bool) $this->option('no-report')) {
$path = 'migration/legacy-api-access-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->newLine();
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return self::SUCCESS;
}
/**
* @param array<string, int> $rows
*/
private function renderTopTable(string $title, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
['Wert', 'Requests'],
collect($rows)
->map(fn (int $count, string $value): array => [$value, $count])
->values()
->all()
);
}
}

View file

@ -0,0 +1,314 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\LegacyImportMap;
use App\Models\LegacyInvoice;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
/**
* Archiviert alle Rechnungen aus den Legacy-Portalen in legacy_invoices (P6.5).
*
* Die Tabelle ist read-only und dient als Audit-Archiv.
* Rechnungen werden nicht in den neuen Rechnungskreis (invoices) übernommen.
*
* Verwendung:
* php artisan legacy:archive-invoices --dry-run
* php artisan legacy:archive-invoices --portal=presseecho
* php artisan legacy:archive-invoices
*/
class ArchiveLegacyInvoices extends Command
{
protected $signature = 'legacy:archive-invoices
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur zählen, nichts schreiben}
{--force : Bereits archivierte Rechnungen aktualisieren}
{--no-report : Keinen JSON-Report schreiben}';
protected $description = 'Archiviert Legacy-Rechnungen in legacy_invoices (read-only Archiv).';
private const PORTAL_CONFIG = [
'presseecho' => [
'connection' => 'mysql_presseecho',
'portal_enum' => Portal::Presseecho,
],
'businessportal24' => [
'connection' => 'mysql_businessportal',
'portal_enum' => Portal::Businessportal24,
],
];
private const CHUNK_SIZE = 200;
public function handle(): int
{
$portalOpt = $this->option('portal');
$isDryRun = (bool) $this->option('dry-run');
$isForce = (bool) $this->option('force');
$portals = $portalOpt === 'all'
? array_keys(self::PORTAL_CONFIG)
: [$portalOpt];
if (array_diff($portals, array_keys(self::PORTAL_CONFIG)) !== []) {
$this->error("Unbekanntes Portal: {$portalOpt}");
return self::FAILURE;
}
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$totalImported = 0;
$totalSkipped = 0;
$totalErrors = 0;
$report = [
'generated_at' => now()->toIso8601String(),
'portal' => $portalOpt,
'dry_run' => $isDryRun,
'force' => $isForce,
'portals' => [],
];
$start = microtime(true);
foreach ($portals as $portal) {
$config = self::PORTAL_CONFIG[$portal];
$portalReport = $this->archivePortal(
$portal,
$config['connection'],
$isDryRun,
$isForce,
);
$report['portals'][$portal] = $portalReport;
$imported = $portalReport['imported'];
$skipped = $portalReport['skipped'];
$errors = $portalReport['errors'];
$this->line(" [{$portal}] Archiviert: {$imported} | Übersprungen: {$skipped} | Fehler: {$errors}");
$this->line(" [{$portal}] Quelle: {$portalReport['source_count']} | ohne User-Mapping: {$portalReport['unmapped_user_count']} | PDF-Payload: {$portalReport['pdf_payload_count']}");
$totalImported += $imported;
$totalSkipped += $skipped;
$totalErrors += $errors;
}
$elapsed = round(microtime(true) - $start, 1);
$this->newLine();
$this->info("Gesamt: {$totalImported} archiviert | {$totalSkipped} übersprungen | {$totalErrors} Fehler | {$elapsed}s");
if (! (bool) $this->option('no-report')) {
$path = 'migration/legacy-invoices-'.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 $totalErrors > 0 ? self::FAILURE : self::SUCCESS;
}
private function safeDateOrNull(mixed $value): ?string
{
if (blank($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
return null;
}
return (string) $value;
}
private function archivePortal(
string $portal,
string $connection,
bool $isDryRun,
bool $isForce,
): array {
$imported = 0;
$skipped = 0;
$errors = 0;
$unmappedUserCount = 0;
$pdfPayloadCount = 0;
$amountCents = 0;
$statusCounts = [];
$hasBillingAddressTable = Schema::connection($connection)->hasTable('invoice_billing_address');
$hasUserPaymentTable = Schema::connection($connection)->hasTable('user_payment');
$hasUserPaymentOptionTable = Schema::connection($connection)->hasTable('user_payment_option');
$hasPaymentOptionTable = Schema::connection($connection)->hasTable('payment_option');
$hasPaymentOptionTranslationTable = Schema::connection($connection)->hasTable('payment_option_translation');
$sourceCount = DB::connection($connection)->table('invoice')->count();
DB::connection($connection)
->table('invoice')
->orderBy('id')
->chunk(self::CHUNK_SIZE, function ($rows) use (
$portal, $connection, $isDryRun, $isForce, $hasBillingAddressTable, $hasUserPaymentTable,
$hasUserPaymentOptionTable, $hasPaymentOptionTable, $hasPaymentOptionTranslationTable,
&$imported, &$skipped, &$errors, &$unmappedUserCount, &$pdfPayloadCount, &$amountCents, &$statusCounts,
): void {
foreach ($rows as $row) {
try {
$status = (string) ($row->status ?? 'unknown');
$rowAmountCents = (int) round((float) $row->amount * 100);
$amountCents += $rowAmountCents;
$statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1;
$existingInvoice = LegacyInvoice::query()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first();
if ($existingInvoice && ! $isForce) {
$skipped++;
continue;
}
if ($isDryRun) {
$imported++;
continue;
}
$userId = null;
if ($row->user_id) {
$userMap = LegacyImportMap::query()
->where('legacy_portal', $portal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $row->user_id)
->first();
$userId = $userMap?->target_id;
}
if ($userId === null) {
$unmappedUserCount++;
}
$pdfPayload = $this->pdfPayload(
$connection,
$row,
$hasBillingAddressTable,
$hasUserPaymentTable,
$hasUserPaymentOptionTable,
$hasPaymentOptionTable,
$hasPaymentOptionTranslationTable,
);
$pdfPayloadCount++;
$taxCents = 0;
$totalCents = $rowAmountCents;
$paidAt = $this->safeDateOrNull($row->pay_date);
LegacyInvoice::query()->updateOrCreate(
[
'legacy_portal' => $portal,
'legacy_id' => $row->id,
],
[
'user_id' => $userId,
'legacy_user_id' => $row->user_id,
'number' => (string) $row->number,
'amount_cents' => $rowAmountCents,
'tax_cents' => $taxCents,
'total_cents' => $totalCents,
'status' => $status,
'invoice_date' => $this->safeDateOrNull($row->invoice_date),
'due_date' => $this->safeDateOrNull($row->due_date),
'paid_at' => $paidAt,
'payment_method' => $row->payment_method ?: null,
'pdf_path' => $existingInvoice?->pdf_path,
'raw_snapshot' => (array) $row,
'pdf_payload' => $pdfPayload,
'imported_at' => now(),
]
);
$imported++;
} catch (\Throwable $e) {
$errors++;
$this->warn(" ! Invoice {$row->id}: {$e->getMessage()}");
}
}
});
ksort($statusCounts);
return [
'source_count' => $sourceCount,
'imported' => $imported,
'skipped' => $skipped,
'errors' => $errors,
'unmapped_user_count' => $unmappedUserCount,
'pdf_payload_count' => $pdfPayloadCount,
'amount_cents' => $amountCents,
'status_counts' => $statusCounts,
];
}
/**
* @return array<string, mixed>
*/
private function pdfPayload(
string $connection,
object $invoice,
bool $hasBillingAddressTable,
bool $hasUserPaymentTable,
bool $hasUserPaymentOptionTable,
bool $hasPaymentOptionTable,
bool $hasPaymentOptionTranslationTable,
): array {
$userPayment = $hasUserPaymentTable && $invoice->user_payment_id
? $this->legacyRow($connection, 'user_payment', (int) $invoice->user_payment_id)
: null;
$userPaymentOption = $userPayment && $hasUserPaymentOptionTable
? $this->legacyRow($connection, 'user_payment_option', (int) $userPayment['user_payment_option_id'])
: null;
$paymentOption = $userPaymentOption && $hasPaymentOptionTable
? $this->legacyRow($connection, 'payment_option', (int) $userPaymentOption['payment_option_id'])
: null;
return [
'invoice' => (array) $invoice,
'billing_address' => $hasBillingAddressTable && $invoice->billing_address_id
? $this->legacyRow($connection, 'invoice_billing_address', (int) $invoice->billing_address_id)
: null,
'user_payment' => $userPayment,
'user_payment_option' => $userPaymentOption,
'payment_option' => $paymentOption,
'payment_option_translation' => $paymentOption && $hasPaymentOptionTranslationTable
? $this->paymentOptionTranslation($connection, (int) $paymentOption['id'])
: null,
];
}
/**
* @return array<string, mixed>|null
*/
private function paymentOptionTranslation(string $connection, int $paymentOptionId): ?array
{
$row = DB::connection($connection)
->table('payment_option_translation')
->where('id', $paymentOptionId)
->whereIn('lang', ['de', 'de_DE', 'de_AT', 'de_CH', 'en'])
->orderByRaw("CASE lang WHEN 'de' THEN 0 WHEN 'de_DE' THEN 1 WHEN 'de_AT' THEN 2 WHEN 'de_CH' THEN 3 ELSE 4 END")
->first();
return $row ? (array) $row : null;
}
/**
* @return array<string, mixed>|null
*/
private function legacyRow(string $connection, string $table, int $id): ?array
{
$row = DB::connection($connection)
->table($table)
->where('id', $id)
->first();
return $row ? (array) $row : null;
}
}

View file

@ -0,0 +1,142 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Korrigiert created_at / updated_at auf bereits importierten Datensätzen.
*
* Verwendet Cross-DB-JOINs (gleicher MySQL-Server), um die Legacy-Timestamps
* in einer einzigen SQL-Operation auf die neuen Tabellen zu übertragen.
* Viel schneller als ein erneuter --force Import.
*
* Verwendung:
* php artisan legacy:fix-timestamps --dry-run # nur zählen
* php artisan legacy:fix-timestamps # alle Entitäten, beide Portale
* php artisan legacy:fix-timestamps --entity=companies --portal=presseecho
*/
class FixLegacyTimestamps extends Command
{
protected $signature = 'legacy:fix-timestamps
{--entity=all : Entität (users|companies|contacts|press-releases|all)}
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur zählen, nichts schreiben}';
protected $description = 'Korrigiert created_at/updated_at auf importierten Datensätzen aus den Legacy-DBs.';
private const PORTAL_DB = [
'presseecho' => 'presseecho',
'businessportal24' => 'businessportal',
];
/** Mapping: neue Tabelle → (legacy_portal, legacy_db, legacy_table, legacy_created_col) */
private const ENTITY_CONFIG = [
'users' => [
'new_table' => 'users',
'legacy_table_name' => 'sf_guard_user',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
],
'companies' => [
'new_table' => 'companies',
'legacy_table_name' => 'company',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
],
'contacts' => [
'new_table' => 'contacts',
'legacy_table_name' => 'contact',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
],
'press-releases' => [
'new_table' => 'press_releases',
'legacy_table_name' => 'press_release',
'legacy_created' => 'created_at',
'legacy_updated' => 'updated_at',
],
];
public function handle(): int
{
$entityOpt = $this->option('entity');
$portalOpt = $this->option('portal');
$isDryRun = (bool) $this->option('dry-run');
$entities = $entityOpt === 'all' ? array_keys(self::ENTITY_CONFIG) : [$entityOpt];
$portals = $portalOpt === 'all' ? array_keys(self::PORTAL_DB) : [$portalOpt];
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$totalUpdated = 0;
$start = microtime(true);
foreach ($entities as $entity) {
$config = self::ENTITY_CONFIG[$entity] ?? null;
if (! $config) {
$this->error("Unbekannte Entität: {$entity}");
continue;
}
foreach ($portals as $portal) {
$legacyDb = self::PORTAL_DB[$portal] ?? null;
if (! $legacyDb) {
$this->error("Unbekanntes Portal: {$portal}");
continue;
}
$updated = $this->fixEntity($config, $portal, $legacyDb, $isDryRun);
$totalUpdated += $updated;
$this->line(" [{$entity}] [{$portal}] → {$updated} Datensätze ".($isDryRun ? 'würden aktualisiert' : 'aktualisiert'));
}
}
$elapsed = round(microtime(true) - $start, 1);
$this->newLine();
$this->info("Gesamt: {$totalUpdated} Datensätze in {$elapsed}s.");
return self::SUCCESS;
}
private function fixEntity(array $config, string $portal, string $legacyDb, bool $isDryRun): int
{
$newTable = $config['new_table'];
$legacyTable = $config['legacy_table_name'];
$legacyCreated = $config['legacy_created'];
$legacyUpdated = $config['legacy_updated'];
if ($isDryRun) {
// Nur zählen: wie viele Datensätze hätten falsche Timestamps?
return (int) DB::selectOne("
SELECT COUNT(*) as cnt
FROM {$newTable} n
JOIN legacy_import_map m
ON m.target_id = n.id
AND m.target_table = '{$newTable}'
AND m.legacy_portal = '{$portal}'
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
WHERE n.created_at != lc.{$legacyCreated}
OR n.updated_at != lc.{$legacyUpdated}
")->cnt ?? 0;
}
return DB::affectingStatement("
UPDATE {$newTable} n
JOIN legacy_import_map m
ON m.target_id = n.id
AND m.target_table = '{$newTable}'
AND m.legacy_portal = '{$portal}'
JOIN `{$legacyDb}`.`{$legacyTable}` lc ON lc.id = m.legacy_id
SET
n.created_at = lc.{$legacyCreated},
n.updated_at = lc.{$legacyUpdated}
");
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace App\Console\Commands;
use App\Services\Auth\UserRolePermissionSyncService;
use App\Services\Import\CategoryImporter;
use App\Services\Import\CompanyImporter;
use App\Services\Import\ContactImporter;
use App\Services\Import\ImportContext;
use App\Services\Import\ImportResult;
use App\Services\Import\PressReleaseImporter;
use App\Services\Import\UserAssociationLinker;
use App\Services\Import\UserImporter;
use Illuminate\Console\Command;
/**
* Legacy-Import-Master-Command (Phase 6 Datenmigration).
*
* Verwendung:
* php artisan legacy:import --source=presseecho --dry-run
* php artisan legacy:import --source=businessportal24 --step=users
* php artisan legacy:import --source=all --step=categories
* php artisan legacy:import --source=all --step=link-associations NEU
* php artisan legacy:import --source=presseecho --force
*
* Schritte (--step):
* categories Kategorien + Translations (einmalig, beide Portale identisch)
* users sf_guard_user + sf_guard_user_profile + Rollen
* companies company + company_user + responsible_company_user
* contacts contact
* press-releases press_release + press_release_image + press_release_contact
* link-associations User↔Kontakt direkt verknüpfen (via Firma-Zugehörigkeit)
* all alle Schritte in korrekter Reihenfolge (Standard)
*/
class ImportLegacyData extends Command
{
protected $signature = 'legacy:import
{--source=all : Portal (presseecho|businessportal24|all)}
{--step=all : Schritt (categories|users|companies|contacts|press-releases|link-associations|all)}
{--dry-run : Nichts schreiben, nur zählen}
{--force : Bereits importierte Datensätze erneut verarbeiten}
{--chunk-size=500 : Batch-Größe (Debugging)}';
protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.';
private const STEPS = [
'categories',
'users',
'companies',
'contacts',
'press-releases',
'link-associations',
];
private const PORTALS = ['presseecho', 'businessportal24'];
public function handle(UserRolePermissionSyncService $roleSync): int
{
$source = $this->option('source');
$step = $this->option('step');
$isDryRun = (bool) $this->option('dry-run');
$isForce = (bool) $this->option('force');
$portals = $source === 'all' ? self::PORTALS : [$source];
$steps = $step === 'all' ? self::STEPS : [$step];
if (! $isDryRun && ! $this->option('force')) {
$this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.');
$this->line(' Portale: '.implode(', ', $portals));
$this->line(' Schritte: '.implode(', ', $steps));
$this->newLine();
if (! $this->confirm('Import starten?')) {
$this->info('Abgebrochen.');
return self::SUCCESS;
}
}
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$totalStart = microtime(true);
foreach ($steps as $currentStep) {
// link-associations arbeitet auf der neuen DB → einmalig, portal-unabhängig
if ($currentStep === 'link-associations') {
$ctx = new ImportContext('all', $isDryRun, $isForce);
$this->info('▶ Schritt [link-associations] (beide Portale, neue DB)');
$start = microtime(true);
$result = app(UserAssociationLinker::class)->run($ctx);
$elapsed = round(microtime(true) - $start, 1);
$this->line("{$result->summary()} ({$elapsed}s)");
continue;
}
// Kategorien sind portal-übergreifend → nur einmal
$stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals;
foreach ($stepPortals as $portal) {
$ctx = new ImportContext($portal, $isDryRun, $isForce);
$this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]");
$start = microtime(true);
$result = $this->runStep($currentStep, $ctx, $roleSync);
$elapsed = round(microtime(true) - $start, 1);
$this->line("{$result->summary()} ({$elapsed}s)");
foreach (array_slice($result->errors(), 0, 10) as $err) {
$this->warn(" ! {$err}");
}
if (count($result->errors()) > 10) {
$this->warn(' ! ... und '.(count($result->errors()) - 10).' weitere Fehler.');
}
}
}
$total = round(microtime(true) - $totalStart, 1);
$this->newLine();
$this->info("Import abgeschlossen in {$total}s.");
return self::SUCCESS;
}
private function runStep(
string $step,
ImportContext $ctx,
UserRolePermissionSyncService $roleSync,
): ImportResult {
return match ($step) {
'categories' => app(CategoryImporter::class)->run($ctx),
'users' => app(UserImporter::class, ['roleSync' => $roleSync])->run($ctx),
'companies' => app(CompanyImporter::class)->run($ctx),
'contacts' => app(ContactImporter::class)->run($ctx),
'press-releases' => app(PressReleaseImporter::class)->run($ctx),
default => throw new \InvalidArgumentException("Unbekannter Schritt: {$step}"),
};
}
}

View file

@ -2,21 +2,19 @@
namespace App\Console\Commands;
use App\Models\Article as NewArticle;
use App\Models\Brand;
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
use App\Models\Legacy\Presseecho\User as LegacyUser;
// Import der Models für die NEUE Struktur
use App\Models\User as NewUser;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
// Import der Models für die ALTE Struktur
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
// Import der Models für die NEUE Struktur
use App\Models\User as NewUser;
use App\Models\Article as NewArticle;
use App\Models\Brand;
// Import der Models für die ALTE Struktur
use App\Models\Legacy\Presseecho\User as LegacyUser;
use App\Models\Legacy\Presseecho\Article as LegacyArticle;
class MigratePresseechoData extends Command
class MigratePresseData extends Command
{
/**
* The name and signature of the console command.
@ -41,8 +39,9 @@ class MigratePresseechoData extends Command
// Holen der Brand-ID für Presseecho aus der neuen DB
$brand = Brand::where('domain', 'presseecho.de')->first();
if (!$brand) {
if (! $brand) {
$this->error('Brand "presseecho.de" not found in the new database. Please seed brands first.');
return 1;
}
@ -58,6 +57,7 @@ class MigratePresseechoData extends Command
DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;');
$this->info('Migration for presseecho.de completed successfully!');
return 0;
}
@ -73,7 +73,7 @@ class MigratePresseechoData extends Command
// Prüfen, ob der Nutzer (anhand der E-Mail) bereits in der neuen DB existiert
$existingUser = NewUser::where('email', $legacyUser->email)->first();
if (!$existingUser) {
if (! $existingUser) {
// Nutzer existiert noch nicht -> neu anlegen
NewUser::create([
'name' => $legacyUser->name,
@ -104,8 +104,9 @@ class MigratePresseechoData extends Command
// Den zugehörigen Nutzer in der NEUEN Datenbank finden
$author = NewUser::where('email', $legacyArticle->author_email)->first(); // Annahme: Artikel hat eine Autoren-E-Mail
if (!$author) {
if (! $author) {
$this->warn("Skipping article ID {$legacyArticle->id}, author with email {$legacyArticle->author_email} not found.");
continue; // Artikel überspringen, wenn kein Autor gefunden wurde
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use Illuminate\Console\Command;
/**
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden.
* Schützt vor "Zombie-Drafts" in der DB optional, konfigurierbar.
*/
class PurgeExpiredPressReleaseDrafts extends Command
{
protected $signature = 'press-releases:purge-drafts
{--days=180 : Entwürfe älter als X Tage archivieren}
{--dry-run : Nur zählen, nichts ändern}';
protected $description = 'Archiviert inaktive PM-Entwürfe (älter als X Tage).';
public function handle(): int
{
$days = (int) $this->option('days');
$isDryRun = $this->option('dry-run');
$cutoff = now()->subDays($days);
$query = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Draft->value)
->where('updated_at', '<', $cutoff);
$count = $query->count();
if ($isDryRun) {
$this->warn("[DRY-RUN] {$count} Entwürfe würden archiviert. Kein tatsächlicher Vorgang.");
return self::SUCCESS;
}
$query->update(['status' => PressReleaseStatus::Archived->value]);
$this->info("PM-Entwürfe archiviert: {$count} Einträge.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Models\MagicLink;
use Illuminate\Console\Command;
/**
* Löscht verbrauchte oder abgelaufene Magic-Links, die älter als 30 Tage sind.
* Läuft täglich via Scheduler.
*/
class PurgeMagicLinks extends Command
{
protected $signature = 'magic-links:purge {--days=30 : Links älter als X Tage löschen}';
protected $description = 'Löscht verbrauchte und abgelaufene Magic-Links.';
public function handle(): int
{
$days = (int) $this->option('days');
$cutoff = now()->subDays($days);
$deleted = MagicLink::query()
->where(function ($q) use ($cutoff): void {
$q->where('expires_at', '<', $cutoff)
->orWhere(function ($q) use ($cutoff): void {
$q->whereNotNull('consumed_at')
->where('consumed_at', '<', $cutoff);
});
})
->delete();
$this->info("Magic-Links bereinigt: {$deleted} Einträge gelöscht.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,181 @@
<?php
namespace App\Console\Commands;
use App\Services\Admin\AdminSlowRequestReporter;
use Illuminate\Console\Command;
class ReportAdminSlowRequests extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:slow-requests
{--from= : Startzeitpunkt, z.B. 2026-04-29 oder 2026-04-29 08:00:00}
{--to= : Endzeitpunkt}
{--route= : Auf Route-Namen filtern}
{--path= : Auf Pfad filtern}
{--status= : Auf HTTP-Statuscode filtern}
{--min-duration= : Mindestdauer in Millisekunden}
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
{--limit=25 : Anzahl Detailzeilen}
{--file=* : Optionaler Logdatei-Pfad oder Glob}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Wertet Slow-Admin-Request-Logs aus.';
/**
* Execute the console command.
*/
public function handle(AdminSlowRequestReporter $reporter): int
{
$top = max(1, (int) $this->option('top'));
$limit = max(1, (int) $this->option('limit'));
$files = array_values(array_filter(array_map('strval', (array) $this->option('file'))));
$report = $reporter->report(
filters: [
'from' => $this->option('from') !== null ? (string) $this->option('from') : null,
'to' => $this->option('to') !== null ? (string) $this->option('to') : null,
'route' => $this->option('route') !== null ? (string) $this->option('route') : null,
'path' => $this->option('path') !== null ? (string) $this->option('path') : null,
'status' => $this->option('status') !== null ? (int) $this->option('status') : null,
'min_duration_ms' => $this->option('min-duration') !== null ? (int) $this->option('min-duration') : null,
],
top: $top,
limit: $limit,
paths: $files !== [] ? $files : null,
);
$this->info('Slow-Admin-Request-Report');
$this->newLine();
$this->line("Dateien: {$report['summary']['files']}");
$this->line("Requests: {$report['summary']['total_requests']}");
$this->line("Routen: {$report['summary']['unique_routes']}");
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
$this->line("Max. Dauer: {$report['summary']['max_duration_ms']} ms");
$this->line("Durchschnitt DB-Zeit: {$report['summary']['average_database_time_ms']} ms");
$this->line("Max. Query-Anzahl: {$report['summary']['max_query_count']}");
$this->renderTopTable('Top Routen', $report['top_routes']);
$this->renderTopTable('Top Pfade', $report['top_paths']);
$this->renderTopTable('Statuscodes', $report['status_codes']);
$this->renderRequests('Langsamste Requests', $report['slowest_requests']);
$this->renderRequests('Query-lastige Requests', $report['query_heavy_requests']);
$this->renderSlowQueries($report['slow_queries']);
$this->renderExplainPlans($report['explain_plans']);
return self::SUCCESS;
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderTopTable(string $title, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
['Wert', 'Requests', 'Ø Dauer', 'Max Dauer', 'Ø DB', 'Queries'],
collect($rows)
->map(fn (array $row): array => [
$row['value'],
$row['requests'],
$row['average_duration_ms'].' ms',
$row['max_duration_ms'].' ms',
$row['average_database_time_ms'].' ms',
$row['total_queries'],
])
->all()
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderRequests(string $title, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
['Zeit', 'Route', 'Pfad', 'Status', 'Dauer', 'DB', 'Queries'],
collect($rows)
->map(fn (array $row): array => [
$row['timestamp'],
$row['route_name'],
$row['path'],
$row['status_code'],
$row['duration_ms'].' ms',
$row['database_time_ms'].' ms',
$row['query_count'],
])
->all()
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderSlowQueries(array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line('Häufige Slow Queries');
$this->table(
['SQL', 'Vorkommen', 'Ø Zeit', 'Max Zeit'],
collect($rows)
->map(fn (array $row): array => [
str($row['sql'])->limit(100)->toString(),
$row['occurrences'],
$row['average_time_ms'].' ms',
$row['max_time_ms'].' ms',
])
->all()
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderExplainPlans(array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line('EXPLAIN Top Slow Queries');
foreach ($rows as $row) {
$this->line(str($row['sql'])->limit(120)->toString());
if ($row['error'] !== null) {
$this->warn((string) $row['error']);
continue;
}
$this->table(
array_keys($row['plan'][0] ?? []),
collect($row['plan'])->map(fn (array $planRow): array => array_values($planRow))->all()
);
}
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\ApiUsageReporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ReportApiUsage extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:usage-report
{--from= : Startzeitpunkt, z.B. 2026-04-01 oder 2026-04-01 00:00:00}
{--to= : Endzeitpunkt}
{--user= : Auf User-ID filtern}
{--status= : Auf HTTP-Statuscode filtern}
{--top=10 : Anzahl Top-Zeilen pro Abschnitt}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Erstellt einen Report aus den protokollierten API-Usage-Logs.';
/**
* Execute the console command.
*/
public function handle(ApiUsageReporter $reporter): int
{
$top = max(1, (int) $this->option('top'));
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
$statusCode = $this->option('status') !== null ? (int) $this->option('status') : null;
$report = $reporter->report(
from: $this->option('from') !== null ? (string) $this->option('from') : null,
to: $this->option('to') !== null ? (string) $this->option('to') : null,
userId: $userId,
statusCode: $statusCode,
top: $top,
);
$this->info('API-Usage-Report');
$this->newLine();
$this->line("Requests: {$report['summary']['total_requests']}");
$this->line("Eindeutige User: {$report['summary']['unique_users']}");
$this->line("Eindeutige Tokens: {$report['summary']['unique_tokens']}");
$this->line("2xx: {$report['summary']['successful_requests']}");
$this->line("4xx: {$report['summary']['client_error_requests']}");
$this->line("5xx: {$report['summary']['server_error_requests']}");
$this->line("Durchschnitt Dauer: {$report['summary']['average_duration_ms']} ms");
$this->renderRows('Top Pfade', ['Pfad', 'Requests'], $report['top_paths']);
$this->renderRows('Statuscodes', ['Status', 'Requests'], $report['status_codes']);
$this->renderRows('Top User', ['User-ID', 'Requests'], $report['top_users']);
$this->renderRows('Top Tokens', ['Token-ID', 'Requests'], $report['top_tokens']);
if (! (bool) $this->option('no-report')) {
$path = 'migration/api-usage-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->newLine();
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return self::SUCCESS;
}
/**
* @param list<string> $headers
* @param list<array{value: mixed, requests: int}> $rows
*/
private function renderRows(string $title, array $headers, array $rows): void
{
if ($rows === []) {
return;
}
$this->newLine();
$this->line($title);
$this->table(
$headers,
collect($rows)
->map(fn (array $row): array => [$row['value'], $row['requests']])
->all()
);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Services\Api\LegacyApiCustomerReporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ReportLegacyApiCustomers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:legacy-customers-report
{--portal=all : Portal (presseecho|businessportal24|both|all)}
{--classification=all : Filter (eligible|needs_review|blocked|all)}
{--no-report : Keinen JSON-Report schreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Ermittelt Legacy-API-Kunden aus migrierten Daten und letzter bezahlter Rechnung.';
/**
* Execute the console command.
*/
public function handle(LegacyApiCustomerReporter $reporter): int
{
$portal = (string) $this->option('portal');
$classification = (string) $this->option('classification');
if (! in_array($portal, ['presseecho', 'businessportal24', 'both', 'all'], true)) {
$this->error("Unbekanntes Portal: {$portal}");
return self::FAILURE;
}
if (! in_array($classification, ['eligible', 'needs_review', 'blocked', 'all'], true)) {
$this->error("Unbekannte Klassifizierung: {$classification}");
return self::FAILURE;
}
$report = $reporter->report($portal);
$customers = collect($report['customers']);
if ($classification !== 'all') {
$customers = $customers
->where('classification', $classification)
->values();
}
$this->info('Legacy-API-Kundenreport');
$this->newLine();
$this->line("Portal: {$portal}");
$this->line("Kandidaten: {$report['summary']['total_candidates']}");
$this->line("Freigabefähig: {$report['summary']['eligible']}");
$this->line("Manuell prüfen: {$report['summary']['needs_review']}");
$this->line("Gesperrt: {$report['summary']['blocked']}");
if ($customers->isNotEmpty()) {
$this->newLine();
$this->table(
['ID', 'E-Mail', 'Portal', 'Letzte Rechnung', 'Status', 'Klassifizierung'],
$customers
->take(25)
->map(fn (array $customer): array => [
$customer['user_id'],
$customer['email'],
$customer['portal'],
$customer['latest_legacy_invoice']['number'] ?? '-',
$customer['latest_legacy_invoice']['status'] ?? '-',
$customer['classification'],
])
->all()
);
}
if (! (bool) $this->option('no-report')) {
$path = 'migration/legacy-api-customers-'.now()->format('Ymd-His').'.json';
Storage::disk('local')->put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$this->newLine();
$this->line("Report geschrieben: storage/app/private/{$path}");
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use App\Mail\GoLivePasswordReset;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
/**
* Sendet Go-Live-Passwort-Reset-Mails an alle aktiven User.
*
* Verwendung:
* php artisan auth:send-go-live-mails --dry-run
* php artisan auth:send-go-live-mails --portal=presseecho
* php artisan auth:send-go-live-mails --limit=10
* php artisan auth:send-go-live-mails --force
*/
class SendGoLiveMails extends Command
{
protected $signature = 'auth:send-go-live-mails
{--dry-run : Nur Anzahl ausgeben, keine Mails senden}
{--portal= : Nur User eines Portals (presseecho|businessportal24|both)}
{--limit=0 : Maximale Anzahl (0 = alle)}
{--force : Bestätigung überspringen}';
protected $description = 'Sendet Go-Live-Passwort-Reset-Mails an alle aktiven Benutzer.';
private const EXPIRES_IN_MINUTES = 60;
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$portal = $this->option('portal');
$limit = (int) $this->option('limit');
$query = User::query()
->where('is_active', true)
->whereNotNull('email')
->when($portal, fn ($q) => $q->where('portal', $portal));
$total = $query->count();
$this->info("Aktive Benutzer gefunden: {$total}");
if ($portal) {
$this->line(" → Portal-Filter: {$portal}");
}
if ($limit > 0) {
$this->line(" → Limit: {$limit}");
$query->limit($limit);
$total = min($total, $limit);
}
if ($isDryRun) {
$this->warn("[DRY-RUN] Es würden {$total} Mails versendet. Kein tatsächlicher Versand.");
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm("Jetzt {$total} Go-Live-Mails senden?")) {
$this->info('Abgebrochen.');
return self::SUCCESS;
}
$sent = 0;
$failed = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->each(function (User $user) use (&$sent, &$failed, $bar): void {
try {
$token = Password::broker()->createToken($user);
$resetUrl = url(route('password.reset', [
'token' => $token,
'email' => $user->email,
], false));
Mail::to($user->email)->send(
new GoLivePasswordReset($user, $resetUrl, self::EXPIRES_IN_MINUTES)
);
$sent++;
} catch (\Throwable $e) {
$failed++;
$this->newLine();
$this->error("Fehler für {$user->email}: {$e->getMessage()}");
}
$bar->advance();
});
$bar->finish();
$this->newLine(2);
$this->info("Versendet: {$sent}");
if ($failed > 0) {
$this->warn("Fehlgeschlagen: {$failed}");
}
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SyncCompanyLogos extends Command
{
protected $signature = 'legacy:sync-company-logos
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Ziel-Dateien erneut überschreiben}';
protected $description = 'Kopiert referenzierte Legacy-Firmenlogos in einen sauberen Storage-Pfad und aktualisiert companies.logo_path.';
public function handle(): int
{
$portals = $this->selectedPortals();
if ($portals === []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$totals = [
'referenced' => 0,
'copied' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
foreach ($portals as $portal) {
$stats = $this->syncPortal($portal, $dryRun, $force);
foreach ($totals as $key => $value) {
$totals[$key] = $value + $stats[$key];
}
$this->line(sprintf(
'%s: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
$portal,
$stats['referenced'],
$stats['copied'],
$stats['updated'],
$stats['already_synced'],
$stats['missing'],
$stats['unused'],
));
}
$this->newLine();
$this->info(sprintf(
'Gesamt: referenziert %d, kopiert %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
$totals['referenced'],
$totals['copied'],
$totals['updated'],
$totals['already_synced'],
$totals['missing'],
$totals['unused'],
$dryRun ? ' (Dry-Run)' : '',
));
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return list<string>
*/
private function selectedPortals(): array
{
$portal = (string) $this->option('portal');
return match ($portal) {
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
default => [],
};
}
/**
* @return array{referenced:int,copied:int,updated:int,already_synced:int,missing:int,unused:int}
*/
private function syncPortal(string $portal, bool $dryRun, bool $force): array
{
$sourceDirectory = "{$portal}/company";
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
$referencedFilenames = [];
$stats = [
'referenced' => 0,
'copied' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
Company::withoutGlobalScopes()
->where('legacy_portal', $portal)
->whereNotNull('logo_path')
->select(['id', 'legacy_portal', 'logo_path'])
->orderBy('id')
->chunkById(500, function ($companies) use ($allSourceFiles, $dryRun, $force, $portal, &$referencedFilenames, &$stats): void {
foreach ($companies as $company) {
$stats['referenced']++;
$sourceFilename = basename((string) $company->logo_path);
$referencedFilenames[$sourceFilename] = true;
if (Str::startsWith((string) $company->logo_path, 'company-logos/')) {
$stats['already_synced']++;
continue;
}
$sourcePath = $allSourceFiles->get($sourceFilename);
if (! $sourcePath) {
$stats['missing']++;
$this->warn("Fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})");
continue;
}
$destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}";
if (! $dryRun && ($force || ! Storage::disk('public')->exists($destinationPath))) {
Storage::disk('public')->copy($sourcePath, $destinationPath);
$stats['copied']++;
} elseif ($dryRun || Storage::disk('public')->exists($destinationPath)) {
$stats['copied'] += $dryRun ? 1 : 0;
}
if (! $dryRun) {
$company->forceFill([
'logo_path' => $destinationPath,
])->save();
}
$stats['updated']++;
}
});
$stats['unused'] = $allSourceFiles->keys()
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
->count();
return $stats;
}
}

View file

@ -0,0 +1,235 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SyncPressReleaseImages extends Command
{
protected $signature = 'legacy:sync-press-release-images
{--portal=all : Portal (presseecho|businessportal24|all)}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Ziel-Dateien erneut überschreiben}
{--skip-variants : Varianten nicht regenerieren (nur Datei-Kopie + DB-Update)}
{--limit=0 : Maximal N Bilder pro Portal verarbeiten (0 = alle)}';
protected $description = 'Kopiert referenzierte Legacy-PM-Bilder in den finalen Storage-Pfad, generiert Varianten und aktualisiert press_release_images.';
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
$portals = $this->selectedPortals();
if ($portals === []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$skipVariants = (bool) $this->option('skip-variants');
$limit = max(0, (int) $this->option('limit'));
$totals = [
'referenced' => 0,
'copied' => 0,
'variants_generated' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
foreach ($portals as $portal) {
$stats = $this->syncPortal($portal, $dryRun, $force, $skipVariants, $limit);
foreach ($totals as $key => $value) {
$totals[$key] = $value + $stats[$key];
}
$this->line(sprintf(
'%s: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d',
$portal,
$stats['referenced'],
$stats['copied'],
$stats['variants_generated'],
$stats['updated'],
$stats['already_synced'],
$stats['missing'],
$stats['unused'],
));
}
$this->newLine();
$this->info(sprintf(
'Gesamt: referenziert %d, kopiert %d, Varianten %d, DB-Updates %d, bereits synchron %d, fehlend %d, ungenutzt %d%s',
$totals['referenced'],
$totals['copied'],
$totals['variants_generated'],
$totals['updated'],
$totals['already_synced'],
$totals['missing'],
$totals['unused'],
$dryRun ? ' (Dry-Run)' : '',
));
return $totals['missing'] > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return list<string>
*/
private function selectedPortals(): array
{
$portal = (string) $this->option('portal');
return match ($portal) {
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
default => [],
};
}
/**
* @return array{referenced:int,copied:int,variants_generated:int,updated:int,already_synced:int,missing:int,unused:int}
*/
private function syncPortal(string $portal, bool $dryRun, bool $force, bool $skipVariants, int $limit): array
{
$sourceDirectory = "{$portal}/press_release";
$allSourceFiles = collect(Storage::disk('public')->files($sourceDirectory))
->mapWithKeys(fn (string $path): array => [basename($path) => $path]);
$referencedFilenames = [];
$stats = [
'referenced' => 0,
'copied' => 0,
'variants_generated' => 0,
'updated' => 0,
'already_synced' => 0,
'missing' => 0,
'unused' => 0,
];
$query = PressReleaseImage::withoutGlobalScopes()
->where('legacy_portal', $portal)
->whereNotNull('path')
->select(['id', 'press_release_id', 'legacy_portal', 'path', 'variants'])
->orderBy('id');
$processed = 0;
$reachLimit = false;
$query->chunkById(500, function ($images) use (
$allSourceFiles, $dryRun, $force, $skipVariants, $portal, $limit,
&$referencedFilenames, &$stats, &$processed, &$reachLimit,
): bool {
foreach ($images as $image) {
if ($reachLimit) {
return false;
}
$stats['referenced']++;
$sourceFilename = basename((string) $image->path);
$referencedFilenames[$sourceFilename] = true;
if (Str::startsWith((string) $image->path, 'press-releases/')) {
$stats['already_synced']++;
if (! $skipVariants && (! is_array($image->variants) || $image->variants === [])) {
if (! $dryRun) {
$variants = $this->imageService->generateMissingPressReleaseVariants($image->path);
if ($variants !== []) {
$image->forceFill(['variants' => $variants])->save();
$stats['variants_generated']++;
$stats['updated']++;
}
} else {
$stats['variants_generated']++;
}
}
$processed++;
if ($limit > 0 && $processed >= $limit) {
$reachLimit = true;
return false;
}
continue;
}
$sourcePath = $allSourceFiles->get($sourceFilename);
if (! $sourcePath) {
$stats['missing']++;
$this->warn("Fehlt: {$portal}/press_release/{$sourceFilename} (Image #{$image->id})");
continue;
}
$destinationPath = "press-releases/{$image->press_release_id}/images/{$sourceFilename}";
if (! $dryRun) {
if ($force || ! Storage::disk('public')->exists($destinationPath)) {
Storage::disk('public')->copy($sourcePath, $destinationPath);
$stats['copied']++;
}
} else {
$stats['copied']++;
}
$variants = [];
if (! $skipVariants && ! $dryRun) {
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
if ($variants !== []) {
$stats['variants_generated']++;
}
} elseif (! $skipVariants && $dryRun) {
$stats['variants_generated']++;
}
if (! $dryRun) {
$size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null];
$image->forceFill([
'disk' => 'public',
'path' => $destinationPath,
'variants' => $variants !== [] ? $variants : $image->variants,
'width' => is_int($size[0] ?? null) ? $size[0] : $image->width,
'height' => is_int($size[1] ?? null) ? $size[1] : $image->height,
])->save();
}
$stats['updated']++;
$processed++;
if ($limit > 0 && $processed >= $limit) {
$reachLimit = true;
return false;
}
}
return true;
});
$stats['unused'] = $allSourceFiles->keys()
->reject(fn (string $filename): bool => isset($referencedFilenames[$filename]))
->count();
return $stats;
}
}

View file

@ -0,0 +1,408 @@
<?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']],
],
);
}
}