- published_at = Legacy-created_at (Publikationsdatum, Frontend-Sortier- schlüssel) statt updated_at, das bei Alt-Daten oft den Massen-/Migrations- stempel trägt und ein falsches "frisches" Datum erzeugte. - created_at/updated_at per forceFill direkt im Import gesetzt (waren nicht fillable, wurden still verworfen) – Import allein ist jetzt datumssauber. - legacy:fix-timestamps korrigiert zusätzlich published_at (status=published). - PM-UUID bleibt beim Re-Import/Delta-Lauf erhalten (kein Neu-Würfeln). - MIGRATION-STEPS auf Zwei-Phasen-Strategie umgestellt: migrate, Phase 1 mit --force, Phase 2 (Delta) ohne --force, Grandfather-Re-Run, Idempotenz-Tabelle. - Tests: LegacyPressReleaseDateImportTest (published_at-Quelle, Entwurf, Force-Re-Import erhält UUID + Datum). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
9.5 KiB
PHP
277 lines
9.5 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Import;
|
||
|
||
use App\Enums\Portal;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\LegacyImportMap;
|
||
use App\Models\PressRelease;
|
||
use App\Models\PressReleaseImage;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
|
||
class PressReleaseImporter
|
||
{
|
||
private const CHUNK_SIZE = 200;
|
||
|
||
/** Legacy-Status → neuer Status */
|
||
private const STATUS_MAP = [
|
||
'new' => PressReleaseStatus::Draft,
|
||
'edited' => PressReleaseStatus::Draft,
|
||
'prepublished' => PressReleaseStatus::Review,
|
||
'published' => PressReleaseStatus::Published,
|
||
'rejected' => PressReleaseStatus::Rejected,
|
||
];
|
||
|
||
public function run(ImportContext $ctx): ImportResult
|
||
{
|
||
$result = new ImportResult;
|
||
$conn = $ctx->connection;
|
||
$legacyPortal = $ctx->legacyPortalValue();
|
||
$portal = $ctx->portalEnum;
|
||
|
||
// Bilder und Kontakt-Pivots vorladen
|
||
$images = DB::connection($conn)
|
||
->table('press_release_image')
|
||
->get()
|
||
->groupBy('press_release_id');
|
||
|
||
$prContacts = DB::connection($conn)
|
||
->table('press_release_contact')
|
||
->get()
|
||
->groupBy('press_release_id');
|
||
|
||
DB::connection($conn)
|
||
->table('press_release')
|
||
->orderBy('id')
|
||
->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $legacyPortal, $portal, $images, $prContacts): void {
|
||
foreach ($rows as $row) {
|
||
try {
|
||
$this->importRow($row, $ctx, $result, $legacyPortal, $portal, $images, $prContacts);
|
||
} catch (\Throwable $e) {
|
||
$result->addError("PR legacy_id={$row->id}: {$e->getMessage()}");
|
||
}
|
||
}
|
||
});
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function importRow(
|
||
object $row,
|
||
ImportContext $ctx,
|
||
ImportResult $result,
|
||
string $legacyPortal,
|
||
Portal $portal,
|
||
Collection $images,
|
||
Collection $prContacts,
|
||
): void {
|
||
$alreadyImported = LegacyImportMap::query()
|
||
->where('legacy_portal', $legacyPortal)
|
||
->where('legacy_table', 'press_release')
|
||
->where('legacy_id', $row->id)
|
||
->exists();
|
||
|
||
if ($alreadyImported && ! $ctx->force) {
|
||
$result->incrementSkipped();
|
||
|
||
return;
|
||
}
|
||
|
||
if ($ctx->dryRun) {
|
||
$result->incrementImported();
|
||
|
||
return;
|
||
}
|
||
|
||
// User aus Import-Map
|
||
$userId = null;
|
||
if ($row->user_id) {
|
||
$userMap = LegacyImportMap::query()
|
||
->where('legacy_portal', $legacyPortal)
|
||
->where('legacy_table', 'sf_guard_user')
|
||
->where('legacy_id', $row->user_id)
|
||
->first();
|
||
$userId = $userMap?->target_id;
|
||
}
|
||
|
||
if (! $userId) {
|
||
$result->incrementSkipped();
|
||
|
||
return;
|
||
}
|
||
|
||
// Company aus Import-Map
|
||
$companyId = null;
|
||
if ($row->company_id) {
|
||
$companyMap = LegacyImportMap::query()
|
||
->where('legacy_portal', $legacyPortal)
|
||
->where('legacy_table', 'company')
|
||
->where('legacy_id', $row->company_id)
|
||
->first();
|
||
$companyId = $companyMap?->target_id;
|
||
}
|
||
|
||
// Kategorie aus Import-Map
|
||
$categoryId = null;
|
||
if ($row->category_id) {
|
||
$catMap = LegacyImportMap::query()
|
||
->where('legacy_table', 'category')
|
||
->where('legacy_id', $row->category_id)
|
||
->first();
|
||
$categoryId = $catMap?->target_id;
|
||
}
|
||
|
||
// Fallback-Kategorie (erste verfügbare)
|
||
if (! $categoryId) {
|
||
$categoryId = LegacyImportMap::query()
|
||
->where('legacy_table', 'category')
|
||
->value('target_id');
|
||
}
|
||
|
||
if (! $categoryId) {
|
||
$result->incrementSkipped();
|
||
|
||
return;
|
||
}
|
||
|
||
$status = self::STATUS_MAP[$row->status] ?? PressReleaseStatus::Draft;
|
||
$language = in_array($row->language, ['de', 'en']) ? $row->language : 'de';
|
||
|
||
// Beim Update (--force): bestehende uuid + slug behalten – öffentliche
|
||
// Referenzen (API-uuid, slug-URLs) dürfen sich beim Re-Import/Delta-Lauf
|
||
// nicht ändern.
|
||
$existingPr = $alreadyImported
|
||
? PressRelease::withoutGlobalScopes()
|
||
->where('legacy_portal', $legacyPortal)
|
||
->where('legacy_id', $row->id)
|
||
->first(['id', 'uuid', 'slug'])
|
||
: null;
|
||
|
||
$slug = $existingPr?->slug ?? $this->uniqueSlug(
|
||
$row->slug ?: Str::slug($row->title) ?: 'pressemitteilung',
|
||
$portal->value,
|
||
$language,
|
||
$existingPr?->id,
|
||
);
|
||
|
||
// Veröffentlichungsdatum = Legacy-`created_at` (Anlage-/Publikationsdatum,
|
||
// im Altsystem der indexierte Sortierschlüssel). Bewusst NICHT `updated_at`:
|
||
// dort steht der letzte Bearbeitungs-/Massen-Update-Stempel (bei vielen
|
||
// Alt-Datensätzen das Migrationsdatum), was im Frontend – das nach
|
||
// `published_at` sortiert/clustert – ein falsches, "frisches" Datum erzeugt.
|
||
$publishedAt = ($status === PressReleaseStatus::Published)
|
||
? $row->created_at
|
||
: null;
|
||
|
||
$pr = PressRelease::withoutTimestamps(function () use (
|
||
$legacyPortal, $row, $portal, $userId, $companyId, $categoryId,
|
||
$language, $slug, $status, $publishedAt, $existingPr,
|
||
): PressRelease {
|
||
$pr = PressRelease::withoutGlobalScopes()->updateOrCreate(
|
||
['legacy_portal' => $legacyPortal, 'legacy_id' => $row->id],
|
||
[
|
||
'uuid' => $existingPr?->uuid ?? (string) Str::uuid(),
|
||
'portal' => $portal->value,
|
||
'user_id' => $userId,
|
||
'company_id' => $companyId,
|
||
'category_id' => $categoryId,
|
||
'language' => $language,
|
||
'title' => $row->title ?: 'Ohne Titel',
|
||
'slug' => $slug,
|
||
'text' => $row->text ?: '',
|
||
'backlink_url' => $row->backlink_url ?: null,
|
||
'keywords' => $row->keywords ?: null,
|
||
'status' => $status->value,
|
||
'hits' => max(0, (int) $row->hits),
|
||
'teaser_begin' => max(0, (int) ($row->teaser_begin ?? 0)),
|
||
'teaser_end' => max(0, (int) ($row->teaser_end ?? 0)),
|
||
'no_export' => (bool) ($row->no_export ?? false),
|
||
'published_at' => $publishedAt,
|
||
]
|
||
);
|
||
|
||
// created_at/updated_at sind nicht fillable und werden von
|
||
// updateOrCreate verworfen → direkt setzen, damit das Anlage-/
|
||
// Bearbeitungsdatum bereits durch den Import korrekt migriert ist
|
||
// (legacy:fix-timestamps bleibt der schnelle Cross-DB-Resync).
|
||
$pr->forceFill([
|
||
'created_at' => $row->created_at ?? now(),
|
||
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
|
||
])->save();
|
||
|
||
return $pr;
|
||
});
|
||
|
||
foreach ($images->get($row->id, []) as $img) {
|
||
PressReleaseImage::withoutGlobalScopes()->updateOrCreate(
|
||
['legacy_portal' => $legacyPortal, 'legacy_id' => $img->id],
|
||
[
|
||
'press_release_id' => $pr->id,
|
||
'path' => $img->image,
|
||
'title' => $img->title ?: null,
|
||
'description' => $img->description ?: null,
|
||
'copyright' => $img->copyright ?: null,
|
||
'is_preview' => (bool) $img->is_preview_image,
|
||
'sort_order' => 0,
|
||
]
|
||
);
|
||
}
|
||
|
||
// Kontakt-Pivot importieren
|
||
$contactIds = [];
|
||
foreach ($prContacts->get($row->id, []) as $pivot) {
|
||
$contactMap = LegacyImportMap::query()
|
||
->where('legacy_portal', $legacyPortal)
|
||
->where('legacy_table', 'contact')
|
||
->where('legacy_id', $pivot->contact_id)
|
||
->first();
|
||
|
||
if ($contactMap) {
|
||
$contactIds[] = $contactMap->target_id;
|
||
}
|
||
}
|
||
|
||
if ($contactIds !== []) {
|
||
$pr->contacts()->syncWithoutDetaching($contactIds);
|
||
}
|
||
|
||
LegacyImportMap::query()->updateOrCreate(
|
||
[
|
||
'legacy_portal' => $legacyPortal,
|
||
'legacy_table' => 'press_release',
|
||
'legacy_id' => $row->id,
|
||
],
|
||
[
|
||
'target_table' => 'press_releases',
|
||
'target_id' => $pr->id,
|
||
'imported_at' => now(),
|
||
]
|
||
);
|
||
|
||
if ($alreadyImported) {
|
||
$result->incrementUpdated();
|
||
} else {
|
||
$result->incrementImported();
|
||
}
|
||
}
|
||
|
||
private function uniqueSlug(string $base, string $portal, string $language, ?int $excludeId = null): string
|
||
{
|
||
$slug = $base;
|
||
$i = 2;
|
||
|
||
while (PressRelease::withoutGlobalScopes()
|
||
->where('portal', $portal)
|
||
->where('language', $language)
|
||
->where('slug', $slug)
|
||
->when($excludeId, fn ($q) => $q->where('id', '!=', $excludeId))
|
||
->exists()
|
||
) {
|
||
$slug = $base.'-'.$i++;
|
||
}
|
||
|
||
return $slug;
|
||
}
|
||
}
|