press-releases: begrenzter Pruefkreislauf mit Admin-Queue (Roadmap 1d.4, E3)
Automatische Pruefung je Meldung hoechstens max_automatic_attempts (3); das letzte Rot blockt statt abzulehnen: Meldung bleibt in review, Admin sieht sie unter "Pruefung noetig" mit allen KI-Begruendungen und entscheidet (Freischalten / Mit Kommentar zurueck). Danach ist die Automatik fuer diese Meldung aus - jede Freigabe geht direkt zum Menschen. Gelb: yellow_to_manual_queue wird jetzt ausgewertet (Vorgabe false). Kundenseite: "Zur Pruefung freigeben" mit Erklaerung der drei Schritte, Versuchszaehler an der Ablehnung, Hinweis auf menschliche Pruefung. Blade: inline @php(...) vor einem @php...@endphp-Block wird als Rohblock gelesen - vier Stellen auf Blockform umgestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f7fbd0c65
commit
13716b9c06
17 changed files with 487 additions and 47 deletions
|
|
@ -82,6 +82,8 @@ class PressRelease extends Model
|
|||
'status',
|
||||
'classification',
|
||||
'classified_at',
|
||||
'auto_check_attempts',
|
||||
'manual_review_required_at',
|
||||
'content_score',
|
||||
'content_tier',
|
||||
'scored_at',
|
||||
|
|
@ -110,6 +112,8 @@ class PressRelease extends Model
|
|||
'status' => PressReleaseStatus::class,
|
||||
'classification' => PressReleaseClassification::class,
|
||||
'classified_at' => 'datetime',
|
||||
'auto_check_attempts' => 'integer',
|
||||
'manual_review_required_at' => 'datetime',
|
||||
'content_score' => 'integer',
|
||||
'content_tier' => PressReleaseContentTier::class,
|
||||
'scored_at' => 'datetime',
|
||||
|
|
@ -529,6 +533,23 @@ class PressRelease extends Model
|
|||
return $this->gone_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatik erschöpft (E3): Für diese Meldung entscheidet nur noch ein
|
||||
* Mensch — jede Freigabe geht ohne KI-Lauf in die Admin-Queue.
|
||||
*/
|
||||
public function requiresManualReview(): bool
|
||||
{
|
||||
return $this->manual_review_required_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet geblockt auf die menschliche Entscheidung.
|
||||
*/
|
||||
public function isAwaitingManualReview(): bool
|
||||
{
|
||||
return $this->status === PressReleaseStatus::Review && $this->requiresManualReview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Instanz-Pendant zu scopeVisibleInArchive(): wertet die öffentliche
|
||||
* Sichtbarkeit aus den bereits geladenen Attributen aus, ohne erneute
|
||||
|
|
|
|||
|
|
@ -94,20 +94,35 @@ class PressReleaseService
|
|||
// Zeitpunkt wird hier zur Nachweisbarkeit protokolliert. (Spalte
|
||||
// heißt historisch withdrawal_waiver_consented_at; die frühere
|
||||
// Erlöschens-Checkbox entfiel mit der B2B-Umstellung, E2.)
|
||||
// Begrenzter Prüfkreislauf (E3, 16.09.2026): Ist die Automatik für
|
||||
// diese Meldung erschöpft, geht die Freigabe ohne KI-Lauf direkt an
|
||||
// einen Menschen. Sonst zählt dieser Lauf.
|
||||
$manual = $pressRelease->requiresManualReview();
|
||||
|
||||
$pressRelease->update([
|
||||
'status' => PressReleaseStatus::Review->value,
|
||||
'withdrawal_waiver_consented_at' => now(),
|
||||
'auto_check_attempts' => $manual ? $pressRelease->auto_check_attempts : $pressRelease->auto_check_attempts + 1,
|
||||
]);
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, $source);
|
||||
$this->logStatusChange(
|
||||
$pressRelease,
|
||||
$previous,
|
||||
PressReleaseStatus::Review,
|
||||
$manual ? 'Erneute Freigabe nach erschöpfter automatischer Prüfung — wartet auf manuelle Entscheidung.' : null,
|
||||
$source,
|
||||
);
|
||||
|
||||
// Versionierter Audit-Trail der KI-Deklaration: jeder Submit erzeugt
|
||||
// einen neuen, unveränderlichen Eintrag (Abs.-50-Verteidigung —
|
||||
// "Kunde hat am Datum X Option Y deklariert und bestätigt").
|
||||
$this->logAiDeclaration($pressRelease);
|
||||
|
||||
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
|
||||
// des Ergebnisses übernimmt der Job über routeByClassification().
|
||||
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||
if (! $manual) {
|
||||
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing
|
||||
// anhand des Ergebnisses übernimmt der Job über
|
||||
// routeByClassification().
|
||||
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||
}
|
||||
|
||||
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
|
||||
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||
|
|
@ -183,14 +198,56 @@ class PressReleaseService
|
|||
}
|
||||
|
||||
if ($classification === PressReleaseClassification::Red) {
|
||||
$max = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
|
||||
|
||||
// E3: Nach dem letzten erlaubten Rot nicht noch einmal ablehnen,
|
||||
// sondern geblockt in die Admin-Queue — ab hier entscheidet ein
|
||||
// Mensch, und die Automatik ist für diese Meldung aus.
|
||||
if ($pressRelease->auto_check_attempts >= $max) {
|
||||
$this->holdForManualReview($pressRelease, sprintf(
|
||||
'Automatische Prüfung erschöpft (%d von %d): %s',
|
||||
$pressRelease->auto_check_attempts,
|
||||
$max,
|
||||
$reason ?: 'Rot ohne Begründung.',
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Gelb geht durch (Entscheidung 16.09.2026) — außer der Schalter für
|
||||
// die manuelle Queue ist bewusst eingeschaltet.
|
||||
if ($classification === PressReleaseClassification::Yellow && config('scoring.classification.yellow_to_manual_queue')) {
|
||||
$this->holdForManualReview($pressRelease, 'Gelb — manuelle Prüfung laut Konfiguration.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->autoPublishApproved($pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung bleibt in `review`, wird als „Prüfung nötig" markiert und
|
||||
* wartet auf die Entscheidung im Admin. Kein Slot-Verbrauch, keine Mail
|
||||
* an den Autor — der sieht den Stand in seinem Konto.
|
||||
*/
|
||||
private function holdForManualReview(PressRelease $pressRelease, string $reason): void
|
||||
{
|
||||
$pressRelease->update(['manual_review_required_at' => now()]);
|
||||
$this->logStatusChange($pressRelease, PressReleaseStatus::Review, PressReleaseStatus::Review, $reason, 'ki');
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::PressReleaseStatus,
|
||||
sprintf('PM #%d wartet auf manuelle Prüfung: %s', $pressRelease->id, mb_substr($reason, 0, 160)),
|
||||
subject: $pressRelease,
|
||||
actorLabel: 'ki',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Begrenzter Prüfkreislauf (Roadmap 1d, E3 entschieden 16.09.2026).
|
||||
*
|
||||
* `auto_check_attempts` zählt, wie oft die automatische Prüfung mit
|
||||
* Statuswirkung für diese Meldung gelaufen ist. `manual_review_required_at`
|
||||
* ist gesetzt, sobald die Automatik erschöpft ist: Die Meldung bleibt in
|
||||
* `review`, erscheint im Admin als „Prüfung nötig", und jede weitere Freigabe
|
||||
* des Autors geht ohne KI-Lauf direkt an einen Menschen.
|
||||
*
|
||||
* Kosten: `press_releases` hat keine Volltext-Indizes mehr, beide Spalten sind
|
||||
* `ALGORITHM=INSTANT`. hasColumn hält die Migration wiederholbar.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('press_releases', 'auto_check_attempts')) {
|
||||
$table->unsignedTinyInteger('auto_check_attempts')->default(0)->after('classified_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('press_releases', 'manual_review_required_at')) {
|
||||
$table->timestamp('manual_review_required_at')->nullable()->after('auto_check_attempts');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->dropColumn(array_filter([
|
||||
Schema::hasColumn('press_releases', 'auto_check_attempts') ? 'auto_check_attempts' : null,
|
||||
Schema::hasColumn('press_releases', 'manual_review_required_at') ? 'manual_review_required_at' : null,
|
||||
]));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -144,12 +144,12 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
|
|||
|
||||
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
||||
|---|---|---|---|
|
||||
| 1d.1 | Hilfeseite ohne kommerziellen Anteil: was eine gute Pressemitteilung ausmacht, wie geprüft wird, was sich verbessern lässt, Abschnitt zu Pressebildern | S | keine |
|
||||
| 1d.2 | Textbaustein in die Ablehnungsmail, Verlinkung auf 1d.1 | S | 1d.1 |
|
||||
| ~~1d.1~~ | Hilfeseite ohne kommerziellen Anteil — **gebaut 16.09.2026**: `/{edition}/gute-pressemitteilung` (DE/EN), Anlass, Aufbau, Sprache, Prüfablauf, sieben Qualitätsdimensionen offen, Pressebilder; Sitemap + Hilfe-Center | S | keine |
|
||||
| ~~1d.2~~ | Textbaustein in die Ablehnungsmail — **16.09.2026**: Link auf dem Portal der Meldung, Herkunft `mail_reject`, nichts Kommerzielles (E6) | S | 1d.1 |
|
||||
| 1d.3 | Abmeldemöglichkeit für Benachrichtigungen, Status in der Suppression-Logik geführt | S | Bounce-Suppression live |
|
||||
| 1d.4 | Prüfwerkzeug: eingefügter Text wird bewertet, Ausgabe als benannte Stufe mit konkreten Hebeln | M | 1d.1 |
|
||||
| 1d.5 | Bewertung einer bestehenden Meldung über Magic Link | S | 1d.4, 1b.2 |
|
||||
| 1d.6 | Partnerbereich am Seitenende, vorerst als "BALD" ohne Namen und Logos | S | 1d.1 |
|
||||
| ~~1d.4~~ | Prüfwerkzeug — **E3 entschieden 16.09.2026: kein eigenständiges Werkzeug.** Prüfung nur beim Freigeben, Ergebnis am Entwurf; „Zur Prüfung freigeben" mit Erklärung, was danach passiert; automatische Prüfung je Meldung `max_automatic_attempts` (3), danach geblockt in die Admin-Queue („Prüfung nötig"), Freischalten oder Kommentar, Automatik für die Meldung ab dann aus | M | 1d.1 |
|
||||
| ~~1d.5~~ | Bewertung einer bestehenden Meldung über Magic Link — **mit 1c.2 erledigt**: der Beansprucher sieht Ablehnungsgrund, KI-Stufe und Content-Score auf der Meldungsseite | S | 1d.4, 1b.2 |
|
||||
| ~~1d.6~~ | Partnerbereich am Seitenende als BALD — **16.09.2026** am Ende von 1d.1 | S | 1d.1 |
|
||||
|
||||
**Offenlegungsgrenze**
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
|
|||
|---|---|---|
|
||||
| ~~E1~~ | **Entschieden 11.09.2026: 3** (drei Live-Läufe, siehe `basiswerte-block0.md` und `umsetzung-protokoll.md` Teil 3). Konfiguriert in `config/newsroom.php`. | 1a.2 |
|
||||
| ~~E2~~ | **Empfehlung 01.09.2026: Rohzahlen** — kein Bodensatz, Verteilung symmetrisch, Einordnung gegen den Schnitt wäre wegen der Wachstumskurve falsch (siehe `basiswerte-block0.md`). | 1c.1 |
|
||||
| E3 | Anzahl kostenloser Prüfläufe und Preis der weiteren in Credits | 1d.4 |
|
||||
| ~~E3~~ | **Entschieden 16.09.2026: keine Credits.** Prüfung ist Teil der Freigabe, nicht ein Werkzeug; N automatische Läufe je Meldung (einstellbar, 3), danach Mensch, Automatik für die Meldung aus. Gelb geht weiter automatisch online, `yellow_to_manual_queue` bleibt als Schalter (Vorgabe aus). | 1d.4 |
|
||||
| ~~E4~~ | **Entschieden 15.09.2026: der Magic-Link-Hinweis, am Ende der Mail, als Hinweis, nicht als Angebot.** Umgesetzt in 1b.4. Der Link auf die Infoseite (1d.2) bekommt damit keinen Platz in der Veröffentlichungsmail — ein Element pro Mail. | 1b.4, 1d.2 |
|
||||
| E5 | Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt | 2.5 |
|
||||
| E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 |
|
||||
|
|
|
|||
|
|
@ -1340,3 +1340,69 @@ und `ManualInvoicePdfRenderer` aus dem parallel entwickelten Projekt
|
|||
nullbare Werte, eine immer-falsche Negation. Nicht angefasst.
|
||||
|
||||
Deploy: kein `migrate`, nur Code; `view:cache` neu. Kein Backfill nötig.
|
||||
|
||||
---
|
||||
|
||||
# Teil 6 — Block 1d: Infoseite und Prüfkreislauf (16.09.2026)
|
||||
|
||||
## Entscheidungen mit dem Nutzer
|
||||
|
||||
- **E3:** keine Credits für Prüfläufe. Die Prüfung ist Teil des Freigebens;
|
||||
ein eigenständiges Werkzeug zum Textreinkopieren gibt es nicht (wäre der
|
||||
Weg, das Gate auszutesten). Automatische Prüfung je Meldung
|
||||
`scoring.classification.max_automatic_attempts` (Vorgabe 3); danach
|
||||
geblockt in die Admin-Queue; Freischalten oder Kommentar; ab dann ist die
|
||||
Automatik für diese Meldung aus.
|
||||
- **Gelb** geht weiter automatisch online. Der Schalter
|
||||
`yellow_to_manual_queue` war im Code nie ausgewertet (1 288 gelbe
|
||||
Meldungen in sechs Monaten ohne Blick); jetzt ausgewertet, Vorgabe
|
||||
`false`, im Admin sichtbar (Filter „KI: Gelb" bestand schon).
|
||||
|
||||
## Befund vorab
|
||||
|
||||
84 KI-Ablehnungen in sechs Monaten, jede Meldung genau einmal, eine
|
||||
davon später veröffentlicht — heute loopt niemand. Der Zähler schützt vor
|
||||
einem Verhalten, das erst mit 1d.1/1d.2 entsteht.
|
||||
|
||||
## 1d.1 / 1d.2 / 1d.6 (Commit c08c9e5)
|
||||
|
||||
Hilfeseite `/{edition}/gute-pressemitteilung` (DE/EN): Anlass, Aufbau,
|
||||
Sprache, Prüfablauf mit Versuchszahl aus der Konfiguration, die sieben
|
||||
Dimensionen des Content-Scores offen (Pressestil, Struktur, Lesbarkeit,
|
||||
Vollständigkeit, Quellen, Überschrift, Eigenständigkeit) — Gewichtung und
|
||||
Schwellen nicht (Offenlegungsgrenze), Pressebilder, Partner als BALD.
|
||||
Kein Preis-, Tarif- oder Credit-Link im Seiteninhalt (Test prüft den
|
||||
Prosa-Block). Sitemap, Hilfe-Center. Ablehnungsmail verlinkt die Seite auf
|
||||
dem Portal der Meldung mit `src=mail_reject&pr=<id>`.
|
||||
|
||||
## E3 — begrenzter Prüfkreislauf
|
||||
|
||||
- Spalten `auto_check_attempts`, `manual_review_required_at` an
|
||||
`press_releases` (INSTANT, keine Volltext-Indizes mehr).
|
||||
- `submitForReview()`: zählt den Lauf; ist die Automatik erschöpft, geht
|
||||
die Freigabe ohne `ClassifyPressRelease` direkt in `review` mit
|
||||
Status-Log „wartet auf manuelle Entscheidung". Content-Score läuft weiter.
|
||||
- `routeByClassification()`: Rot beim N-ten Lauf → `holdForManualReview()`
|
||||
statt Ablehnung (bleibt `review`, Log „Automatische Prüfung erschöpft
|
||||
(N von N): <Grund>", ActivityLog). Gelb → Schalter. Keine Mail an den
|
||||
Autor für den Block — er sieht den Stand im Konto (offen: ob eine kurze
|
||||
Mail „ein Mensch prüft" gewünscht ist).
|
||||
- Admin ▸ Pressemitteilungen: Ansicht „Prüfung nötig" (Zähler rot, wenn >
|
||||
0), Filteroption, Badges „Prüfung nötig" / „nur manuell" in der Liste;
|
||||
Detailseite zeigt alle KI-Begründungen chronologisch und beschriftet die
|
||||
Aktionen „Freischalten" / „Mit Kommentar zurück" (bestehende
|
||||
publish/reject-Aktionen, Kommentar geht per Ablehnungsmail an den Autor).
|
||||
- Kundenseite: Button „Zur Prüfung freigeben" (statt „einreichen", überall
|
||||
vereinheitlicht), Modal erklärt in drei Schritten, was nach der Freigabe
|
||||
passiert; abgelehnte Meldung zeigt „Automatische Prüfung X von N genutzt"
|
||||
mit Link auf 1d.1; geblockte Meldung: „wird von einem Menschen geprüft".
|
||||
- Nebenbefund Blade: ein inline `@php(...)` wird bis zum nächsten
|
||||
`@php … @endphp` als Rohblock gelesen — vier Stellen auf Blockform
|
||||
umgestellt, eine davon Bestand (Modal).
|
||||
|
||||
Tests: `ManualReviewGateTest` (5), `StaticPagesTest` (+2),
|
||||
`ConversionFunnelTest` (erweitert). 1d.5 ist mit 1c.2 erledigt (der
|
||||
Beansprucher sieht Ablehnungsgrund, KI-Stufe, Content-Score).
|
||||
|
||||
Deploy: `migrate --force` (Millisekunden), `view:cache`, `queue:restart`.
|
||||
Kein Backfill — bestehende Meldungen starten mit 0 Versuchen.
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@
|
|||
Bildrechte-Bestätigung — es gibt nichts zu bestätigen. Default true, damit
|
||||
die Checkbox im Zweifel lieber einmal zu viel erscheint.
|
||||
--}}
|
||||
@php($bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true))
|
||||
{{-- Blockform: ein inline @php(...) würde bis zum nächsten @endphp weiter unten als Rohblock gelesen. --}}
|
||||
@php
|
||||
$bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true);
|
||||
@endphp
|
||||
|
||||
<flux:modal :name="$name" class="w-full max-w-xl">
|
||||
@if ($bookingRequired)
|
||||
|
|
@ -72,16 +75,32 @@
|
|||
<div class="space-y-5" x-data="{ agb: false, images: {{ $hasTitleImage ? 'false' : 'true' }}, contact: false, waiver: false, ai: false }">
|
||||
<div>
|
||||
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung freigeben') }}</flux:heading>
|
||||
</div>
|
||||
|
||||
{{-- E3 (16.09.2026): unmissverständlich, dass die Freigabe nicht
|
||||
„online" heißt — erst die Prüfung, dann die Veröffentlichung. --}}
|
||||
@php
|
||||
$maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
|
||||
@endphp
|
||||
<article class="panel overflow-hidden" style="border-color:var(--color-hub); border-left-width:3px;">
|
||||
<div class="p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
|
||||
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Was nach der Freigabe passiert') }}</p>
|
||||
<ol class="m-0 list-decimal space-y-1 ps-5">
|
||||
<li>{{ __('Der Text geht zur automatischen Inhaltsprüfung — er ist damit noch nicht online.') }}</li>
|
||||
<li>{{ __('Grün oder Gelb: die Mitteilung wird veröffentlicht. Rot: sie kommt mit Begründung zurück, Sie können nachbessern und erneut freigeben.') }}</li>
|
||||
<li>{{ __('Nach :max Prüfungen mit Rot sieht sich die Redaktion den Text an und entscheidet.', ['max' => $maxAttempts]) }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Rechtliche Hinweise (Platzhalter — vor Go-Live anwaltlich prüfen) --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head" style="padding:10px 16px;">
|
||||
<span class="section-eyebrow">{{ __('Rechtliche Hinweise') }}</span>
|
||||
</div>
|
||||
<div class="p-4 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
|
||||
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit dem Einreichen versichern Sie:') }}</p>
|
||||
<p class="m-0 mb-2 font-semibold text-[color:var(--color-ink)]">{{ __('Mit der Freigabe versichern Sie:') }}</p>
|
||||
<ul class="m-0 list-disc space-y-1 ps-5">
|
||||
<li>{{ __('Sie sind befugt, den Inhalt zu veröffentlichen.') }}</li>
|
||||
<li>{{ __('Alle verwendeten Bilder, Logos und Zitate liegen in Ihrer Nutzungsbefugnis.') }}</li>
|
||||
|
|
@ -205,7 +224,7 @@
|
|||
<flux:button variant="primary" wire:click="{{ $action }}"
|
||||
wire:loading.attr="disabled"
|
||||
x-bind:disabled="! (agb && images && contact && waiver && ai)">
|
||||
{{ $confirmLabel ?? __('Veröffentlichung anfordern') }}
|
||||
{{ $confirmLabel ?? __('Zur Prüfung freigeben') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -307,7 +307,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
->orWhereHas('company', fn ($q) => $q->where('name', 'like', '%'.$term.'%'));
|
||||
});
|
||||
})
|
||||
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
|
||||
// „Prüfung nötig" (E3): geblockt in review, Automatik erschöpft.
|
||||
->when($this->statusFilter === 'manual', fn ($q) => $q->where('status', PressReleaseStatus::Review->value)->whereNotNull('manual_review_required_at'))
|
||||
->when($this->statusFilter !== 'all' && $this->statusFilter !== 'manual', fn ($q) => $q->where('status', $this->statusFilter))
|
||||
->when($this->classificationFilter !== 'all', fn ($q) => $q->where('classification', $this->classificationFilter))
|
||||
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
|
||||
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
|
||||
|
|
@ -413,6 +415,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as rejected', [PressReleaseStatus::Rejected->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as archived', [PressReleaseStatus::Archived->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? AND manual_review_required_at IS NOT NULL THEN 1 ELSE 0 END) as manual', [PressReleaseStatus::Review->value])
|
||||
->first();
|
||||
|
||||
return [
|
||||
|
|
@ -422,6 +425,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
'draft' => (int) ($stats->draft ?? 0),
|
||||
'rejected' => (int) ($stats->rejected ?? 0),
|
||||
'archived' => (int) ($stats->archived ?? 0),
|
||||
'manual' => (int) ($stats->manual ?? 0),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
@ -574,6 +578,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Review->value])>
|
||||
{{ __('In Prüfung') }} <span class="cnt">{{ number_format($stats['review']) }}</span>
|
||||
</button>
|
||||
{{-- E3: Automatik erschöpft — hier wartet eine menschliche Entscheidung. --}}
|
||||
<button type="button" wire:click="setView('manual')"
|
||||
@class(['view-tab', 'is-active' => $statusFilter === 'manual'])>
|
||||
{{ __('Prüfung nötig') }} <span @class(['cnt', 'text-[color:var(--color-err)]' => ($stats['manual'] ?? 0) > 0])>{{ number_format($stats['manual'] ?? 0) }}</span>
|
||||
</button>
|
||||
<button type="button" wire:click="setView('{{ \App\Enums\PressReleaseStatus::Published->value }}')"
|
||||
@class(['view-tab', 'is-active' => $statusFilter === \App\Enums\PressReleaseStatus::Published->value])>
|
||||
{{ __('Veröffentlicht') }} <span class="cnt">{{ number_format($stats['published']) }}</span>
|
||||
|
|
@ -611,6 +620,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
@foreach ($statusOptions as $s)
|
||||
<option value="{{ $s->value }}">{{ $s->label() }}</option>
|
||||
@endforeach
|
||||
<option value="manual">{{ __('Prüfung nötig (Automatik erschöpft)') }}</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="classificationFilter" class="w-full">
|
||||
|
|
@ -814,10 +824,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
@endif
|
||||
|
||||
@if ($statusFilter !== 'all')
|
||||
@php $statusEnum = \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
|
||||
@php $statusEnum = $statusFilter === 'manual' ? null : \App\Enums\PressReleaseStatus::tryFrom($statusFilter); @endphp
|
||||
<span class="active-chip">
|
||||
<span>{{ __('Status') }}:
|
||||
<strong>{{ $statusEnum?->label() ?? $statusFilter }}</strong></span>
|
||||
<strong>{{ $statusEnum?->label() ?? ($statusFilter === 'manual' ? __('Prüfung nötig') : $statusFilter) }}</strong></span>
|
||||
<button type="button" class="x" wire:click="setView('all')"
|
||||
aria-label="{{ __('Filter entfernen') }}">
|
||||
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
|
||||
|
|
@ -1055,6 +1065,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
|
||||
@if ($pr->isAwaitingManualReview())
|
||||
<span class="badge err" title="{{ __('Automatische Prüfung erschöpft — Entscheidung nötig') }}">{{ __('Prüfung nötig') }}</span>
|
||||
@elseif ($pr->requiresManualReview())
|
||||
<span class="badge muted" title="{{ __('Automatik für diese Meldung aus') }}">{{ __('nur manuell') }}</span>
|
||||
@endif
|
||||
@if ($pr->classification)
|
||||
@php
|
||||
$kiBadge = match ($pr->classification) {
|
||||
|
|
|
|||
|
|
@ -259,10 +259,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
{{-- ============== STATUS-WORKFLOW ============== --}}
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
|
||||
@php
|
||||
$manual = $pr->isAwaitingManualReview();
|
||||
@endphp
|
||||
<article class="panel" style="border-color:var(--color-{{ $manual ? 'err' : 'warn' }}); border-left-width:3px;">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
|
||||
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
|
||||
@if ($manual)
|
||||
<span class="badge err dot">{{ __('Prüfung nötig — Automatik erschöpft') }}</span>
|
||||
@else
|
||||
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5 flex flex-wrap items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
|
||||
|
|
@ -270,8 +277,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<flux:icon.clock class="size-[18px]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-[220px] text-[13px] text-[color:var(--color-ink-2)]">
|
||||
<p class="m-0">{{ __('Diese PM wartet auf eine Prüf-Entscheidung.') }}</p>
|
||||
@if ($latestClassification && $latestClassification->reason)
|
||||
@if ($manual)
|
||||
{{-- E3: die automatische Prüfung hat ihre Versuche aufgebraucht.
|
||||
Ab hier entscheidet ein Mensch — Freischalten oder mit
|
||||
Kommentar zurück; danach bleibt die Automatik für diese
|
||||
Meldung aus. --}}
|
||||
<p class="m-0 font-semibold text-[color:var(--color-ink)]">
|
||||
{{ __(':n automatische Prüfungen, zuletzt Rot. Ihre Entscheidung: veröffentlichen oder mit Kommentar zurück an den Autor.', ['n' => $pr->auto_check_attempts]) }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Jede weitere Freigabe des Autors landet ohne KI-Lauf wieder hier.') }}
|
||||
</p>
|
||||
@php
|
||||
$kiRejections = $statusLogs->filter(fn ($log) => $log->source === 'ki' && filled($log->reason))->sortBy('created_at')->values();
|
||||
@endphp
|
||||
@if ($kiRejections->isNotEmpty())
|
||||
<ol class="m-0 mt-3 ps-4 space-y-1.5 text-[12px] text-[color:var(--color-ink-2)] list-decimal">
|
||||
@foreach ($kiRejections as $log)
|
||||
<li><span class="font-mono text-[11px] text-[color:var(--color-ink-4)]">{{ $log->created_at->format('d.m. H:i') }}</span> · {{ $log->reason }}</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@endif
|
||||
@else
|
||||
<p class="m-0">{{ __('Diese PM wartet auf eine Prüf-Entscheidung.') }}</p>
|
||||
@endif
|
||||
@if (! $manual && $latestClassification && $latestClassification->reason)
|
||||
<p class="m-0 mt-1 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
<strong class="text-[color:var(--color-ink-2)]">{{ __('KI-Hinweis') }}:</strong>
|
||||
{{ $latestClassification->reason }}
|
||||
|
|
@ -286,10 +316,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:modal.trigger name="confirm-show-publish">
|
||||
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
|
||||
<flux:button type="button" variant="primary">{{ $manual ? __('Freischalten') : __('Veröffentlichen') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<flux:modal.trigger name="confirm-show-reject">
|
||||
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
|
||||
<flux:button type="button" variant="danger">{{ $manual ? __('Mit Kommentar zurück') : __('Ablehnen') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -826,7 +826,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
class="w-full"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
{{ __('Zur Prüfung senden') }}
|
||||
{{ __('Zur Prüfung freigeben') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
|
||||
|
|
@ -1084,14 +1084,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
</div>
|
||||
|
||||
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}}
|
||||
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung freigeben") --}}
|
||||
{{-- Im Erstellen-Formular gibt es noch keinen Bild-Upload (der folgt im
|
||||
Editor nach dem Entwurf-Sichern), daher entfällt die Bildrechte-Frage. --}}
|
||||
<x-press-release-submit-modal
|
||||
name="confirm-submit-review"
|
||||
action="save('review')"
|
||||
booking-action="saveDraftAndChooseBooking"
|
||||
:confirm-label="__('Zur Prüfung senden')"
|
||||
:confirm-label="__('Zur Prüfung freigeben')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="false" />
|
||||
|
|
|
|||
|
|
@ -1055,7 +1055,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
name="confirm-submit-review"
|
||||
action="saveAndSubmit"
|
||||
booking-action="saveDraftAndChooseBooking"
|
||||
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & einreichen')"
|
||||
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut zur Prüfung freigeben') : __('Speichern & zur Prüfung freigeben')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="! $coverIsPlaceholder" />
|
||||
|
|
|
|||
|
|
@ -691,7 +691,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
class="font-mono text-[9.5px] tracking-[0.16em] text-[color:var(--color-accent-deep)] font-bold mb-1">
|
||||
03</div>
|
||||
<div class="text-[11.5px] font-semibold text-[color:var(--color-ink)] leading-tight">
|
||||
{{ __('Zur Prüfung senden') }}</div>
|
||||
{{ __('Zur Prüfung freigeben') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-portal.empty-state>
|
||||
|
|
@ -718,7 +718,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
<li>{{ __('Bearbeiten') }}</li>
|
||||
<li>{{ __('Vorschau') }}</li>
|
||||
<li class="text-[color:var(--color-accent-deep)] font-semibold">
|
||||
{{ __('→ Zur Prüfung senden') }}</li>
|
||||
{{ __('→ Zur Prüfung freigeben') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -743,7 +743,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
class="text-[11.5px] text-[color:var(--color-ink-2)] leading-[1.7] mt-2 list-none p-0 m-0 space-y-0.5">
|
||||
<li>{{ __('Bearbeiten') }}</li>
|
||||
<li class="text-[color:var(--color-accent-deep)] font-semibold">
|
||||
{{ __('→ Erneut einreichen') }}</li>
|
||||
{{ __('→ Erneut zur Prüfung freigeben') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -797,6 +797,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
return [
|
||||
'pr' => $pr,
|
||||
'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null,
|
||||
'helpPageUrl' => rtrim((string) config('domains.domain_portal_url'), '/').'/de/gute-pressemitteilung',
|
||||
'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [],
|
||||
'viewRanges' => VisibilityStats::ranges(),
|
||||
'publicLinks' => $pr->status === PressReleaseStatus::Published
|
||||
|
|
@ -1035,6 +1036,19 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
|
||||
</span>
|
||||
@php
|
||||
$maxAttempts = max(1, (int) config('scoring.classification.max_automatic_attempts', 3));
|
||||
@endphp
|
||||
@if ($pr->requiresManualReview())
|
||||
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Für diese Mitteilung entscheidet ab jetzt ein Mensch: Nach der nächsten Freigabe prüft die Redaktion direkt, ohne automatische Vorprüfung.') }}
|
||||
</span>
|
||||
@elseif ($pr->auto_check_attempts > 0)
|
||||
<span class="mt-1 block text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Automatische Prüfung :used von :max genutzt. Bleibt der Text danach Rot, prüft ein Mensch.', ['used' => $pr->auto_check_attempts, 'max' => $maxAttempts]) }}
|
||||
<a href="{{ $helpPageUrl }}" target="_blank" rel="noopener" class="underline underline-offset-2">{{ __('Was eine gute Pressemitteilung ausmacht') }}</a>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -1068,8 +1082,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="flex flex-wrap items-center gap-3">
|
||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[220px]">
|
||||
{{ $pr->status === PressReleaseStatus::Rejected
|
||||
? __('Sie können den Text bearbeiten und erneut zur Prüfung einreichen.')
|
||||
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
|
||||
? __('Sie können den Text bearbeiten und erneut zur Prüfung freigeben.')
|
||||
: __('Geben Sie den Entwurf zur Prüfung frei, sobald er vollständig ist. Online geht er erst nach der Prüfung.') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap">
|
||||
@if ($canEdit)
|
||||
|
|
@ -1080,7 +1094,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
@if ($hasActiveBooking)
|
||||
<flux:modal.trigger name="confirm-submit-review">
|
||||
<flux:button type="button" variant="primary">
|
||||
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
|
||||
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut zur Prüfung freigeben') : __('Zur Prüfung freigeben') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
@else
|
||||
|
|
@ -1219,9 +1233,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.clock class="size-[18px]" />
|
||||
</div>
|
||||
<p class="flex-1 text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
|
||||
</p>
|
||||
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
|
||||
@if ($pr->isAwaitingManualReview())
|
||||
{{-- E3: Automatik erschöpft — ein Mensch prüft. Kein Termin
|
||||
versprechen, den wir nicht halten. --}}
|
||||
<p class="m-0 font-semibold text-[color:var(--color-ink)]">{{ __('Ihre Pressemitteilung wird von einem Menschen geprüft.') }}</p>
|
||||
<p class="m-0 mt-1">{{ __('Die automatische Prüfung hat den Text mehrfach nicht freigegeben. Jetzt sieht sich die Redaktion Text und Begründungen an und schaltet frei oder meldet sich mit einem Kommentar. Sie werden per E-Mail benachrichtigt.') }}</p>
|
||||
@else
|
||||
<p class="m-0">{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
|
@ -1631,7 +1652,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<x-press-release-submit-modal
|
||||
name="confirm-submit-review"
|
||||
action="submitForReview"
|
||||
:confirm-label="__('Veröffentlichung anfordern')"
|
||||
:confirm-label="__('Zur Prüfung freigeben')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="! $coverIsPlaceholder" />
|
||||
|
|
|
|||
168
tests/Feature/ManualReviewGateTest.php
Normal file
168
tests/Feature/ManualReviewGateTest.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Mail\PressReleaseRejected;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Begrenzter Prüfkreislauf (Roadmap 1d, E3 entschieden 16.09.2026): die
|
||||
* automatische Prüfung läuft je Meldung N-mal, danach entscheidet ein Mensch
|
||||
* und die Automatik bleibt für diese Meldung aus.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
config()->set('scoring.classification.max_automatic_attempts', 2);
|
||||
config()->set('billing.enforce_booking', false);
|
||||
Queue::fake();
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function gateDraft(): array
|
||||
{
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$pr = PressRelease::factory()->create(['user_id' => $customer->id, 'status' => PressReleaseStatus::Draft->value]);
|
||||
|
||||
return [$customer, $pr];
|
||||
}
|
||||
|
||||
function redRun(PressRelease $pr, string $reason): void
|
||||
{
|
||||
app(PressReleaseService::class)->routeByClassification($pr->fresh(), PressReleaseClassification::Red, $reason);
|
||||
}
|
||||
|
||||
test('each release counts its automatic checks and the last red blocks instead of rejecting', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $pr] = gateDraft();
|
||||
$service = app(PressReleaseService::class);
|
||||
$this->actingAs($customer);
|
||||
|
||||
// Erster Durchlauf: Rot → abgelehnt, Zähler 1.
|
||||
$service->submitForReview($pr);
|
||||
Queue::assertPushed(ClassifyPressRelease::class, 1);
|
||||
expect($pr->fresh()->auto_check_attempts)->toBe(1);
|
||||
|
||||
redRun($pr, 'K1: Werbliche Sprache');
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected)
|
||||
->and($pr->fresh()->requiresManualReview())->toBeFalse();
|
||||
|
||||
// Zweiter (= letzter erlaubter) Durchlauf: Rot → nicht ablehnen, blocken.
|
||||
$service->submitForReview($pr->fresh());
|
||||
Queue::assertPushed(ClassifyPressRelease::class, 2);
|
||||
expect($pr->fresh()->auto_check_attempts)->toBe(2);
|
||||
|
||||
redRun($pr, 'K1: Immer noch werblich');
|
||||
$fresh = $pr->fresh();
|
||||
|
||||
expect($fresh->status)->toBe(PressReleaseStatus::Review)
|
||||
->and($fresh->isAwaitingManualReview())->toBeTrue()
|
||||
->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason)
|
||||
->toContain('Automatische Prüfung erschöpft (2 von 2)');
|
||||
|
||||
// Keine Ablehnungsmail für den Block — der Autor sieht den Stand im Konto.
|
||||
Mail::assertNotQueued(PressReleaseRejected::class, fn ($mail) => str_contains((string) $mail->reason, 'erschöpft'));
|
||||
});
|
||||
|
||||
test('after the human decision the automation stays off for this release', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $pr] = gateDraft();
|
||||
$service = app(PressReleaseService::class);
|
||||
$this->actingAs($customer);
|
||||
|
||||
$pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value]);
|
||||
redRun($pr, 'K2: Rot');
|
||||
expect($pr->fresh()->isAwaitingManualReview())->toBeTrue();
|
||||
|
||||
// Admin: mit Kommentar zurück.
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
$this->actingAs($admin);
|
||||
$service->reject($pr->fresh(), 'Bitte die Behauptung im zweiten Absatz belegen.');
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected)
|
||||
->and($pr->fresh()->requiresManualReview())->toBeTrue();
|
||||
|
||||
// Autor gibt erneut frei: kein KI-Job, direkt wieder in der Queue.
|
||||
$this->actingAs($customer);
|
||||
Queue::fake();
|
||||
$service->submitForReview($pr->fresh());
|
||||
|
||||
Queue::assertNotPushed(ClassifyPressRelease::class);
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review)
|
||||
->and($pr->fresh()->isAwaitingManualReview())->toBeTrue()
|
||||
->and($pr->fresh()->auto_check_attempts)->toBe(2)
|
||||
->and(PressReleaseStatusLog::query()->where('press_release_id', $pr->id)->latest('id')->first()->reason)->toContain('wartet auf manuelle Entscheidung');
|
||||
});
|
||||
|
||||
test('yellow publishes by default and is held only when the switch is on', function () {
|
||||
/** @var TestCase $this */
|
||||
[, $pr] = gateDraft();
|
||||
$pr->update(['status' => PressReleaseStatus::Review->value]);
|
||||
|
||||
config()->set('scoring.classification.yellow_to_manual_queue', false);
|
||||
app(PressReleaseService::class)->routeByClassification($pr->fresh(), PressReleaseClassification::Yellow, 'Gelb');
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
|
||||
|
||||
[, $other] = gateDraft();
|
||||
$other->update(['status' => PressReleaseStatus::Review->value]);
|
||||
config()->set('scoring.classification.yellow_to_manual_queue', true);
|
||||
app(PressReleaseService::class)->routeByClassification($other->fresh(), PressReleaseClassification::Yellow, 'Gelb');
|
||||
|
||||
expect($other->fresh()->status)->toBe(PressReleaseStatus::Review)
|
||||
->and($other->fresh()->isAwaitingManualReview())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the admin sees blocked releases in their own view and the detail page explains the decision', function () {
|
||||
/** @var TestCase $this */
|
||||
[, $pr] = gateDraft();
|
||||
$pr->update(['auto_check_attempts' => 2, 'status' => PressReleaseStatus::Review->value, 'title' => 'Geblockte Meldung']);
|
||||
redRun($pr, 'K4: Falsche Tatsachenbehauptung');
|
||||
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('admin.press-releases.index')
|
||||
->call('setView', 'manual')
|
||||
->assertSeeText('Geblockte Meldung')
|
||||
->assertSeeText('Prüfung nötig');
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSeeText('Automatik erschöpft')
|
||||
->assertSeeText('K4: Falsche Tatsachenbehauptung')
|
||||
->assertSeeText('Freischalten')
|
||||
->assertSeeText('Mit Kommentar zurück');
|
||||
});
|
||||
|
||||
test('the customer page explains the release button, counts attempts and shows the human review', function () {
|
||||
/** @var TestCase $this */
|
||||
[$customer, $pr] = gateDraft();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSeeText('Zur Prüfung freigeben')
|
||||
->assertSeeText('Was nach der Freigabe passiert')
|
||||
->assertSeeText('Nach 2 Prüfungen mit Rot');
|
||||
|
||||
$pr->update(['status' => PressReleaseStatus::Rejected->value, 'auto_check_attempts' => 1]);
|
||||
PressReleaseStatusLog::query()->create(['press_release_id' => $pr->id, 'from_status' => 'review', 'to_status' => 'rejected', 'reason' => 'K1: Werblich', 'source' => 'ki']);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSeeText('Automatische Prüfung 1 von 2 genutzt');
|
||||
|
||||
$pr->update(['status' => PressReleaseStatus::Review->value, 'auto_check_attempts' => 2, 'manual_review_required_at' => now()]);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSeeText('von einem Menschen geprüft');
|
||||
});
|
||||
|
|
@ -50,7 +50,7 @@ test('with the gate on a draft without a booking shows the notice and links to b
|
|||
->assertSee('Noch keine Buchung')
|
||||
->assertSee('Buchung wählen')
|
||||
->assertSee(route('me.bookings.index'), false)
|
||||
->assertDontSee('Zur Prüfung einreichen');
|
||||
->assertDontSee('Zur Prüfung freigeben');
|
||||
});
|
||||
|
||||
test('with the gate on a paid single purchase unlocks the submission action', function () {
|
||||
|
|
@ -63,7 +63,7 @@ test('with the gate on a paid single purchase unlocks the submission action', fu
|
|||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertDontSee('Noch keine Buchung')
|
||||
->assertSee('Zur Prüfung einreichen');
|
||||
->assertSee('Zur Prüfung freigeben');
|
||||
});
|
||||
|
||||
test('submitting without a booking is rejected at the service level', function () {
|
||||
|
|
@ -105,5 +105,5 @@ test('with the gate off the notice never shows', function () {
|
|||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertDontSee('Noch keine Buchung')
|
||||
->assertSee('Zur Prüfung einreichen');
|
||||
->assertSee('Zur Prüfung freigeben');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ test('the pm create page shows a notice instead of the form without a company',
|
|||
->assertSet('hasCompanies', false)
|
||||
->assertSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
|
||||
->assertSee('Firma anlegen')
|
||||
->assertDontSee('Zur Prüfung senden');
|
||||
->assertDontSee('Zur Prüfung freigeben');
|
||||
});
|
||||
|
||||
test('the pm create page shows the editor when a company exists', function () {
|
||||
|
|
@ -30,5 +30,5 @@ test('the pm create page shows the editor when a company exists', function () {
|
|||
->assertSet('hasCompanies', true)
|
||||
->assertSet('companyId', $company->id)
|
||||
->assertDontSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
|
||||
->assertSee('Zur Prüfung senden');
|
||||
->assertSee('Zur Prüfung freigeben');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ test('customer show renders the publish confirmation modal with legal note and q
|
|||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Pressemitteilung zur Prüfung einreichen')
|
||||
->assertSee('Pressemitteilung zur Prüfung freigeben')
|
||||
->assertSee('Rechtliche Hinweise')
|
||||
->assertSee('Mit dem Einreichen versichern Sie:')
|
||||
->assertSee('Mit der Freigabe versichern Sie:')
|
||||
->assertSee('PM-Kontingent diesen Monat')
|
||||
->assertSee('2 / 3')
|
||||
->assertSee('Ihre Bestätigungen');
|
||||
|
|
@ -49,7 +49,7 @@ test('the quota block is hidden for users with an unlimited quota', function ()
|
|||
|
||||
// Launch-Schalter aus → Kontingent unbegrenzt → kein Quota-Block.
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Pressemitteilung zur Prüfung einreichen')
|
||||
->assertSee('Pressemitteilung zur Prüfung freigeben')
|
||||
->assertDontSee('PM-Kontingent diesen Monat');
|
||||
});
|
||||
|
||||
|
|
@ -113,5 +113,5 @@ test('the modal shows a booking notice instead of the submit flow when the gate
|
|||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Buchung erforderlich')
|
||||
->assertDontSee('Mit dem Einreichen versichern Sie:');
|
||||
->assertDontSee('Mit der Freigabe versichern Sie:');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ test('customer show page surfaces latest reject reason prominently', function ()
|
|||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Werbliche Sprache wurde markiert.')
|
||||
->assertSee('Erneut einreichen');
|
||||
->assertSee('Erneut zur Prüfung freigeben');
|
||||
});
|
||||
|
||||
test('admin index publish goes through service and creates audit log', function () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue