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:
Kevin Adametz 2026-09-01 11:57:25 +00:00
parent 81d9c43ab7
commit aab22401d0
6 changed files with 819 additions and 7 deletions

View 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();
});