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 */ 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(); for ($i = 0; $i < $items->count() - 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; } }