314 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|