Phase 9 Block 1: Gelb-Routing Direkt-Live, Slot-Verbrauch bei Veroeffentlichung, Submit-Gate

9A — Gelb geht direkt live (Entscheidung 12.06.2026):
- routeByClassification(): Gelb durchlaeuft denselben Auto-Publish-Pfad
  wie Gruen (autoPublishApproved); nur Rot wird abgelehnt
- Scheduler publiziert faellige gelbe + gruene PMs; unklassifizierte
  bleiben als Fallback in der manuellen Queue

9B — Slot-Verbrauch bei Veroeffentlichung (Decision-Update 3.2):
- Increment aus submitForReview() entfernt; publish() und
  changeStatusFromAdmin() zaehlen idempotent beim ersten
  published-Uebergang (Pruefung ueber Status-Logs); Rot kostet nichts
- Submit-Guard: Einreichen erfordert freien Slot
  (QuotaExceededException, API 422)

9C — Submit-Gate vorbereitet (Decision-Update 5.1):
- User::hasActiveBooking()-Stub hinter config/billing.php
  (enforce_booking, Default aus); Tarif-Modul ersetzt nur den Rumpf
- Einreichungs-Modal zeigt ohne Buchung einen Buchungs-Hinweis;
  Server-Guard (BookingRequiredException), API antwortet 402
- Fix: Customer-Create legte PMs bei "Zur Pruefung senden" direkt mit
  Status review an (vorbei an Blacklist/Quota/KI/Status-Log) — laeuft
  jetzt immer ueber submitForReview()

Suite: 451 passed, 4 skipped (9 neue Tests). Pint clean.
Plan: docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md (Block 2 nach Review-Stopp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 09:47:06 +00:00
parent 8d8d957884
commit 4419d9ff43
21 changed files with 551 additions and 114 deletions

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Submit-Gate (Decision-Update §5.1): Einreichen zur Prüfung erfordert eine
* aktive Buchung. Speichern als Entwurf bleibt immer frei.
*/
class BookingRequiredException extends RuntimeException
{
public function __construct(string $message = 'Für das Einreichen zur Prüfung wird eine aktive Buchung benötigt.')
{
parent::__construct($message);
}
}

View file

@ -32,6 +32,22 @@ class PressReleaseService
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
$user = $pressRelease->user;
// Submit-Gate (Decision-Update §5.1): Einreichen erfordert eine aktive
// Buchung. Bis zum Tarif-Modul steuert billing.enforce_booking den Stub.
if ($user && ! $user->hasActiveBooking()) {
throw new BookingRequiredException;
}
// Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht
// (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn
// noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne
// verfügbares Kontingent automatisch veröffentlicht.
if ($user && $user->pressReleaseQuotaRemaining() <= 0) {
throw new QuotaExceededException;
}
$previous = $pressRelease->status;
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
@ -47,10 +63,6 @@ class PressReleaseService
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
// Quota-Stub: zählt den Monatsverbrauch des Autors hoch. Wird vom
// echten Tarif-Modul später abgelöst (Schnittstelle bleibt stabil).
$pressRelease->user?->increment('press_release_quota_used_this_month');
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
// des Ergebnisses übernimmt der Job über routeByClassification().
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
@ -92,11 +104,14 @@ class PressReleaseService
/**
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses
* (Konzept §15.1). Wird vom ClassifyPressRelease-Job aufgerufen.
* (Decision-Update §5.0, Entscheidung 12.06.2026). Wird vom
* ClassifyPressRelease-Job aufgerufen.
*
* - Rot Ablehnung mit Begründung an den Autor
* - Gelb bleibt in der manuellen Review-Queue
* - Grün automatische Veröffentlichung (sofort bzw. zum geplanten Termin)
* - Rot Ablehnung mit Begründung an den Autor
* - Gelb/Grün automatische Veröffentlichung (sofort bzw. zum Termin);
* Gelb bleibt als interne Markierung erhalten (nicht
* boostbar, Admin-Signal), löst aber keine manuelle
* Prüfung aus
*
* Greift nur, solange die PM noch im Status `review` steht; manuelle
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
@ -113,22 +128,18 @@ class PressReleaseService
return;
}
if ($classification === PressReleaseClassification::Green) {
$this->autoPublishGreen($pressRelease);
}
// Gelb: keine Aktion bleibt zur manuellen Prüfung im Status „review".
$this->autoPublishApproved($pressRelease);
}
/**
* Veröffentlicht eine grün klassifizierte PM automatisch.
* Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch.
*
* Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
* Scheduler die Publikation zum Termin. Andernfalls wird sofort
* publiziert optional mit einem Sicherheitsfenster
* (scoring.classification.green_delay_minutes).
*/
private function autoPublishGreen(PressRelease $pressRelease): void
private function autoPublishApproved(PressRelease $pressRelease): void
{
if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
return;
@ -156,6 +167,8 @@ class PressReleaseService
throw new BlacklistViolationException($reason, $word);
}
$this->consumePublishSlot($pressRelease);
$pressRelease->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
@ -165,6 +178,27 @@ class PressReleaseService
$this->notifyAuthor($pressRelease, 'published');
}
/**
* Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers
* (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte
* PMs kosten nichts). Erneutes Publizieren etwa nach Archivierung
* zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem
* Schreiben des neuen Status-Logs aufgerufen werden.
*/
private function consumePublishSlot(PressRelease $pressRelease): void
{
$alreadyPublishedOnce = PressReleaseStatusLog::query()
->where('press_release_id', $pressRelease->id)
->where('to_status', PressReleaseStatus::Published->value)
->exists();
if ($alreadyPublishedOnce) {
return;
}
$pressRelease->user?->increment('press_release_quota_used_this_month');
}
/**
* Bestimmt das wirksame `published_at` einer PM.
*
@ -234,6 +268,10 @@ class PressReleaseService
{
$previous = $pressRelease->status;
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
$this->consumePublishSlot($pressRelease);
}
$pressRelease->update([
'status' => $status->value,
'published_at' => $status === PressReleaseStatus::Published

View file

@ -0,0 +1,17 @@
<?php
namespace App\Services\PressRelease;
use RuntimeException;
/**
* Das monatliche PM-Kontingent des Autors ist aufgebraucht Einreichen ist
* erst nach Reset/Upgrade (später: Extra-PM) wieder möglich.
*/
class QuotaExceededException extends RuntimeException
{
public function __construct(string $message = 'Das monatliche PM-Kontingent ist aufgebraucht.')
{
parent::__construct($message);
}
}