presseportale/app/Console/Commands/FixApiPortalDuplicates.php
Kevin Adametz 6e159ecf06 deps: Composer- und npm-Pakete innerhalb der Constraints aktualisiert
Laravel 12.69, Livewire 4.4, Flux 2.20, Cashier 16.8 (Stripe-SDK 21),
Larastan 3.12; Vite 6.4.3, Tailwind 4.3, Alpine 3.17 - npm audit 0.
Drei neue Larastan-Befunde behoben, drei tote Baseline-Einträge entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 15:04:52 +00:00

208 lines
7.9 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\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Verteilt per API doppelt eingegangene Pressemitteilungen auf die Portale.
*
* Hintergrund (Diagnose 13.08.2026): Legacy-API-Kunden mit Keys beider
* Altportale (z. B. connektar) senden jede PM an presseecho.de UND
* businessportal24.de. Da Firmen- und PM-Portal bisher vom User geerbt
* wurden, landeten beide Kopien im selben Portal — die zweite mit
* Slug-Suffix "-2". Dieses Command findet solche Paare (gleicher User,
* Titel und Sprache, gleiches Portal, kurz nacheinander per API
* eingereicht) und verschiebt die jeweils zweite Kopie samt ihrer
* exklusiven Firma ins jeweils andere Portal; Slugs werden dabei im
* Zielportal neu (ohne Suffix, sofern frei) vergeben.
*
* Web-Doppel-Submits (kein API-Status-Log) und Legacy-Sync-Importe
* (legacy_id gesetzt) werden nicht angefasst.
*
* Verwendung:
* php artisan press-releases:fix-api-portal-duplicates --dry-run
* php artisan press-releases:fix-api-portal-duplicates
* php artisan press-releases:fix-api-portal-duplicates --user=13993 --window=120
*/
class FixApiPortalDuplicates extends Command
{
protected $signature = 'press-releases:fix-api-portal-duplicates
{--since=2026-07-20 : Nur PMs ab diesem Datum (API-Go-Live) berücksichtigen}
{--window=60 : Max. Minuten zwischen den beiden Einreichungen eines Paars}
{--user= : Optional auf eine User-ID beschränken}
{--dry-run : Nur anzeigen, nichts schreiben}';
protected $description = 'Verschiebt per API doppelt eingereichte PMs (Slug-Suffix "-2") samt Firma ins jeweils andere Portal.';
public function handle(): int
{
$since = (string) $this->option('since');
$windowMinutes = max(1, (int) $this->option('window'));
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
$isDryRun = (bool) $this->option('dry-run');
if ($isDryRun) {
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
}
$pairs = $this->findDuplicatePairs($since, $windowMinutes, $userId);
if ($pairs->isEmpty()) {
$this->info('Keine API-Duplikat-Paare gefunden.');
return self::SUCCESS;
}
$movedPressReleases = 0;
$movedCompanies = 0;
$rows = [];
foreach ($pairs as [$keep, $move]) {
$target = $move->portal === Portal::Businessportal24
? Portal::Presseecho
: Portal::Businessportal24;
$newSlug = $move->generateUniqueSlug($move->title, [
'portal' => $target->value,
'language' => $move->language,
]);
$company = $this->exclusiveCompanyOf($move);
$rows[] = [
$move->id,
mb_strimwidth($move->title, 0, 50, '…'),
"{$move->portal->value} → {$target->value}",
"{$move->slug} → {$newSlug}",
$company ? "#{$company->id} {$company->slug}" : '–',
$keep->id,
];
if (! $isDryRun) {
DB::transaction(function () use ($move, $company, $target, $newSlug, &$movedCompanies): void {
$move->portal = $target;
$move->slug = $newSlug;
$move->saveWithUniqueSlugRetry($move->title, [
'portal' => $target->value,
'language' => $move->language,
]);
if ($company) {
$company->portal = $target;
$company->slug = $company->generateUniqueSlug($company->name, [
'portal' => $target->value,
]);
$company->saveWithUniqueSlugRetry($company->name, [
'portal' => $target->value,
]);
$movedCompanies++;
}
});
} elseif ($company) {
$movedCompanies++;
}
$movedPressReleases++;
}
$this->table(
['PM', 'Titel', 'Portal', 'Slug', 'Firma zieht mit', 'Original bleibt'],
$rows,
);
$verb = $isDryRun ? 'würden verschoben werden' : 'verschoben';
$this->info("{$movedPressReleases} Pressemitteilungen und {$movedCompanies} Firmen {$verb}.");
return self::SUCCESS;
}
/**
* Findet Duplikat-Paare: gleicher User, Titel, Sprache und gleiches
* Portal, beide per API eingereicht (Status-Log source=api), innerhalb
* des Zeitfensters nacheinander angelegt. Bei mehr als zwei Treffern
* pro Gruppe (erneute Einreichung derselben PM) werden die Einträge
* chronologisch zu Paaren zusammengefasst.
*
* @return Collection<int, array{0: PressRelease, 1: PressRelease}>
*/
private function findDuplicatePairs(string $since, int $windowMinutes, ?int $userId): Collection
{
$candidates = PressRelease::query()
->withoutGlobalScopes([PortalScope::class])
->whereNull('legacy_id')
->whereIn('portal', [Portal::Presseecho->value, Portal::Businessportal24->value])
->where('created_at', '>=', $since)
->when($userId !== null, fn (Builder $query) => $query->where('user_id', $userId))
->whereExists(function ($query): void {
$query->select(DB::raw(1))
->from('press_release_status_logs')
->whereColumn('press_release_status_logs.press_release_id', 'press_releases.id')
->where('press_release_status_logs.source', 'api');
})
->orderBy('created_at')
->orderBy('id')
->get();
return $candidates
->groupBy(fn (PressRelease $pressRelease): string => implode('|', [
$pressRelease->user_id,
$pressRelease->portal->value,
$pressRelease->language,
$pressRelease->title,
]))
->filter(fn (Collection $group): bool => $group->count() >= 2)
->flatMap(function (Collection $group) use ($windowMinutes): array {
$pairs = [];
$items = $group->values()->all();
for ($i = 0; $i < count($items) - 1; $i++) {
$first = $items[$i];
$second = $items[$i + 1];
if ($first->created_at->diffInMinutes($second->created_at) <= $windowMinutes) {
$pairs[] = [$first, $second];
$i++;
}
}
return $pairs;
})
->values();
}
/**
* Firma der verschobenen PM, sofern sie exklusiv zu dieser PM gehört
* (Legacy-API-Muster: pro Einreichung wird eine frische Firma angelegt).
* Firmen mit weiteren PMs oder aus dem Legacy-Sync bleiben unangetastet.
*/
private function exclusiveCompanyOf(PressRelease $pressRelease): ?Company
{
if ($pressRelease->company_id === null) {
return null;
}
$company = Company::query()
->withoutGlobalScopes([PortalScope::class])
->find($pressRelease->company_id);
if (! $company || $company->legacy_id !== null) {
return null;
}
$hasOtherPressReleases = PressRelease::query()
->withoutGlobalScopes([PortalScope::class])
->where('company_id', $company->id)
->whereKeyNot($pressRelease->id)
->exists();
return $hasOtherPressReleases ? null : $company;
}
}