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:
Kevin Adametz 2026-07-20 13:03:07 +00:00
parent 22b7bd38b7
commit a0c2e663dc
8 changed files with 136 additions and 11 deletions

View file

@ -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 = [];

View file

@ -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,

View file

@ -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;

View file

@ -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) {

View file

@ -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;
});