presseportale/app/Console/Commands/ExpireGrandfathering.php
Kevin Adametz c82f279fef press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)
- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

282 lines
11 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\ActivityType;
use App\Enums\UserPaymentOptionStatus;
use App\Mail\GrandfatheringExpired;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Beendet Bestandsschutz-Posten, deren explizites Legacy-Vertragsende
* (`grandfathered_until`, aus valid_until_date importiert) überschritten ist
* (Audit F06, Plan 5.2).
*
* Bisher endete ein solcher Vertrag nur im Zugriff (scopeGrandfatheredNow),
* nicht im Datensatz: Status blieb `grandfathered`, der MAN-Rechnungslauf
* hätte weiter abgerechnet, und niemand sagte dem Kunden Bescheid. Jetzt
* läuft der Befehl täglich vor dem Rechnungslauf in zwei Phasen:
*
* Phase 1 (Audit U11): je Kunde in einer Transaktion. Die Kandidaten werden
* unter Zeilensperre mit allen Auswahlbedingungen erneut gelesen und nur mit
* denselben Bedingungen auf `cancelled` gesetzt (`cancelled_at` und
* `grandfathering_expired_at` im selben Schreibzugriff). Eine zwischenzeitliche
* Verlängerung bleibt so erhalten; ein überlappender Lauf trifft null Zeilen
* und schreibt weder Ereignis noch Benachrichtigungsauftrag. Das
* ActivityLog-Ereignis liegt im selben Commit und zählt nur die tatsächlich
* getroffenen Posten.
*
* Phase 2 (Audit U12): alle Posten mit `grandfathering_expired_at` ohne
* `grandfathering_notified_at` werden je Kunde mit genau einer Mail
* nachgeholt und erst danach als benachrichtigt markiert. Scheitert das
* Einplanen, bleibt der Marker leer, der Befehl meldet FAILURE (Scheduler-
* Alarm) und der nächste Lauf holt die Mail nach – ohne den Vertrag erneut
* anzufassen. Gesperrte Adressen werden ohne Mail als erledigt markiert.
*/
class ExpireGrandfathering extends Command
{
protected $signature = 'billing:expire-grandfathering
{--dry-run : Nur anzeigen, welche Posten beendet und welche Kunden benachrichtigt würden}';
protected $description = 'Beendet Bestandsschutz-Posten mit überschrittenem Legacy-Vertragsende und informiert den Kunden.';
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
{
// E18 als Zusicherung statt nur als Kommentar (OPT-9): vor jeder
// Änderung, damit ein Lauf auf falscher Queue gar nichts anfasst.
if (($violation = $this->queueViolation()) !== null) {
$this->error($violation);
return self::FAILURE;
}
$candidates = $this->expiredQuery()->with('user')->get();
$pending = $this->pendingNotificationQuery()->with('user')->get();
if ($candidates->isEmpty() && $pending->isEmpty()) {
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende, keine offene Benachrichtigung.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
foreach ($candidates as $option) {
$this->line(sprintf('[dry-run] Posten #%d (User #%s) endete am %s', $option->id, $option->user_id, $option->grandfathered_until?->toDateString()));
}
foreach ($pending as $option) {
$this->line(sprintf('[dry-run] Posten #%d (User #%s): Benachrichtigung seit %s offen', $option->id, $option->user_id, $option->grandfathering_expired_at?->toDateTimeString()));
}
return self::SUCCESS;
}
$ended = $this->endExpired($candidates, $activityLog);
[$notified, $failed] = $this->notifyPending($this->pendingNotificationQuery()->with('user')->get(), $suppressions);
$this->info(sprintf('%d Bestandsschutz-Posten beendet, %d Kunden benachrichtigt.', $ended, $notified));
if ($failed > 0) {
$this->error(sprintf('%d Benachrichtigung(en) konnten nicht eingeplant werden – der nächste Lauf holt sie nach.', $failed));
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* Phase 1: bedingtes Beenden je Kunde. Liefert die Zahl der tatsächlich
* von diesem Lauf beendeten Posten.
*
* @param Collection<int, UserPaymentOption> $candidates
*/
private function endExpired(Collection $candidates, ActivityLog $activityLog): int
{
$ended = 0;
foreach ($candidates->groupBy('user_id') as $options) {
/** @var User|null $user */
$user = $options->first()?->user;
$ids = $options->pluck('id')->all();
$ended += DB::transaction(function () use ($ids, $user, $activityLog): int {
/** @var Collection<int, UserPaymentOption> $current */
$current = $this->expiredQuery()->whereIn('id', $ids)->lockForUpdate()->get();
if ($current->isEmpty()) {
return 0;
}
$now = now();
$affected = $this->expiredQuery()
->whereIn('id', $current->pluck('id')->all())
->update([
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => $now,
'grandfathering_expired_at' => $now,
]);
if ($affected === 0) {
return 0;
}
$latestEnd = $current->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
$activityLog->record(
ActivityType::SubscriptionChange,
sprintf('Bestandsschutz zum Vertragsende %s beendet (%d Posten).', $latestEnd?->format('d.m.Y'), $affected),
$user,
null,
['action' => 'expire_grandfathering', 'posts' => $affected, 'until' => $latestEnd?->toDateString()],
'system',
);
return $affected;
});
}
return $ended;
}
/**
* Phase 2: je Kunde genau eine Mail für alle offenen Posten.
* Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden].
*
* Idempotent (Audit N14): Je Kunde eine Transaktion — offene Posten unter
* Zeilensperre erneut lesen, den Marker per bedingtem UPDATE beanspruchen
* (0 Zeilen = ein anderer Lauf war schneller, nichts einplanen) und erst
* DANACH die Mail in derselben Transaktion einplanen. Scheitert das
* Einplanen, rollt der Marker mit zurück; der nächste Lauf holt nach.
*
* Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION`
* (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und
* ist damit Teil der Transaktion. handle() sichert das zu (OPT-9,
* queueViolation()). Bei einem Wechsel auf Redis o. ä. bräuchte
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
* `notified_at` nach Zustellung).
*
* @param Collection<int, UserPaymentOption> $pending
* @return array{0: int, 1: int}
*/
private function notifyPending(Collection $pending, EmailSuppressionService $suppressions): array
{
$notified = 0;
$failed = 0;
foreach ($pending->groupBy('user_id') as $userId => $options) {
/** @var User|null $user */
$user = $options->first()?->user;
$ids = $options->pluck('id')->all();
try {
$queued = DB::transaction(function () use ($ids, $user, $suppressions): bool {
/** @var Collection<int, UserPaymentOption> $open */
$open = $this->pendingNotificationQuery()->whereIn('id', $ids)->lockForUpdate()->get();
if ($open->isEmpty()) {
return false;
}
$claimed = $this->pendingNotificationQuery()
->whereIn('id', $open->pluck('id')->all())
->update(['grandfathering_notified_at' => now()]);
if ($claimed === 0) {
return false;
}
if ($user === null || $suppressions->isSuppressed($user->email)) {
// Gesperrte Adresse: ohne Mail als erledigt markiert.
return false;
}
$latestEnd = $open->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd));
return true;
});
} catch (\Throwable $exception) {
report($exception);
$this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage()));
$failed++;
continue;
}
if ($queued) {
$notified++;
}
}
return [$notified, $failed];
}
/**
* Prüft die Annahme aus notifyPending() (E18): Der Mail-Job wird in
* derselben DB-Transaktion eingereiht wie der Marker. Das gilt nur für
* die Datenbank-Queue auf der Standard-Verbindung — oder für `sync`
* (Tests, lokale Läufe), wo der Versand innerhalb der Transaktion läuft
* und ein Fehler den Marker mit zurückrollt. Jede andere Queue würde den
* Job unabhängig vom Commit einreihen: dann lieber FAILURE (Alarm) als
* stille Doppel- oder Nicht-Zustellung.
*/
private function queueViolation(): ?string
{
$default = (string) config('queue.default');
if ($default === 'sync') {
return null;
}
if ($default !== 'database') {
return sprintf('E18 verletzt: QUEUE_CONNECTION ist „%s“, die Benachrichtigung ist nur mit der Datenbank-Queue idempotent. Nichts geändert.', $default);
}
$queueConnection = config('queue.connections.database.connection');
if ($queueConnection !== null && $queueConnection !== '' && $queueConnection !== config('database.default')) {
return sprintf('E18 verletzt: DB_QUEUE_CONNECTION „%s“ weicht von der Standard-Verbindung „%s“ ab, der Job-Insert läge außerhalb der Transaktion. Nichts geändert.', $queueConnection, config('database.default'));
}
return null;
}
/**
* Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl,
* Sperre und UPDATE identisch (U11).
*
* @return Builder<UserPaymentOption>
*/
private function expiredQuery(): Builder
{
return UserPaymentOption::query()
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->whereNull('cancelled_at')
->whereNotNull('grandfathered_until')
->whereDate('grandfathered_until', '<', today());
}
/**
* Beendete Posten, deren Kunde noch nicht informiert wurde (U12).
*
* @return Builder<UserPaymentOption>
*/
private function pendingNotificationQuery(): Builder
{
return UserPaymentOption::query()
->whereNotNull('grandfathering_expired_at')
->whereNull('grandfathering_notified_at');
}
}