presseportale/app/Services/PressRelease/RejectionRateReport.php
Kevin Adametz c497628af3 reports: Basiswerte für die Konversionsmessung (Roadmap 0.1 und 0.2)
Beide Werte müssen gemessen sein, bevor die erste Maßnahme aus Block 1 live
geht — ohne Vergleichspunkt wäre jede spätere Bewertung eine Vermutung.

press-releases:view-distribution gibt die Verteilung der Aufrufzahlen je Portal
aus: Median, Quartile, P10/P90 und eine Häufigkeitsverteilung. Ein Mittelwert
allein trägt hier nicht — eine Handvoll Ausreißer zieht ihn nach oben, während
der typische Absender darunter liegt. Genau davor soll E2 schützen, deshalb
wird die Schiefe (Mittelwert gegen Median) ausdrücklich mit ausgewiesen.

Altbestand und seit dem Relaunch eingegangene Meldungen stehen getrennt: Ihre
Zähler haben unterschiedliche Vorgeschichten, und eine Einordnung, die beide
vermischt, vergleicht Äpfel mit Birnen. Mit 410 entfernte Meldungen zählen
nicht mit, eine Meldung auf beiden Portalen bei beiden.

press-releases:rejection-rate gibt die Ablehnungsquote je Einreichungsquelle
aus. Gezählt wird je EINREICHUNG, nicht je Meldung: Ein Übergang nach `review`
eröffnet eine Einreichung, die nächste Entscheidung schließt sie. Eine
überarbeitete und erneut eingereichte Meldung ist damit ein zweiter Vorgang mit
eigenem Ausgang — und genau das ist die Größe, die die Infoseite (1d) später
verändern soll. Gruppiert wird nach dem Einreichenden, nicht nach dem
Entscheider; letzterer ist immer die KI-Prüfung und als Gruppierung wertlos.
Jeder API-Client bekommt eine eigene Zeile, Einreichungen aus dem Kundenbereich
fallen zu "direkt" zusammen.

Noch offene Prüfungen zählen nicht in den Nenner, sonst sänke die Quote allein
dadurch, dass der Zeitraum bis heute reicht. Entscheidungen zu Einreichungen
von vor dem Zeitraum werden gemeldet statt verschluckt — ein zu kurz gewählter
Zeitraum sähe sonst wie eine niedrige Quote aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:57:25 +00:00

204 lines
7.2 KiB
PHP

<?php
namespace App\Services\PressRelease;
use App\Enums\PressReleaseStatus;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use Illuminate\Support\Carbon;
/**
* Basiswert der Ablehnungsquote je Quelle (Roadmap 0.2).
*
* Zwei Verwendungen: als Zahl für das connektar-Gespräch, und als
* Vergleichspunkt, gegen den später die Wirkung der Infoseite (1d) gemessen
* wird. Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT in die
* Konversionsleiter, sondern wird gegen diesen Basiswert geführt.
*
* Gezählt wird je EINREICHUNG, nicht je Meldung: Ein Übergang nach `review`
* eröffnet eine Einreichung, die nächste Entscheidung (`published` oder
* `rejected`) schließt sie. Wird eine abgelehnte Meldung überarbeitet und
* erneut eingereicht, sind das zwei Einreichungen mit eigenem Ausgang — und
* genau das ist die Größe, die 1d verändern soll.
*
* Die Quelle stammt vom Übergang nach `review` (wer eingereicht hat), nicht
* von der Entscheidung — die trifft immer die KI-Prüfung und wäre als
* Gruppierung wertlos.
*/
class RejectionRateReport
{
/**
* @return array{
* from: string,
* to: string,
* sources: list<array{key: string, label: string, eingereicht: int, veroeffentlicht: int, abgelehnt: int, offen: int, quote: float|null}>,
* totals: array{eingereicht: int, veroeffentlicht: int, abgelehnt: int, offen: int, quote: float|null},
* entscheidungen_ohne_einreichung: int,
* }
*/
public function build(Carbon $from, ?Carbon $to = null): array
{
$to ??= Carbon::now();
$logs = PressReleaseStatusLog::query()
->whereIn('to_status', [
PressReleaseStatus::Review->value,
PressReleaseStatus::Published->value,
PressReleaseStatus::Rejected->value,
])
->whereBetween('created_at', [$from, $to])
->orderBy('press_release_id')
->orderBy('id')
->get(['press_release_id', 'to_status', 'source', 'changed_by_user_id']);
$buckets = [];
$orphans = 0;
$open = null;
foreach ($logs as $log) {
$status = $log->to_status;
if ($status === PressReleaseStatus::Review) {
// Zwei Einreichungen ohne Entscheidung dazwischen: die erste
// ist noch offen (in Prüfung) und wird als solche gezählt.
if ($open !== null) {
$this->add($buckets, $open, 'offen');
}
$open = ['source' => (string) $log->source, 'user_id' => $log->changed_by_user_id];
$this->add($buckets, $open, 'eingereicht');
continue;
}
if ($open === null) {
// Entscheidung zu einer Einreichung, die vor dem Zeitraum lag.
// Nicht zuordenbar — wird gemeldet statt stillschweigend
// verschluckt, sonst sähe ein zu kurz gewählter Zeitraum wie
// eine niedrige Quote aus.
$orphans++;
continue;
}
$this->add($buckets, $open, $status === PressReleaseStatus::Rejected ? 'abgelehnt' : 'veroeffentlicht');
$open = null;
}
if ($open !== null) {
$this->add($buckets, $open, 'offen');
}
return $this->summarise($buckets, $from, $to, $orphans);
}
/**
* @param array<string, array<string, mixed>> $buckets
* @param array{source: string, user_id: int|null} $submission
*/
private function add(array &$buckets, array $submission, string $field): void
{
$key = $this->bucketKey($submission);
$buckets[$key] ??= [
'key' => $key,
'user_id' => $submission['user_id'],
'source' => $submission['source'],
'eingereicht' => 0,
'veroeffentlicht' => 0,
'abgelehnt' => 0,
'offen' => 0,
];
$buckets[$key][$field]++;
}
/**
* Jeder API-Client ist eine eigene Quelle — der Sinn der Auswertung ist ja
* gerade, sie zu vergleichen. Einreichungen aus dem Kundenbereich fallen
* dagegen zu „direkt" zusammen; dort ist der einzelne Absender keine
* Quelle, sondern ein Einzelfall.
*
* @param array{source: string, user_id: int|null} $submission
*/
private function bucketKey(array $submission): string
{
return $submission['source'] === 'api'
? 'api:'.($submission['user_id'] ?? 0)
: $submission['source'];
}
/**
* @param array<string, array<string, mixed>> $buckets
* @return array<string, mixed>
*/
private function summarise(array $buckets, Carbon $from, Carbon $to, int $orphans): array
{
$names = User::query()
->whereIn('id', array_filter(array_column($buckets, 'user_id')))
->get(['id', 'name', 'agency_name'])
->mapWithKeys(fn (User $u): array => [$u->id => trim((string) $u->agency_name) ?: (string) $u->name]);
$sources = [];
$totals = ['eingereicht' => 0, 'veroeffentlicht' => 0, 'abgelehnt' => 0, 'offen' => 0];
foreach ($buckets as $bucket) {
$sources[] = [
'key' => $bucket['key'],
'label' => $this->label($bucket, $names->all()),
'eingereicht' => $bucket['eingereicht'],
'veroeffentlicht' => $bucket['veroeffentlicht'],
'abgelehnt' => $bucket['abgelehnt'],
'offen' => $bucket['offen'],
'quote' => $this->rate($bucket['veroeffentlicht'], $bucket['abgelehnt']),
];
foreach ($totals as $field => $value) {
$totals[$field] = $value + $bucket[$field];
}
}
usort($sources, static fn (array $a, array $b): int => $b['eingereicht'] <=> $a['eingereicht']);
$totals['quote'] = $this->rate($totals['veroeffentlicht'], $totals['abgelehnt']);
return [
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'sources' => $sources,
'totals' => $totals,
'entscheidungen_ohne_einreichung' => $orphans,
];
}
/**
* @param array<string, mixed> $bucket
* @param array<int, string> $names
*/
private function label(array $bucket, array $names): string
{
if ($bucket['source'] !== 'api') {
return match ($bucket['source']) {
'customer', 'web' => 'direkt (Kundenbereich)',
'admin' => 'Redaktion',
default => (string) $bucket['source'],
};
}
$userId = $bucket['user_id'];
return sprintf('API: %s (#%s)', $names[$userId] ?? 'unbekannt', $userId ?? '?');
}
/**
* Bezugsgröße sind die ENTSCHIEDENEN Einreichungen. Noch offene Prüfungen
* gehören nicht in den Nenner — sonst sänke die Quote allein dadurch, dass
* der Zeitraum bis an den heutigen Tag reicht.
*/
private function rate(int $published, int $rejected): ?float
{
$decided = $published + $rejected;
return $decided === 0 ? null : round($rejected / $decided * 100, 1);
}
}