option('dry-run'); $limit = max(1, (int) $this->option('limit')); $now = now(); $published = 0; $rejected = 0; $failed = 0; $rechecked = 0; $blocked = 0; // Fairer Fortschritt (Umsetzungsaudit 02.10.2026, O06): Fachlich // blockierte Meldungen bleiben fällig und stünden sonst bei jedem // Lauf vorn — eine volle Seite davon hielte jede jüngere Meldung auf. // Deshalb seitenweise per Cursor über (scheduled_at, id); das Limit // gilt für bearbeitete Meldungen, Blockaden zählen nur auf ein // Scan-Budget, das den Lauf begrenzt. $scanBudget = $limit * 10; $scanned = 0; $handled = 0; $cursor = null; while ($handled < $limit && $scanned < $scanBudget) { $page = $this->duePage($now, $cursor, min($limit, $scanBudget - $scanned)); if ($page->isEmpty()) { break; } foreach ($page as $pressRelease) { $scanned++; $cursor = [$pressRelease->scheduled_at, $pressRelease->id]; $line = sprintf( ' #%d scheduled_at=%s title="%s"', $pressRelease->id, $pressRelease->scheduled_at?->format('Y-m-d H:i') ?? '-', Str::limit($pressRelease->title, 60), ); if ($dryRun) { $this->line($line.' [DRY]'); $handled++; if ($handled >= $limit) { break; } continue; } $outcome = $this->processCandidate($service, $activityLog, $pressRelease, $line); match ($outcome) { 'published' => $published++, 'rejected' => $rejected++, 'rechecked' => $rechecked++, 'blocked' => $blocked++, default => $failed++, }; if ($outcome !== 'blocked') { $handled++; } if ($handled >= $limit) { break; } } } if ($scanned === 0) { $this->info('Keine fälligen geplanten Pressemitteilungen gefunden.'); return self::SUCCESS; } if ($dryRun) { $this->info(sprintf('%d fällige Pressemitteilung(en) gefunden. (Dry-Run)', $scanned)); } if (! $dryRun) { $this->newLine(); $this->info(sprintf( 'Fertig: %d veröffentlicht, %d wegen Blacklist abgelehnt, %d neu zur Prüfung, %d blockiert, %d fehlgeschlagen.', $published, $rejected, $rechecked, $blocked, $failed, )); } return $failed > 0 ? self::FAILURE : self::SUCCESS; } /** * Nächste Seite fälliger Kandidaten nach dem Cursor (scheduled_at, id). * Gelb und Grün gehen zum Termin automatisch live (Decision-Update §5.0); * nur Rot wird abgelehnt. Unklassifizierte PMs bleiben als Fallback in * der manuellen Queue. * * @param array{0: mixed, 1: int}|null $cursor * @return Collection */ private function duePage(CarbonInterface $now, ?array $cursor, int $size): Collection { return PressRelease::withoutGlobalScope(PortalScope::class) ->where('status', PressReleaseStatus::Review->value) ->whereIn('classification', [ PressReleaseClassification::Green->value, PressReleaseClassification::Yellow->value, ]) ->whereNotNull('scheduled_at') ->where('scheduled_at', '<=', $now) ->whereNull('manual_review_required_at') ->when($cursor !== null, fn ($query) => $query->where(fn ($query) => $query ->where('scheduled_at', '>', $cursor[0]) ->orWhere(fn ($query) => $query->where('scheduled_at', $cursor[0])->where('id', '>', $cursor[1])))) ->orderBy('scheduled_at') ->orderBy('id') ->limit(max(1, $size)) ->get(); } /** * Eine fällige Meldung veröffentlichen oder fachlich einordnen. * * @return 'published'|'rejected'|'rechecked'|'blocked'|'failed' */ private function processCandidate(PressReleaseService $service, ActivityLog $activityLog, PressRelease $pressRelease, string $line): string { if ($this->classificationIsStale($pressRelease)) { ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $pressRelease->content_hash)->onQueue('classification'); $this->warn($line.' [RECHECK: Inhalt nach der Prüfung geändert]'); return 'rechecked'; } try { $service->publish($pressRelease, source: 'scheduler'); $this->line($line.' [OK]'); return 'published'; } catch (BlacklistViolationException $e) { $this->warn($line.' [REJECT: '.$e->word.']'); return 'rejected'; } catch (StaleClassificationException) { $this->warn($line.' [RECHECK: Inhalt unter der Sperre abweichend, Prüfung neu eingeplant]'); return 'rechecked'; } catch (ManualReviewRequiredException) { $this->warn($line.' [BLOCKIERT: wartet auf manuelle Prüfung]'); return 'blocked'; } catch (QuotaExceededException) { $this->warn($line.' [BLOCKIERT: Kontingent aufgebraucht]'); $this->recordBlockedOnce($activityLog, $pressRelease, 'quota', 'Kontingent aufgebraucht'); return 'blocked'; } catch (ImageRightsConfirmationRequiredException) { $this->warn($line.' [BLOCKIERT: Titelbild nicht bestätigt]'); $this->recordBlockedOnce($activityLog, $pressRelease, 'image_rights', 'Titelbild vom Kontoinhaber noch nicht bestätigt'); return 'blocked'; } catch (Throwable $e) { $this->error($line.' [FAIL: '.$e->getMessage().']'); report($e); return 'failed'; } } /** * Fachliche Sperre einmal je Sperrgrund festhalten (K-02): Ein Eintrag im * Aktivitäts-Verlauf der Meldung (für Admins sichtbar) und eine * Log-Warnung — nicht bei jedem 5-Minuten-Lauf erneut. Ein Statuswechsel * der Meldung beginnt eine neue Episode. */ private function recordBlockedOnce(ActivityLog $activityLog, PressRelease $pressRelease, string $reason, string $label): void { $description = sprintf('PM #%d: Termin erreicht, aber nicht veröffentlicht — %s.', $pressRelease->id, $label); $since = PressReleaseStatusLog::query() ->where('press_release_id', $pressRelease->id) ->max('created_at'); $alreadyRecorded = ActivityEvent::query() ->where('subject_type', $pressRelease->getMorphClass()) ->where('subject_id', $pressRelease->id) ->where('description', $description) ->when($since !== null, fn ($query) => $query->where('occurred_at', '>=', $since)) ->exists(); if ($alreadyRecorded) { return; } Log::warning('Geplante Veröffentlichung blockiert.', ['press_release_id' => $pressRelease->id, 'reason' => $reason]); $activityLog->record( ActivityType::PressReleaseStatus, $description, subject: $pressRelease, meta: ['publish_blocked' => $reason], actorLabel: 'scheduler', ); } /** * Jüngste gültige Klassifikation mit Prüfsumme, die nicht zum aktuellen * Inhalt passt. Altbestand ohne Prüfsumme (vor der Bindung) gilt weiter. */ private function classificationIsStale(PressRelease $pressRelease): bool { $latestHash = KiAudit::query() ->where('press_release_id', $pressRelease->id) ->where('type', KiAudit::TYPE_CLASSIFICATION) ->whereNull('discarded_at') ->orderByDesc('id') ->value('content_hash'); return $latestHash !== null && $pressRelease->content_hash !== null && $latestHash !== $pressRelease->content_hash; } }