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>
248 lines
9.7 KiB
PHP
248 lines
9.7 KiB
PHP
<?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;
|
||
}
|
||
}
|