12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
87
app/Console/Commands/AnalyzeLegacyApiAccessLogs.php
Normal file
87
app/Console/Commands/AnalyzeLegacyApiAccessLogs.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
314
app/Console/Commands/ArchiveLegacyInvoices.php
Normal file
314
app/Console/Commands/ArchiveLegacyInvoices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
142
app/Console/Commands/FixLegacyTimestamps.php
Normal file
142
app/Console/Commands/FixLegacyTimestamps.php
Normal 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}
|
||||
");
|
||||
}
|
||||
}
|
||||
144
app/Console/Commands/ImportLegacyData.php
Normal file
144
app/Console/Commands/ImportLegacyData.php
Normal 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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
45
app/Console/Commands/PurgeExpiredPressReleaseDrafts.php
Normal file
45
app/Console/Commands/PurgeExpiredPressReleaseDrafts.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
app/Console/Commands/PurgeMagicLinks.php
Normal file
37
app/Console/Commands/PurgeMagicLinks.php
Normal 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;
|
||||
}
|
||||
}
|
||||
181
app/Console/Commands/ReportAdminSlowRequests.php
Normal file
181
app/Console/Commands/ReportAdminSlowRequests.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/ReportApiUsage.php
Normal file
92
app/Console/Commands/ReportApiUsage.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/ReportLegacyApiCustomers.php
Normal file
92
app/Console/Commands/ReportLegacyApiCustomers.php
Normal 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;
|
||||
}
|
||||
}
|
||||
106
app/Console/Commands/SendGoLiveMails.php
Normal file
106
app/Console/Commands/SendGoLiveMails.php
Normal 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;
|
||||
}
|
||||
}
|
||||
160
app/Console/Commands/SyncCompanyLogos.php
Normal file
160
app/Console/Commands/SyncCompanyLogos.php
Normal 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;
|
||||
}
|
||||
}
|
||||
235
app/Console/Commands/SyncPressReleaseImages.php
Normal file
235
app/Console/Commands/SyncPressReleaseImages.php
Normal 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;
|
||||
}
|
||||
}
|
||||
408
app/Console/Commands/VerifyLegacyImport.php
Normal file
408
app/Console/Commands/VerifyLegacyImport.php
Normal 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']],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue