diff --git a/app/Console/Commands/ImportLegacyData.php b/app/Console/Commands/ImportLegacyData.php index ca0ef200..80a3070d 100644 --- a/app/Console/Commands/ImportLegacyData.php +++ b/app/Console/Commands/ImportLegacyData.php @@ -12,6 +12,7 @@ use App\Services\Import\PressReleaseImporter; use App\Services\Import\UserAssociationLinker; use App\Services\Import\UserImporter; use Illuminate\Console\Command; +use Illuminate\Support\Carbon; /** * Legacy-Import-Master-Command (Phase 6 – Datenmigration). @@ -22,6 +23,7 @@ use Illuminate\Console\Command; * 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 + * php artisan legacy:import --source=presseecho --since=today --force ← Delta: nur heute Angelegtes/Geändertes * * Schritte (--step): * categories – Kategorien + Translations (einmalig, beide Portale identisch) @@ -39,6 +41,7 @@ class ImportLegacyData extends Command {--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} + {--since= : Nur Legacy-Zeilen mit created_at/updated_at ab diesem Zeitpunkt (z. B. "2026-07-20", "today"); für Updates mit --force kombinieren} {--chunk-size=500 : Batch-Größe (Debugging)}'; protected $description = 'Legacy-Daten aus Presseecho / Businessportal24 in die neue DB importieren.'; @@ -61,9 +64,29 @@ class ImportLegacyData extends Command $isDryRun = (bool) $this->option('dry-run'); $isForce = (bool) $this->option('force'); + $since = null; + + if (filled($this->option('since'))) { + try { + $since = Carbon::parse((string) $this->option('since'), config('app.timezone')); + } catch (\Throwable) { + $this->error('Ungültiger --since-Wert: '.$this->option('since')); + + return self::FAILURE; + } + } + $portals = $source === 'all' ? self::PORTALS : [$source]; $steps = $step === 'all' ? self::STEPS : [$step]; + if ($since) { + $this->info('Delta-Modus: nur Legacy-Zeilen mit created_at/updated_at ab '.$since->format('d.m.Y H:i:s').'.'); + + if (! $isForce) { + $this->warn('Hinweis: ohne --force werden im Zeitfenster GEÄNDERTE, bereits importierte Datensätze übersprungen — nur Neue kommen dazu.'); + } + } + if (! $isDryRun && ! $this->option('force')) { $this->warn('WICHTIG: Dieser Command schreibt Daten in die Produktions-DB.'); $this->line(' Portale: '.implode(', ', $portals)); @@ -86,7 +109,7 @@ class ImportLegacyData extends Command 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); + $ctx = new ImportContext('all', $isDryRun, $isForce, $since); $this->info('▶ Schritt [link-associations] (beide Portale, neue DB)'); $start = microtime(true); $result = app(UserAssociationLinker::class)->run($ctx); @@ -100,7 +123,7 @@ class ImportLegacyData extends Command $stepPortals = ($currentStep === 'categories') ? [$portals[0]] : $portals; foreach ($stepPortals as $portal) { - $ctx = new ImportContext($portal, $isDryRun, $isForce); + $ctx = new ImportContext($portal, $isDryRun, $isForce, $since); $this->info("▶ Schritt [{$currentStep}] Portal [{$portal}]"); $start = microtime(true); diff --git a/app/Services/Import/CompanyImporter.php b/app/Services/Import/CompanyImporter.php index f025311b..c0003061 100644 --- a/app/Services/Import/CompanyImporter.php +++ b/app/Services/Import/CompanyImporter.php @@ -43,6 +43,10 @@ class CompanyImporter DB::connection($conn) ->table('company') + ->when($ctx->since, fn ($query) => $query->where(function ($window) use ($ctx): void { + $window->where('created_at', '>=', $ctx->since) + ->orWhere('updated_at', '>=', $ctx->since); + })) ->orderBy('id') ->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $legacyPortal, $portal, $responsibles, $members): void { foreach ($rows as $row) { @@ -117,12 +121,17 @@ class CompanyImporter 'logo_path' => $row->logo ?: null, 'is_active' => (bool) $row->is_active, 'disable_footer_code' => (bool) ($row->disable_footer_code ?? false), - 'created_at' => $row->created_at ?? now(), - 'updated_at' => $row->updated_at ?? $row->created_at ?? now(), ] ); }); + // created_at/updated_at sind nicht fillable und würden von updateOrCreate + // verworfen → explizit per forceFill setzen (Muster PressReleaseImporter). + $company->forceFill([ + 'created_at' => $row->created_at ?? now(), + 'updated_at' => $row->updated_at ?? $row->created_at ?? now(), + ])->save(); + // Pivot-Zuordnungen (member + responsible) $pivotPayload = []; diff --git a/app/Services/Import/ContactImporter.php b/app/Services/Import/ContactImporter.php index 12b0d61d..f728ab27 100644 --- a/app/Services/Import/ContactImporter.php +++ b/app/Services/Import/ContactImporter.php @@ -20,6 +20,10 @@ class ContactImporter DB::connection($conn) ->table('contact') + ->when($ctx->since, fn ($query) => $query->where(function ($window) use ($ctx): void { + $window->where('created_at', '>=', $ctx->since) + ->orWhere('updated_at', '>=', $ctx->since); + })) ->orderBy('id') ->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $legacyPortal, $portal): void { foreach ($rows as $row) { @@ -93,12 +97,17 @@ class ContactImporter 'phone' => $this->cleanText($row->phone, 255), 'fax' => $this->cleanText($row->fax, 255), 'email' => $this->cleanText($row->email, 190), - 'created_at' => $row->created_at ?? now(), - 'updated_at' => $row->updated_at ?? $row->created_at ?? now(), ] ); }); + // created_at/updated_at sind nicht fillable und würden von updateOrCreate + // verworfen → explizit per forceFill setzen (Muster PressReleaseImporter). + $contact->forceFill([ + 'created_at' => $row->created_at ?? now(), + 'updated_at' => $row->updated_at ?? $row->created_at ?? now(), + ])->save(); + LegacyImportMap::query()->updateOrCreate( [ 'legacy_portal' => $legacyPortal, diff --git a/app/Services/Import/ImportContext.php b/app/Services/Import/ImportContext.php index 1e3a6489..911d7f66 100644 --- a/app/Services/Import/ImportContext.php +++ b/app/Services/Import/ImportContext.php @@ -3,9 +3,14 @@ namespace App\Services\Import; use App\Enums\Portal; +use Illuminate\Support\Carbon; /** * Hält den Kontext für einen Import-Lauf. + * + * `since` schränkt den Quell-Scan auf Legacy-Zeilen ein, deren + * created_at ODER updated_at ab dem Stichtag liegt (Delta-Läufe) — + * Kategorien und link-associations bleiben davon unberührt. */ final class ImportContext { @@ -17,6 +22,7 @@ final class ImportContext public readonly string $portal, // 'presseecho' | 'businessportal24' | 'all' public readonly bool $dryRun = false, public readonly bool $force = false, + public readonly ?Carbon $since = null, ) { if ($portal === 'all') { $this->portalEnum = null; diff --git a/app/Services/Import/PressReleaseImporter.php b/app/Services/Import/PressReleaseImporter.php index b56bf181..de099eea 100644 --- a/app/Services/Import/PressReleaseImporter.php +++ b/app/Services/Import/PressReleaseImporter.php @@ -44,6 +44,10 @@ class PressReleaseImporter DB::connection($conn) ->table('press_release') + ->when($ctx->since, fn ($query) => $query->where(function ($window) use ($ctx): void { + $window->where('created_at', '>=', $ctx->since) + ->orWhere('updated_at', '>=', $ctx->since); + })) ->orderBy('id') ->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $legacyPortal, $portal, $images, $prContacts): void { foreach ($rows as $row) { diff --git a/app/Services/Import/UserImporter.php b/app/Services/Import/UserImporter.php index 7baf2449..ece7c6c2 100644 --- a/app/Services/Import/UserImporter.php +++ b/app/Services/Import/UserImporter.php @@ -92,6 +92,10 @@ class UserImporter 'sf_guard_user_profile.tax_exempt_reason', 'sf_guard_user_profile.disable_footer_code', ]) + ->when($ctx->since, fn ($query) => $query->where(function ($window) use ($ctx): void { + $window->where('sf_guard_user.created_at', '>=', $ctx->since) + ->orWhere('sf_guard_user.updated_at', '>=', $ctx->since); + })) ->orderBy('sf_guard_user.id') ->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $portal, $legacyPortal, $userGroups): void { foreach ($rows as $row) { @@ -163,15 +167,18 @@ class UserImporter 'last_login_ip' => $row->ip_address ?: null, 'legacy_portal' => $legacyPortal, 'legacy_id' => $row->legacy_id, - 'created_at' => $row->created_at ?? now(), - 'updated_at' => $row->updated_at ?? $row->created_at ?? now(), // Kein Passwort – User erhält Go-Live-Reset-Mail (D-09) ] ); - // is_super_admin ist nicht mass-assignable (siehe User::$fillable) – - // im vertrauenswürdigen Import explizit per forceFill setzen. - $user->forceFill(['is_super_admin' => (bool) $row->is_super_admin])->save(); + // is_super_admin und die Timestamps sind nicht mass-assignable und + // würden von updateOrCreate verworfen – im vertrauenswürdigen Import + // explizit per forceFill setzen (Muster PressReleaseImporter). + $user->forceFill([ + 'is_super_admin' => (bool) $row->is_super_admin, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => $row->updated_at ?? $row->created_at ?? now(), + ])->save(); return $user; }); diff --git a/tests/Feature/LegacyPressReleaseDateImportTest.php b/tests/Feature/LegacyPressReleaseDateImportTest.php index fa686854..96de8b59 100644 --- a/tests/Feature/LegacyPressReleaseDateImportTest.php +++ b/tests/Feature/LegacyPressReleaseDateImportTest.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Services\Import\ImportContext; use App\Services\Import\PressReleaseImporter; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -178,3 +179,64 @@ test('a force re-import preserves the uuid and the publication date', function ( expect($second->uuid)->toBe($originalUuid); expect($second->published_at?->format('Y-m-d H:i:s'))->toBe('2009-04-08 15:43:42'); }); + +test('a since delta only scans legacy rows created or updated in the window', function () { + /** @var TestCase $this */ + seedLegacyPressReleaseSchema(); + + DB::connection('mysql_presseecho')->table('press_release')->insert([ + [ + 'id' => 20, + 'title' => 'Alte unveränderte Meldung', + 'language' => 'de', + 'text' => 'Inhalt', + 'category_id' => 500, + 'user_id' => 700, + 'status' => 'published', + 'no_export' => false, + 'created_at' => '2019-05-01 08:00:00', + 'updated_at' => '2019-05-01 08:00:00', + 'slug' => 'alt-unveraendert', + ], + [ + 'id' => 21, + 'title' => 'Heute neu angelegt', + 'language' => 'de', + 'text' => 'Inhalt', + 'category_id' => 500, + 'user_id' => 700, + 'status' => 'published', + 'no_export' => false, + 'created_at' => '2026-07-20 09:00:00', + 'updated_at' => '2026-07-20 09:00:00', + 'slug' => 'heute-neu', + ], + [ + 'id' => 22, + 'title' => 'Alt, aber heute geändert', + 'language' => 'de', + 'text' => 'Inhalt', + 'category_id' => 500, + 'user_id' => 700, + 'status' => 'published', + 'no_export' => false, + 'created_at' => '2012-01-15 10:00:00', + 'updated_at' => '2026-07-20 10:30:00', + 'slug' => 'alt-heute-geaendert', + ], + ]); + + app(PressReleaseImporter::class)->run(new ImportContext( + 'presseecho', + false, + true, + Carbon::parse('2026-07-20 00:00:00'), + )); + + $imported = PressRelease::withoutGlobalScopes() + ->where('legacy_portal', 'presseecho') + ->whereIn('legacy_id', [20, 21, 22]) + ->pluck('legacy_id')->sort()->values()->all(); + + expect($imported)->toBe([21, 22]); +}); diff --git a/tests/Feature/LegacyUserProfileImportTest.php b/tests/Feature/LegacyUserProfileImportTest.php index a1049e2c..bd0c9462 100644 --- a/tests/Feature/LegacyUserProfileImportTest.php +++ b/tests/Feature/LegacyUserProfileImportTest.php @@ -127,6 +127,11 @@ test('legacy user import creates linked profile data from sf guard user profile' expect($user->profile?->tax_exempt)->toBeTrue(); expect($user->profile?->tax_exempt_reason)->toBe('Reverse Charge'); expect($user->profile?->disable_footer_code)->toBeTrue(); + + // Timestamps kommen aus dem Legacy-System, nicht vom Importzeitpunkt + // (created_at/updated_at sind nicht fillable → forceFill im Importer). + expect($user->created_at?->format('Y-m-d H:i:s'))->toBe('2020-01-01 00:00:00'); + expect($user->updated_at?->format('Y-m-d H:i:s'))->toBe('2020-01-02 00:00:00'); }); /**