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>
208 lines
7.9 KiB
PHP
208 lines
7.9 KiB
PHP
<?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;
|
||
}
|
||
}
|