presseportale/app/Services/PressRelease/ViewDistributionReport.php
Kevin Adametz 2a2215b3c3 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

248 lines
9.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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];
/**
* Alterskohorten in Tagen seit Veröffentlichung (Untergrenze je Klasse).
*
* @var list<int>
*/
private const Cohorts = [0, 7, 14, 21, 28, 56, 91];
/**
* @return array{
* from: string,
* to: string,
* stichtag: 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>}}>, cohorts: list<array{alter: string, anzahl: int, median: int|null, mittelwert: float|null}>}>,
* }
*/
public function build(Carbon $from, ?Carbon $to = null): array
{
$reference = Carbon::now();
$to ??= $reference->copy();
$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,
'cohorts' => $this->cohorts($portal, $from, $to, $reference),
];
}
return [
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'stichtag' => $reference->toDateString(),
'portals' => $portals,
];
}
/**
* Aufrufe nach Alter der Meldung.
*
* Der Grund, warum es diese Aufschlüsselung gibt: `hits` ist ein
* mitlaufender Gesamtzähler, und die Aufrufe wachsen nach der
* Veröffentlichung über Wochen weiter. Eine Kennzahl über ein
* Drei-Monats-Fenster misst deshalb nicht, wie viele Aufrufe eine Meldung
* bekommt, sondern wie viel eine zufällig alte Meldung bisher gesammelt
* hat — und dieser Wert verschiebt sich mit der Altersmischung des
* Fensters. Zwei Messungen zu verschiedenen Zeitpunkten wären damit nicht
* vergleichbar, und genau das braucht der Basiswert.
*
* Vergleichbar ist erst die einzelne Kohorte: „Median der Meldungen im
* Alter von 28 bis 55 Tagen" lässt sich in einem halben Jahr identisch
* erheben. DIESE Zeile ist der Basiswert, nicht die Gesamtzahl.
*
* Nur Meldungen seit dem Relaunch — die Zähler des Altbestands stammen aus
* dem Altsystem und haben keine hier nachvollziehbare Vorgeschichte.
*
* Das Alter zählt ab dem Stichtag der Erhebung (heute), nicht ab `$to`:
* `hits` steht immer auf dem Stand von heute, also muss auch das Alter
* von heute aus gerechnet werden. Mit einem `$to` in der Vergangenheit
* fallen die jungen Kohorten schlicht leer aus — statt Meldungen, die in
* Wahrheit Wochen älter sind, als „0–6 Tage" auszuweisen.
*
* @return list<array{alter: string, anzahl: int, median: int|null, mittelwert: float|null}>
*/
private function cohorts(Portal $portal, Carbon $from, Carbon $to, Carbon $reference): array
{
$bounds = self::Cohorts;
$result = [];
foreach ($bounds as $i => $lowerDays) {
$upperDays = $bounds[$i + 1] ?? null;
// Älter heißt: früher veröffentlicht. Die Altersgrenzen drehen sich
// deshalb zu Datumsgrenzen um — und bleiben im Fenster from..to.
// Alter [lower, upper) Tage: die Untergrenze des Datums ist
// exklusiv, damit die Kohorten lückenlos aneinanderschließen und
// keine Meldung in zwei fällt. Klemmt das Fenster (`$from`) oder
// ist es die älteste Kohorte, bleibt der Fensterbeginn inklusiv.
$newest = $reference->copy()->subDays($lowerDays)->min($to);
$bound = $upperDays === null ? null : $reference->copy()->subDays($upperDays);
$lowerExclusive = $bound !== null && $bound->greaterThanOrEqualTo($from);
$oldest = $bound === null ? $from->copy() : $bound->max($from);
if ($oldest->greaterThan($newest)) {
continue;
}
$hits = $this->hits($portal, $oldest, $newest, true, $lowerExclusive);
$count = count($hits);
$result[] = [
'alter' => $upperDays === null ? "{$lowerDays}+ Tage" : "{$lowerDays}–".($upperDays - 1).' Tage',
'anzahl' => $count,
'median' => $count > 0 ? $this->percentile($hits, 0.50) : null,
'mittelwert' => $count > 0 ? round(array_sum($hits) / $count, 1) : null,
];
}
return $result;
}
/**
* 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.
*
* @param bool $fromExclusive Untergrenze ausschließen (Kohortengrenzen), sonst inklusiv
* @return list<int>
*/
private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew, bool $fromExclusive = false): array
{
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', [$portal->value, Portal::Both->value])
->where('status', PressReleaseStatus::Published->value)
->whereNull('gone_at')
->where('published_at', $fromExclusive ? '>' : '>=', $from)
->where('published_at', '<=', $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;
}
}