legacy: --since-Delta für legacy:import + Importer setzen Timestamps wieder korrekt
--since=<Zeitpunkt> schränkt den Quell-Scan auf Legacy-Zeilen ein, deren created_at ODER updated_at im Fenster liegt (users/companies/contacts/ press-releases; Kategorien und link-associations bleiben ungefiltert). Kombiniert mit --force werden geänderte Bestandszeilen aktualisiert — damit dauert ein Tages-Delta Sekunden statt eines Volldurchlaufs über 150k+ Zeilen. Dazu Bugfix, der den Delta erst rund macht: User-/Company-/Contact- Importer übergaben created_at/updated_at innerhalb von updateOrCreate — die Felder sind nicht fillable und wurden stillschweigend verworfen (Live-Befund: NULL-Timestamps, bisher nur durch legacy:fix-timestamps geheilt). Jetzt explizit per forceFill wie im PressReleaseImporter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
263f030a6e
commit
4668cfc9fe
8 changed files with 136 additions and 11 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue