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:
parent
8d8d957884
commit
4419d9ff43
21 changed files with 551 additions and 114 deletions
|
|
@ -44,9 +44,15 @@ class PublishScheduledPressReleases extends Command
|
|||
|
||||
$now = now();
|
||||
|
||||
// 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.
|
||||
$candidates = PressRelease::withoutGlobalScopes()
|
||||
->where('status', PressReleaseStatus::Review->value)
|
||||
->where('classification', PressReleaseClassification::Green->value)
|
||||
->whereIn('classification', [
|
||||
PressReleaseClassification::Green->value,
|
||||
PressReleaseClassification::Yellow->value,
|
||||
])
|
||||
->whereNotNull('scheduled_at')
|
||||
->where('scheduled_at', '<=', $now)
|
||||
->orderBy('scheduled_at')
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ use App\Http\Resources\PressReleaseResource;
|
|||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
|
@ -141,6 +143,14 @@ class PressReleaseController extends Controller
|
|||
|
||||
try {
|
||||
$service->submitForReview($pressRelease);
|
||||
} catch (BookingRequiredException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 402);
|
||||
} catch (QuotaExceededException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
} catch (BlacklistViolationException $exception) {
|
||||
return response()->json([
|
||||
'message' => $exception->getMessage(),
|
||||
|
|
|
|||
|
|
@ -92,6 +92,24 @@ class User extends Authenticatable
|
|||
return max(0, (int) $this->press_release_quota - (int) $this->press_release_quota_used_this_month);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit-Gate aus dem Decision-Update §5.1: Einreichen zur Prüfung
|
||||
* erfordert eine aktive Buchung.
|
||||
*
|
||||
* Stub bis zum Tarif-Modul (Phase 9D/9E): solange
|
||||
* `billing.enforce_booking` deaktiviert ist (Default), gilt jede:r als
|
||||
* gebucht. Das Tarif-Modul ersetzt den Rumpf durch die echte
|
||||
* Subscription-/Einzelkauf-Prüfung — die Schnittstelle bleibt stabil.
|
||||
*/
|
||||
public function hasActiveBooking(): bool
|
||||
{
|
||||
if (! config('billing.enforce_booking')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's initials
|
||||
*/
|
||||
|
|
|
|||
17
app/Services/PressRelease/BookingRequiredException.php
Normal file
17
app/Services/PressRelease/BookingRequiredException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
app/Services/PressRelease/QuotaExceededException.php
Normal file
17
app/Services/PressRelease/QuotaExceededException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue