presseportale/app/Services/Import/PressReleaseImporter.php
Kevin Adametz 5a9aab7012 Migration: PM-Datum korrekt übernehmen, UUID stabil, Zwei-Phasen-Runbook
- 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>
2026-06-17 13:32:48 +00:00

277 lines
9.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}