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

314 lines
12 KiB
PHP

<?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;
}
}