presseportale/app/Console/Commands/MigrateLegacyMedia.php
Kevin Adametz e783adc54e technik: Statische Analyse wieder aussagekräftig machen
phpstan lief mit 390 Meldungen außerhalb der Baseline, der größte Teil davon
Falschmeldungen. Ursache: Larastan löste keinen einzigen Cast auf, weil alle
Models ihre Casts über die casts()-METHODE deklarieren (Laravel-11-Stil).
Laravel führt die erst zur Laufzeit mit $casts zusammen, Larastan instanziiert
Models aber ohne Konstruktor — $pressRelease->status galt deshalb als string
und jeder Enum-Vergleich als "immer falsch". Dafür gibt es den Schalter
parseModelCastsMethod, dessen Larastan-Default false ist.

Zweite Sammelursache waren fehlende Generics an den Relations-Methoden: Ohne
sie landete phpstan auf Illuminate\Database\Eloquent\Model und meldete jede
Modell-Eigenschaft und jeden eigenen Scope als unbekannt. Alle 102
Relationen tragen jetzt ihr @return <Related, $this>; das verbessert
nebenbei die Vervollständigung in der IDE.

Zusammen fällt die Zahl von 390 auf 169. Die neu erzeugte Baseline umfasst 166
Einträge, überwiegend Closure-Parameter, die als blankes Builder typisiert
sind, sowie zwei nachgeprüfte Falschmeldungs-Gruppen: die Concerns unter
app/Livewire werden ausschließlich aus Volt-Komponenten eingebunden, die
phpstan nicht analysiert, und Cashier typisiert findBillable() gegen den TRAIT
Billable, was phpstan nicht mit User vereinbaren kann — die Laufzeitprüfung
dort ist richtig und bleibt.

Was die Analyse an echten toten Zweigen zutage gefördert hat, ist mit
entfernt: eine doppelte Null-Prüfung im Nachweis-PDF, Prüfungen gegen
NOT-NULL-Spalten in der Rechnungsstellung und im Vorschau-Controller, eine
immer wahre Bedingung im Logo-Pfad und in RetireAgencyCompanyType, ein
überflüssiges ?? [] auf einer Symfony-Methode, die immer ein Array liefert,
sowie der unerreichbare null-Arm in publicPortalLinks(). MigrateLegacyMedia
deklariert seine Medientypen jetzt als Literal-Union, womit das match ohne
default nachweislich vollständig ist statt nur faktisch.

Damit das nicht wieder abdriftet — die Baseline stand seit Mai unverändert —
läuft die Analyse ab jetzt als eigener Schritt in der Test-Pipeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:07:51 +00:00

475 lines
17 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MigrateLegacyMedia extends Command
{
protected $signature = 'legacy:migrate-media
{--portal=all : Portal (presseecho|businessportal24|all)}
{--type=all : Medientyp (company-logos|press-release-images|all)}
{--base-path=dev/migration : Lokaler Ordner mit den Legacy-Dateien}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Bereits migrierte Dateien erneut überschreiben}
{--limit=0 : Maximal N Legacy-Datensätze pro Typ und Portal verarbeiten (0 = alle)}';
protected $description = 'Migriert Legacy-Firmenlogos und Pressemitteilungsbilder anhand der Legacy-DB in den finalen Storage.';
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
/**
* @var array<string, list<string>>
*/
private const SOURCE_DIRECTORIES = [
'company-logos' => [
'uploads/company',
'thumbnails/company',
],
'press-release-images' => [
'uploads/pressreleaseimage',
'uploads/pressreleaseimage_',
'uploads/_pressreleaseimage',
'thumbnails/pressreleaseimage',
],
];
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
$portals = $this->selectedPortals();
$types = $this->selectedTypes();
if ($portals === []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
if ($types === []) {
$this->error('Ungültiger Medientyp. Erlaubt: company-logos, press-release-images, all.');
return self::FAILURE;
}
$configuredBasePath = (string) $this->option('base-path');
$basePath = Str::startsWith($configuredBasePath, '/')
? rtrim($configuredBasePath, '/')
: base_path(trim($configuredBasePath, '/'));
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$limit = max(0, (int) $this->option('limit'));
$totals = $this->emptyStats();
foreach ($portals as $portal) {
foreach ($types as $type) {
$stats = match ($type) {
'company-logos' => $this->migrateCompanyLogos($portal, $basePath, $dryRun, $force, $limit),
'press-release-images' => $this->migratePressReleaseImages($portal, $basePath, $dryRun, $force, $limit),
};
foreach ($totals as $key => $value) {
$totals[$key] = $value + $stats[$key];
}
$this->line(sprintf(
'%s/%s: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d',
$portal,
$type,
$stats['legacy_rows'],
$stats['migrated'],
$stats['thumbnail_fallback'],
$stats['updated'],
$stats['already_synced'],
$stats['missing_target'],
$stats['missing_file'],
));
}
}
$this->newLine();
$this->info(sprintf(
'Gesamt: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d%s',
$totals['legacy_rows'],
$totals['migrated'],
$totals['thumbnail_fallback'],
$totals['updated'],
$totals['already_synced'],
$totals['missing_target'],
$totals['missing_file'],
$dryRun ? ' (Dry-Run)' : '',
));
return ($totals['missing_target'] + $totals['missing_file']) > 0
? self::FAILURE
: self::SUCCESS;
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function migrateCompanyLogos(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array
{
$stats = $this->emptyStats();
$sourceIndex = $this->sourceIndex($basePath, $portal, 'company-logos');
$processed = 0;
DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('company')
->whereNotNull('logo')
->where('logo', '!=', '')
->orderBy('id')
->chunk(500, function ($rows) use ($portal, $sourceIndex, $dryRun, $force, $limit, &$processed, &$stats): bool {
foreach ($rows as $row) {
if ($limit > 0 && $processed >= $limit) {
return false;
}
$processed++;
$stats['legacy_rows']++;
$company = Company::withoutGlobalScopes()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first(['id', 'logo_path', 'logo_variants']);
if (! $company) {
$stats['missing_target']++;
$this->warn("Ziel fehlt: {$portal}/company/{$row->id}");
continue;
}
$sourceFilename = $this->legacyFilename((string) $row->logo);
$sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null;
$destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}";
if ($this->isAlreadySynced($company->logo_path, $destinationPath, $force)) {
$stats['already_synced']++;
if (! $dryRun && (! is_array($company->logo_variants) || $company->logo_variants === [])) {
$variants = $this->imageService->generateMissingCompanyLogoVariants($destinationPath);
if ($variants !== []) {
$company->forceFill(['logo_variants' => $variants])->save();
$stats['updated']++;
}
}
continue;
}
if (! $sourcePath) {
$stats['missing_file']++;
$this->warn("Datei fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})");
continue;
}
if (! $dryRun) {
$this->copyToPublicStorage($sourcePath, $destinationPath, $force);
$company->forceFill([
'logo_path' => $destinationPath,
'logo_variants' => $this->imageService->generateMissingCompanyLogoVariants($destinationPath),
])->save();
}
$stats['migrated']++;
$stats['updated']++;
}
return true;
});
return $stats;
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function migratePressReleaseImages(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array
{
$stats = $this->emptyStats();
$sourceIndex = $this->sourceIndex($basePath, $portal, 'press-release-images');
$thumbnailIndex = $this->thumbnailIndex($basePath, $portal);
$processed = 0;
DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('press_release_image')
->orderBy('id')
->chunk(500, function ($rows) use ($portal, $sourceIndex, $thumbnailIndex, $dryRun, $force, $limit, &$processed, &$stats): bool {
foreach ($rows as $row) {
if ($limit > 0 && $processed >= $limit) {
return false;
}
if (blank($row->image)) {
continue;
}
$processed++;
$stats['legacy_rows']++;
$pressRelease = PressRelease::withoutGlobalScopes()
->where('legacy_portal', $portal)
->where('legacy_id', $row->press_release_id)
->first(['id']);
if (! $pressRelease) {
$stats['missing_target']++;
$this->warn("Ziel fehlt: {$portal}/press_release/{$row->press_release_id}");
continue;
}
$sourceFilename = $this->legacyFilename((string) $row->image);
$sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null;
$usedThumbnailFallback = false;
if (! $sourcePath) {
$sourcePath = $thumbnailIndex[(int) $row->id] ?? null;
$usedThumbnailFallback = $sourcePath !== null;
}
$destinationFilename = $usedThumbnailFallback ? basename($sourcePath) : $sourceFilename;
$destinationPath = "press-releases/{$pressRelease->id}/images/{$destinationFilename}";
$image = PressReleaseImage::withTrashed()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first();
if ($image && $this->isAlreadySynced($image->path, $destinationPath, $force)) {
$stats['already_synced']++;
if (! $dryRun && (! is_array($image->variants) || $image->variants === [])) {
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
if ($variants !== []) {
$image->forceFill(['variants' => $variants])->save();
$stats['updated']++;
}
}
continue;
}
if (! $sourcePath) {
$stats['missing_file']++;
$this->warn("Datei fehlt: {$portal}/press_release_image/{$sourceFilename} (Image #{$row->id})");
continue;
}
if ($usedThumbnailFallback) {
$stats['thumbnail_fallback']++;
}
$variants = [];
$size = [null, null];
$mime = null;
if (! $dryRun) {
$this->copyToPublicStorage($sourcePath, $destinationPath, $force);
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
$size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null];
$mime = File::mimeType($sourcePath) ?: null;
$image = $image ?: new PressReleaseImage;
$image->forceFill([
'press_release_id' => $pressRelease->id,
'disk' => 'public',
'path' => $destinationPath,
'variants' => $variants,
'title' => $row->title ?: null,
'description' => $row->description ?: null,
'copyright' => $row->copyright ?: null,
'is_preview' => (bool) $row->is_preview_image,
'sort_order' => 0,
'width' => is_int($size[0] ?? null) ? $size[0] : null,
'height' => is_int($size[1] ?? null) ? $size[1] : null,
'mime' => $mime,
'legacy_portal' => $portal,
'legacy_id' => $row->id,
]);
if ($image->trashed()) {
$image->restore();
}
$image->save();
}
$stats['migrated']++;
$stats['updated']++;
}
return true;
});
return $stats;
}
/**
* @return list<string>
*/
private function selectedPortals(): array
{
$portal = (string) $this->option('portal');
return match ($portal) {
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
default => [],
};
}
/**
* @return list<string>
*/
/**
* @return list<'company-logos'|'press-release-images'>
*/
private function selectedTypes(): array
{
$type = (string) $this->option('type');
return match ($type) {
'all' => ['company-logos', 'press-release-images'],
'company-logos', 'press-release-images' => [$type],
default => [],
};
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function emptyStats(): array
{
return [
'legacy_rows' => 0,
'migrated' => 0,
'thumbnail_fallback' => 0,
'updated' => 0,
'already_synced' => 0,
'missing_target' => 0,
'missing_file' => 0,
];
}
/**
* @return array<string, string>
*/
private function sourceIndex(string $basePath, string $portal, string $type): array
{
$index = [];
foreach (self::SOURCE_DIRECTORIES[$type] as $relativeDirectory) {
$directory = "{$basePath}/{$portal}/{$relativeDirectory}";
if (! File::isDirectory($directory)) {
continue;
}
foreach (File::allFiles($directory) as $file) {
$index[$file->getFilename()] ??= $file->getPathname();
$index[Str::lower($file->getFilename())] ??= $file->getPathname();
}
}
return $index;
}
/**
* @return array<int, string>
*/
private function thumbnailIndex(string $basePath, string $portal): array
{
$directory = "{$basePath}/{$portal}/thumbnails/pressreleaseimage";
if (! File::isDirectory($directory)) {
return [];
}
$index = [];
$priorities = [
'press_image_preview' => 50,
'pressrelease_form' => 40,
'backend_list' => 30,
'press_image_list' => 20,
'press_list_image' => 10,
];
foreach (File::allFiles($directory) as $file) {
if (! preg_match('/-(\d+)\.[^.]+$/', $file->getFilename(), $matches)) {
continue;
}
$legacyImageId = (int) $matches[1];
$thumbnailType = Str::after($file->getPathname(), "{$directory}/");
$thumbnailType = Str::before($thumbnailType, DIRECTORY_SEPARATOR);
$priority = $priorities[$thumbnailType] ?? 0;
$current = $index[$legacyImageId] ?? null;
if (! $current || $priority > ($current['priority'] ?? 0)) {
$index[$legacyImageId] = [
'path' => $file->getPathname(),
'priority' => $priority,
];
}
}
return collect($index)
->map(fn (array $entry): string => $entry['path'])
->all();
}
private function legacyFilename(string $path): string
{
$urlPath = parse_url($path, PHP_URL_PATH);
$filename = basename(rawurldecode((string) ($urlPath ?: $path)));
return Str::of($filename)->trim()->toString();
}
private function isAlreadySynced(?string $currentPath, string $destinationPath, bool $force): bool
{
return ! $force
&& $currentPath === $destinationPath
&& Storage::disk('public')->exists($destinationPath);
}
private function copyToPublicStorage(string $sourcePath, string $destinationPath, bool $force): void
{
if (! $force && Storage::disk('public')->exists($destinationPath)) {
return;
}
$stream = fopen($sourcePath, 'rb');
if ($stream === false) {
throw new \RuntimeException("Quelldatei kann nicht gelesen werden: {$sourcePath}");
}
try {
Storage::disk('public')->put($destinationPath, $stream, 'public');
} finally {
fclose($stream);
}
}
}