- 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>
282 lines
11 KiB
PHP
282 lines
11 KiB
PHP
<?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');
|
||
}
|
||
}
|