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>
This commit is contained in:
parent
ac4b9596da
commit
c497628af3
6 changed files with 819 additions and 7 deletions
105
app/Console/Commands/ReportPressReleaseViews.php
Normal file
105
app/Console/Commands/ReportPressReleaseViews.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PressRelease\ViewDistributionReport;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Basiswert der Aufrufzahlen (Roadmap 0.1).
|
||||
*
|
||||
* Muss gemessen sein, BEVOR die erste Maßnahme aus Block 1 live geht — ohne
|
||||
* Vergleichspunkt wird jede spätere Bewertung eine Vermutung. Die Verteilung
|
||||
* entscheidet zugleich E2: ob die Absenderansicht Rohzahlen zeigt oder eine
|
||||
* Einordnung gegen den Portalschnitt.
|
||||
*/
|
||||
class ReportPressReleaseViews extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:view-distribution
|
||||
{--months=3 : Zeitraum in Monaten rückwärts}
|
||||
{--from= : Startdatum, überschreibt --months}
|
||||
{--to= : Enddatum (Standard: heute)}
|
||||
{--json : Ausgabe als JSON statt als Tabelle}';
|
||||
|
||||
protected $description = 'Basiswert: Verteilung der Aufrufzahlen je Portal (Median, Quartile, Häufigkeiten).';
|
||||
|
||||
public function handle(ViewDistributionReport $report): int
|
||||
{
|
||||
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now();
|
||||
$from = $this->option('from') !== null
|
||||
? Carbon::parse((string) $this->option('from'))
|
||||
: $to->copy()->subMonths(max(1, (int) $this->option('months')));
|
||||
|
||||
$data = $report->build($from, $to);
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Aufrufzahlen je Portal — veröffentlicht {$data['from']} bis {$data['to']}");
|
||||
|
||||
foreach ($data['portals'] as $portal) {
|
||||
$this->newLine();
|
||||
$this->line("<comment>{$portal['label']}</comment>");
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($portal['segments'] as $segment) {
|
||||
$s = $segment['stats'];
|
||||
|
||||
if ($s['anzahl'] === 0) {
|
||||
$rows[] = [$segment['segment'], 0, '–', '–', '–', '–', '–', '–', '–'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
$segment['segment'],
|
||||
$s['anzahl'],
|
||||
$s['min'],
|
||||
$s['p10'],
|
||||
$s['p25'],
|
||||
$s['median'],
|
||||
$s['p75'],
|
||||
$s['p90'],
|
||||
$s['max'],
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(['Bestand', 'PMs', 'Min', 'P10', 'P25', 'Median', 'P75', 'P90', 'Max'], $rows);
|
||||
|
||||
$gesamt = $portal['segments'][0]['stats'];
|
||||
|
||||
if ($gesamt['anzahl'] === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' Mittelwert %s gegenüber Median %d%s',
|
||||
$gesamt['mittelwert'],
|
||||
$gesamt['median'],
|
||||
// Die Schiefe ist die eigentliche Antwort auf E2: Zieht eine
|
||||
// Handvoll Ausreißer den Schnitt nach oben, liegt der typische
|
||||
// Absender darunter und eine Rohzahl liest sich für ihn als
|
||||
// Misserfolg.
|
||||
$gesamt['mittelwert'] > $gesamt['median'] * 1.25
|
||||
? ' ← rechtsschief, Rohzahlen wirken für die Mehrheit enttäuschend'
|
||||
: '',
|
||||
));
|
||||
|
||||
$verteilung = [];
|
||||
|
||||
foreach ($gesamt['verteilung'] as $klasse => $anzahl) {
|
||||
$anteil = $gesamt['anzahl'] > 0 ? round($anzahl / $gesamt['anzahl'] * 100) : 0;
|
||||
$verteilung[] = [$klasse.' Aufrufe', $anzahl, $anteil.' %'];
|
||||
}
|
||||
|
||||
$this->table(['Klasse', 'PMs', 'Anteil'], $verteilung);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
90
app/Console/Commands/ReportRejectionRate.php
Normal file
90
app/Console/Commands/ReportRejectionRate.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PressRelease\RejectionRateReport;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Symfony\Component\Console\Helper\TableSeparator;
|
||||
|
||||
/**
|
||||
* Basiswert der Ablehnungsquote je Quelle (Roadmap 0.2).
|
||||
*
|
||||
* Als wiederkehrende Auswertung angelegt: Die Zahl wird für das
|
||||
* connektar-Gespräch gebraucht und ist zugleich der Vergleichspunkt, gegen den
|
||||
* später die Wirkung der Infoseite (1d) gemessen wird.
|
||||
*/
|
||||
class ReportRejectionRate extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:rejection-rate
|
||||
{--months=3 : Zeitraum in Monaten rückwärts}
|
||||
{--from= : Startdatum, überschreibt --months}
|
||||
{--to= : Enddatum (Standard: jetzt)}
|
||||
{--json : Ausgabe als JSON statt als Tabelle}';
|
||||
|
||||
protected $description = 'Basiswert: Ablehnungsquote je Einreichungsquelle (API-Client, Kundenbereich, Redaktion).';
|
||||
|
||||
public function handle(RejectionRateReport $report): int
|
||||
{
|
||||
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now();
|
||||
$from = $this->option('from') !== null
|
||||
? Carbon::parse((string) $this->option('from'))
|
||||
: $to->copy()->subMonths(max(1, (int) $this->option('months')));
|
||||
|
||||
$data = $report->build($from, $to);
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Ablehnungsquote je Quelle — {$data['from']} bis {$data['to']}");
|
||||
$this->newLine();
|
||||
|
||||
if ($data['sources'] === []) {
|
||||
$this->warn('Keine Einreichungen im Zeitraum.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($data['sources'] as $source) {
|
||||
$rows[] = [
|
||||
$source['label'],
|
||||
$source['eingereicht'],
|
||||
$source['veroeffentlicht'],
|
||||
$source['abgelehnt'],
|
||||
$source['offen'],
|
||||
$source['quote'] === null ? '–' : $source['quote'].' %',
|
||||
];
|
||||
}
|
||||
|
||||
$t = $data['totals'];
|
||||
$rows[] = new TableSeparator;
|
||||
$rows[] = [
|
||||
'<comment>gesamt</comment>',
|
||||
$t['eingereicht'],
|
||||
$t['veroeffentlicht'],
|
||||
$t['abgelehnt'],
|
||||
$t['offen'],
|
||||
$t['quote'] === null ? '–' : '<comment>'.$t['quote'].' %</comment>',
|
||||
];
|
||||
|
||||
$this->table(['Quelle', 'Eingereicht', 'Veröffentlicht', 'Abgelehnt', 'Offen', 'Quote'], $rows);
|
||||
|
||||
$this->line(' Quote = abgelehnt / entschieden. Noch offene Prüfungen zählen nicht in den Nenner.');
|
||||
|
||||
if ($data['entscheidungen_ohne_einreichung'] > 0) {
|
||||
$this->newLine();
|
||||
$this->warn(sprintf(
|
||||
'%d Entscheidungen betreffen Einreichungen von VOR dem Zeitraum und sind keiner Quelle zugeordnet. '
|
||||
.'Für einen belastbaren Basiswert den Zeitraum weiter zurück legen.',
|
||||
$data['entscheidungen_ohne_einreichung'],
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
204
app/Services/PressRelease/RejectionRateReport.php
Normal file
204
app/Services/PressRelease/RejectionRateReport.php
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
171
app/Services/PressRelease/ViewDistributionReport.php
Normal file
171
app/Services/PressRelease/ViewDistributionReport.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Basiswert der Aufrufzahlen (Roadmap 0.1): Verteilung der Aufrufe je Portal
|
||||
* über einen Zeitraum.
|
||||
*
|
||||
* Wozu: Die geplante Absenderansicht (1c.1) macht öffentlich, was bisher
|
||||
* niemand belegen musste. Fällt die Verteilung schwach aus, werden Rohzahlen
|
||||
* zum Nachteil und es braucht eine Einordnung gegen den Portalschnitt — das
|
||||
* ist Entscheidung E2, und sie muss VOR dem Bau fallen. Dafür reicht kein
|
||||
* Mittelwert: Eine Handvoll Ausreißer zieht ihn nach oben, während der
|
||||
* typische Absender darunter liegt. Maßgeblich ist der Median.
|
||||
*
|
||||
* Der Mittelwert wird trotzdem mitgeführt, aber nur, damit die Schiefe der
|
||||
* Verteilung sichtbar wird: Liegt er deutlich über dem Median, ist genau der
|
||||
* Fall eingetreten, vor dem E2 schützen soll.
|
||||
*
|
||||
* Gezählt wird `press_releases.hits` — der Gesamtzähler. Das Ereignis-Log
|
||||
* taugt für diesen Basiswert nicht: Es läuft erst seit dem Umbau des
|
||||
* Aufruf-Zählers und kennt den Altbestand nicht.
|
||||
*
|
||||
* Legacy-Bestand und seit dem Relaunch eingegangene Meldungen werden getrennt
|
||||
* ausgewiesen. Ihre Zähler haben unterschiedliche Vorgeschichten (im
|
||||
* Altsystem gesammelt gegenüber hier gezählt), und eine Einordnung, die beide
|
||||
* vermischt, vergleicht Äpfel mit Birnen.
|
||||
*/
|
||||
class ViewDistributionReport
|
||||
{
|
||||
/**
|
||||
* Grenzen der Häufigkeitsverteilung (Untergrenze je Klasse).
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
private const Buckets = [0, 25, 50, 100, 200, 500, 1000];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* from: string,
|
||||
* to: string,
|
||||
* portals: list<array{portal: string, label: string, segments: list<array{segment: string, stats: array{anzahl: int, min?: int, p10?: int, p25?: int, median?: int, p75?: int, p90?: int, max?: int, mittelwert?: float, verteilung?: array<string, int>}}>}>,
|
||||
* }
|
||||
*/
|
||||
public function build(Carbon $from, ?Carbon $to = null): array
|
||||
{
|
||||
$to ??= Carbon::now();
|
||||
|
||||
$portals = [];
|
||||
|
||||
foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) {
|
||||
$segments = [];
|
||||
|
||||
foreach (['gesamt' => null, 'seit Relaunch' => true, 'Altbestand' => false] as $label => $onlyNew) {
|
||||
$hits = $this->hits($portal, $from, $to, $onlyNew);
|
||||
|
||||
if ($hits === [] && $label !== 'gesamt') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = ['segment' => $label, 'stats' => $this->describe($hits)];
|
||||
}
|
||||
|
||||
$portals[] = [
|
||||
'portal' => $portal->value,
|
||||
'label' => $portal->label(),
|
||||
'segments' => $segments,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'from' => $from->toDateString(),
|
||||
'to' => $to->toDateString(),
|
||||
'portals' => $portals,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufrufzahlen der im Zeitraum veröffentlichten Meldungen.
|
||||
*
|
||||
* Eine Meldung mit `portal = both` erscheint auf beiden Portalen und zählt
|
||||
* deshalb bei beiden mit — der Zähler ist einer, die Sichtbarkeit doppelt.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew): array
|
||||
{
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereIn('portal', [$portal->value, Portal::Both->value])
|
||||
->where('status', PressReleaseStatus::Published->value)
|
||||
->whereNull('gone_at')
|
||||
->whereBetween('published_at', [$from, $to])
|
||||
->when($onlyNew === true, fn (Builder $query) => $query->whereNull('legacy_portal'))
|
||||
->when($onlyNew === false, fn (Builder $query) => $query->whereNotNull('legacy_portal'))
|
||||
->orderBy('hits')
|
||||
->pluck('hits')
|
||||
->map(static fn ($hits): int => (int) $hits)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $hits aufsteigend sortiert
|
||||
* @return array{anzahl: int, min?: int, p10?: int, p25?: int, median?: int, p75?: int, p90?: int, max?: int, mittelwert?: float, verteilung?: array<string, int>}
|
||||
*/
|
||||
private function describe(array $hits): array
|
||||
{
|
||||
$count = count($hits);
|
||||
|
||||
if ($count === 0) {
|
||||
return ['anzahl' => 0];
|
||||
}
|
||||
|
||||
return [
|
||||
'anzahl' => $count,
|
||||
'min' => $hits[0],
|
||||
'p10' => $this->percentile($hits, 0.10),
|
||||
'p25' => $this->percentile($hits, 0.25),
|
||||
'median' => $this->percentile($hits, 0.50),
|
||||
'p75' => $this->percentile($hits, 0.75),
|
||||
'p90' => $this->percentile($hits, 0.90),
|
||||
'max' => $hits[$count - 1],
|
||||
'mittelwert' => round(array_sum($hits) / $count, 1),
|
||||
'verteilung' => $this->histogram($hits),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perzentil nach der „nearest rank"-Methode: Der zurückgegebene Wert ist
|
||||
* immer eine tatsächlich gemessene Aufrufzahl, kein interpolierter
|
||||
* Zwischenwert. Für eine Einordnung („die Hälfte der Meldungen liegt
|
||||
* darunter") ist das die ehrlichere Angabe.
|
||||
*
|
||||
* @param list<int> $sorted
|
||||
*/
|
||||
private function percentile(array $sorted, float $fraction): int
|
||||
{
|
||||
$rank = (int) ceil($fraction * count($sorted));
|
||||
|
||||
return $sorted[max(0, $rank - 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $hits
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function histogram(array $hits): array
|
||||
{
|
||||
$bounds = self::Buckets;
|
||||
$result = [];
|
||||
|
||||
foreach ($bounds as $i => $lower) {
|
||||
$upper = $bounds[$i + 1] ?? null;
|
||||
$label = $upper === null ? "{$lower}+" : "{$lower}–".($upper - 1);
|
||||
|
||||
$result[$label] = count(array_filter(
|
||||
$hits,
|
||||
static fn (int $h): bool => $h >= $lower && ($upper === null || $h < $upper),
|
||||
));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,16 +28,58 @@ Der größte ungenutzte Bestand liegt bereits im System: Firmen, deren Meldungen
|
|||
|
||||
## Block 0 — Messbarkeit
|
||||
|
||||
Ohne definierte Konversionspunkte ist keiner der folgenden Blöcke bewertbar.
|
||||
Ohne definierte Konversionspunkte ist keiner der folgenden Blöcke bewertbar. Die Messung läuft nicht über Umami, sondern über eigene Ereignisdaten. Grund: Die zu messende Strecke führt über zwei Domains und einen Magic Link, und ein cookiefreies Analytics-Tool verliert die Sitzung genau an der Stelle, an der die Konversion passiert. Claim, Registrierung und Buchung sind außerdem keine Seitenaufrufe, sondern Zustandsänderungen in der Datenbank, bei denen die Identität ohnehin bekannt ist.
|
||||
|
||||
**Rollenteilung:** Ereignistabelle für den Konversionstrichter (Block 1). Umami für Seitenaufrufe, Suchbegriffe und Herkunft aus der Suche (Block 2). Keine Vermischung.
|
||||
|
||||
### Arbeitspakete
|
||||
|
||||
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
||||
|---|---|---|---|
|
||||
| 0.1 | Konversionspunkte festlegen: Claim eines Newsrooms, Registrierung, erste Buchung, Wiedereinreichung nach Ablehnung | S | keine |
|
||||
| 0.2 | Umami-Ziele einrichten, getrennt nach Herkunft (Veröffentlichungsmail, Ablehnungsmail, organisch) | S | 0.1 |
|
||||
| 0.3 | Bestandsaufnahme Aufrufzahlen: Median und Verteilung der letzten drei Monate je Portal | S | keine |
|
||||
| 0.4 | Ablehnungsquote je Quelle (connektar, openPR, direkt) als wiederkehrende Auswertung | S | keine |
|
||||
| 0.1 | Basiswert Aufrufzahlen: Median und Verteilung je Portal über die letzten drei Monate | S | keine |
|
||||
| 0.2 | Basiswert Ablehnungsquote je Quelle (connektar, openPR, direkt), als wiederkehrende Auswertung angelegt | S | keine |
|
||||
| 0.3 | Tabelle `conversion_events` anlegen: Ereignistyp, Firmen-ID, Meldungs-ID, Quelle, Zeitstempel | S | keine |
|
||||
| 0.4 | Herkunftsparameter in allen Mail-Links, gespeichert **auf dem Magic-Link-Token**, nicht in der Session | S | 0.3 |
|
||||
| 0.5 | Ereignisse an den bestehenden Zustandsänderungen mitschreiben | M | 0.3, 0.4 |
|
||||
| 0.6 | Auswertung als Artisan-Befehl: Trichter je Quelle und Zeitraum | S | 0.5 |
|
||||
| 0.7 | Aufbewahrungsfrist für Ereignisdaten festlegen und in der Datenschutzerklärung führen | S | 0.3 |
|
||||
|
||||
**Warum 0.3 vor Block 1 steht:** Die Sichtbarkeitsansicht macht öffentlich, was bisher nicht belegt werden musste. Fällt die Verteilung schwach aus, werden Rohzahlen zum Nachteil und es braucht eine Einordnung gegen den Portaldurchschnitt. Diese Entscheidung muss vor dem Bau fallen.
|
||||
### Konversionsleiter
|
||||
|
||||
Fünf Stufen statt einer Liste gleichrangiger Punkte. Nur so ist erkennbar, an welcher Stelle es hakt.
|
||||
|
||||
| Stufe | Ereignis | Was ein Abbruch hier bedeutet |
|
||||
|---|---|---|
|
||||
| 1 | Mail-Link geklickt | Betreff oder Anlass trägt nicht |
|
||||
| 2 | Magic Link eingelöst | Der Verwaltungszugang wird nicht verstanden |
|
||||
| 3 | Newsroom bearbeitet | Kein Anreiz, das Profil zu pflegen |
|
||||
| 4 | Konto angelegt | Der Nutzen des Kontos ist nicht erkennbar |
|
||||
| 5 | Erste Buchung | Preis oder Angebot passt nicht |
|
||||
|
||||
Die Wiedereinreichung nach Ablehnung gehört nicht in diese Leiter. Sie misst, ob die Infoseite wirkt, und wird gegen den Basiswert aus 0.2 separat geführt.
|
||||
|
||||
### Quellen
|
||||
|
||||
Jeder Mail-Link trägt einen Herkunftswert, der auf dem Token gespeichert wird und damit Domainwechsel, Mailclient und Weiterleitung überlebt.
|
||||
|
||||
| Wert | Herkunft |
|
||||
|---|---|
|
||||
| `mail_publish` | Veröffentlichungsbenachrichtigung |
|
||||
| `mail_reject` | Ablehnungsbenachrichtigung |
|
||||
| `profile_claim` | Hinweis auf der Newsroom-Seite |
|
||||
| `organic` | keine zugeordnete Herkunft |
|
||||
|
||||
### Reihenfolge
|
||||
|
||||
**Sofort, ohne Vorarbeit:** 0.1 und 0.2. Beides sind Datenbankabfragen. Die Verteilung der Aufrufe entscheidet E2, also ob die Absenderansicht Rohzahlen oder eine Einordnung zeigt. Die Ablehnungsquote je Quelle wird ohnehin für das connektar-Gespräch gebraucht.
|
||||
|
||||
**Vor der ersten Maßnahme aus Block 1:** 0.3 bis 0.5. Herkunftsdaten lassen sich nicht rückwirkend erzeugen. Wer hier zu spät anfängt, sieht später Buchungen, aber nicht ihren Ursprung.
|
||||
|
||||
**Basiswerte müssen vor dem Livegang von Block 1 stehen.** Ohne Vergleichspunkt wird jede spätere Bewertung eine Vermutung.
|
||||
|
||||
### Datensparsamkeit
|
||||
|
||||
Die Ereignistabelle referenziert Firmen- und Meldungs-IDs, keine Personen und keine Klartextadressen. Damit bleibt sie interne Statistik. Die Aufbewahrungsfrist wird in 0.7 gleich mitdefiniert und nicht nachgetragen.
|
||||
|
||||
**Abbruchbedingung:** keine. Block 0 ist Voraussetzung, nicht Wette.
|
||||
|
||||
|
|
@ -90,7 +132,7 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
|
|||
|
||||
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
||||
|---|---|---|---|
|
||||
| 1c.1 | Absenderansicht je Meldung: Aufrufe im Verlauf, Indexierungsstatus, Einordnung gegen Portaldurchschnitt | M | 0.3 |
|
||||
| 1c.1 | Absenderansicht je Meldung: Aufrufe im Verlauf, Indexierungsstatus, Einordnung gegen Portaldurchschnitt | M | 0.1 |
|
||||
| 1c.2 | Zugang zur Ansicht über Magic Link, auch ohne Konto | S | 1b.2 |
|
||||
| 1c.3 | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten | M | 1a.3, 1c.1 |
|
||||
|
||||
|
|
|
|||
200
tests/Feature/ConversionBaselineReportsTest.php
Normal file
200
tests/Feature/ConversionBaselineReportsTest.php
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\RejectionRateReport;
|
||||
use App\Services\PressRelease\ViewDistributionReport;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Basiswerte aus Block 0 der Roadmap. Beide müssen gemessen sein, bevor die
|
||||
* erste Maßnahme aus Block 1 live geht — ohne Vergleichspunkt wäre jede
|
||||
* spätere Bewertung eine Vermutung.
|
||||
*/
|
||||
function baselineRelease(Portal $portal, int $hits, array $attributes = []): PressRelease
|
||||
{
|
||||
return PressRelease::factory()
|
||||
->published()
|
||||
->forPortal($portal)
|
||||
->create(array_merge([
|
||||
'hits' => $hits,
|
||||
'published_at' => now()->subDays(10),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function baselineLog(PressRelease $release, string $toStatus, string $source, ?User $user, string $at): void
|
||||
{
|
||||
PressReleaseStatusLog::query()->create([
|
||||
'press_release_id' => $release->getKey(),
|
||||
'to_status' => $toStatus,
|
||||
'source' => $source,
|
||||
'changed_by_user_id' => $user?->getKey(),
|
||||
'created_at' => $at,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 0.1 Aufrufe
|
||||
|
||||
test('the view distribution reports median and quartiles per portal', function () {
|
||||
/** @var TestCase $this */
|
||||
foreach ([10, 20, 30, 40, 50] as $hits) {
|
||||
baselineRelease(Portal::Presseecho, $hits);
|
||||
}
|
||||
|
||||
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
|
||||
$presseecho = collect($data['portals'])->firstWhere('portal', 'presseecho');
|
||||
$stats = $presseecho['segments'][0]['stats'];
|
||||
|
||||
expect($stats['anzahl'])->toBe(5)
|
||||
->and($stats['min'])->toBe(10)
|
||||
->and($stats['median'])->toBe(30)
|
||||
->and($stats['max'])->toBe(50)
|
||||
->and($stats['mittelwert'])->toBe(30.0);
|
||||
});
|
||||
|
||||
test('the view distribution counts a both-portal release on both portals', function () {
|
||||
/** @var TestCase $this */
|
||||
baselineRelease(Portal::Both, 77);
|
||||
|
||||
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
|
||||
|
||||
// Der Zähler ist einer, die Sichtbarkeit doppelt — für die Einordnung
|
||||
// eines Absenders zählt die Meldung auf beiden Portalen mit.
|
||||
foreach ($data['portals'] as $portal) {
|
||||
expect($portal['segments'][0]['stats']['median'])->toBe(77);
|
||||
}
|
||||
});
|
||||
|
||||
test('the view distribution leaves out removed releases', function () {
|
||||
/** @var TestCase $this */
|
||||
baselineRelease(Portal::Presseecho, 100);
|
||||
baselineRelease(Portal::Presseecho, 999, ['gone_at' => now()]);
|
||||
|
||||
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
|
||||
$stats = collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'][0]['stats'];
|
||||
|
||||
// Eine mit 410 entfernte Meldung ist nicht mehr öffentlich und darf den
|
||||
// Portalschnitt nicht mitbestimmen.
|
||||
expect($stats['anzahl'])->toBe(1)
|
||||
->and($stats['max'])->toBe(100);
|
||||
});
|
||||
|
||||
test('the view distribution separates legacy stock from post-relaunch releases', function () {
|
||||
/** @var TestCase $this */
|
||||
baselineRelease(Portal::Presseecho, 20, ['legacy_portal' => Portal::Presseecho->value, 'legacy_id' => 4711]);
|
||||
baselineRelease(Portal::Presseecho, 200);
|
||||
|
||||
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
|
||||
$segments = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'])
|
||||
->keyBy('segment');
|
||||
|
||||
// Beide Zähler haben verschiedene Vorgeschichten; eine Einordnung, die sie
|
||||
// vermischt, vergleicht Äpfel mit Birnen.
|
||||
expect($segments['seit Relaunch']['stats']['median'])->toBe(200)
|
||||
->and($segments['Altbestand']['stats']['median'])->toBe(20);
|
||||
});
|
||||
|
||||
test('the view distribution ignores releases published outside the window', function () {
|
||||
/** @var TestCase $this */
|
||||
baselineRelease(Portal::Presseecho, 50);
|
||||
baselineRelease(Portal::Presseecho, 5000, ['published_at' => now()->subYear()]);
|
||||
|
||||
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
|
||||
|
||||
expect(collect($data['portals'])->firstWhere('portal', 'presseecho')['segments'][0]['stats']['anzahl'])->toBe(1);
|
||||
});
|
||||
|
||||
test('the view distribution command renders without data', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->artisan('press-releases:view-distribution')->assertSuccessful();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- 0.2 Ablehnungsquote
|
||||
|
||||
test('the rejection rate is reported per submitting source', function () {
|
||||
/** @var TestCase $this */
|
||||
$api = User::factory()->create(['agency_name' => 'connektar.de Presseverteiler']);
|
||||
$customer = User::factory()->create();
|
||||
|
||||
foreach (range(1, 3) as $i) {
|
||||
$release = baselineRelease(Portal::Presseecho, 0);
|
||||
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(5)->toDateTimeString());
|
||||
baselineLog($release, $i === 1 ? PressReleaseStatus::Rejected->value : PressReleaseStatus::Published->value, 'ki', null, now()->subDays(4)->toDateTimeString());
|
||||
}
|
||||
|
||||
$release = baselineRelease(Portal::Presseecho, 0);
|
||||
baselineLog($release, PressReleaseStatus::Review->value, 'customer', $customer, now()->subDays(5)->toDateTimeString());
|
||||
baselineLog($release, PressReleaseStatus::Published->value, 'ki', null, now()->subDays(4)->toDateTimeString());
|
||||
|
||||
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
|
||||
$sources = collect($data['sources'])->keyBy('key');
|
||||
|
||||
// Gruppiert wird nach dem EINREICHENDEN, nicht nach dem Entscheider — die
|
||||
// Entscheidung trifft immer die KI-Prüfung und wäre als Gruppierung wertlos.
|
||||
expect($sources['api:'.$api->id]['label'])->toContain('connektar.de Presseverteiler')
|
||||
->and($sources['api:'.$api->id]['eingereicht'])->toBe(3)
|
||||
->and($sources['api:'.$api->id]['abgelehnt'])->toBe(1)
|
||||
->and($sources['api:'.$api->id]['quote'])->toBe(33.3)
|
||||
->and($sources['customer']['label'])->toBe('direkt (Kundenbereich)')
|
||||
->and($sources['customer']['quote'])->toBe(0.0)
|
||||
->and($data['totals']['quote'])->toBe(25.0);
|
||||
});
|
||||
|
||||
test('a resubmission after rejection counts as its own submission', function () {
|
||||
/** @var TestCase $this */
|
||||
$api = User::factory()->create(['agency_name' => 'connektar.de Presseverteiler']);
|
||||
$release = baselineRelease(Portal::Presseecho, 0);
|
||||
|
||||
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(9)->toDateTimeString());
|
||||
baselineLog($release, PressReleaseStatus::Rejected->value, 'ki', null, now()->subDays(8)->toDateTimeString());
|
||||
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDays(3)->toDateTimeString());
|
||||
baselineLog($release, PressReleaseStatus::Published->value, 'ki', null, now()->subDays(2)->toDateTimeString());
|
||||
|
||||
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
|
||||
|
||||
// Genau diese Größe soll die Infoseite (1d) verändern: dieselbe Meldung,
|
||||
// überarbeitet, mit eigenem Ausgang.
|
||||
expect($data['totals']['eingereicht'])->toBe(2)
|
||||
->and($data['totals']['abgelehnt'])->toBe(1)
|
||||
->and($data['totals']['veroeffentlicht'])->toBe(1)
|
||||
->and($data['totals']['quote'])->toBe(50.0);
|
||||
});
|
||||
|
||||
test('an undecided submission is counted as open, not as published', function () {
|
||||
/** @var TestCase $this */
|
||||
$api = User::factory()->create();
|
||||
$release = baselineRelease(Portal::Presseecho, 0);
|
||||
|
||||
baselineLog($release, PressReleaseStatus::Review->value, 'api', $api, now()->subDay()->toDateTimeString());
|
||||
|
||||
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
|
||||
|
||||
// Offene Prüfungen dürfen nicht in den Nenner — sonst sänke die Quote
|
||||
// allein dadurch, dass der Zeitraum bis heute reicht.
|
||||
expect($data['totals']['offen'])->toBe(1)
|
||||
->and($data['totals']['quote'])->toBeNull();
|
||||
});
|
||||
|
||||
test('a decision without a submission in the window is reported, not swallowed', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = baselineRelease(Portal::Presseecho, 0);
|
||||
|
||||
baselineLog($release, PressReleaseStatus::Review->value, 'api', null, now()->subMonths(6)->toDateTimeString());
|
||||
baselineLog($release, PressReleaseStatus::Rejected->value, 'ki', null, now()->subDay()->toDateTimeString());
|
||||
|
||||
$data = app(RejectionRateReport::class)->build(now()->subMonths(3));
|
||||
|
||||
// Ein zu kurz gewählter Zeitraum sähe sonst wie eine niedrige Quote aus.
|
||||
expect($data['entscheidungen_ohne_einreichung'])->toBe(1)
|
||||
->and($data['totals']['eingereicht'])->toBe(0);
|
||||
});
|
||||
|
||||
test('the rejection rate command reports an empty window plainly', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->artisan('press-releases:rejection-rate')
|
||||
->expectsOutputToContain('Keine Einreichungen im Zeitraum.')
|
||||
->assertSuccessful();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue