reports: Kohortenalter ab Stichtag statt ab --to; legacy:settle-unpaid kennt bereits ersetzte Quellen; dev: Basiswert 0.1 vom Live-System ausgewertet

view-distribution rechnete das Alter der Kohorten ab dem Enddatum des
Fensters, die Zähler stehen aber immer auf heute — mit einem --to in der
Vergangenheit waren alle Kohorten um die Verzögerung des Laufs zu jung.
Das Alter zählt jetzt ab dem Stichtag der Erhebung (Feld stichtag), junge
Kohorten fallen bei altem Enddatum leer aus.

settle-unpaid meldete beim zweiten Lauf einen Posten, dessen Quelle der
erste Lauf schon per MAN-Rechnung ersetzt hatte (superseded), als
Konflikt. Solche Quellen zählen jetzt als already_reissued.

Basiswerte Block 0: Live-Erhebung vom 21.09.2026 (gleiches Fenster,
Median 126/303 statt 98/200) eingetragen, Kohortenzeile mit Alters-
Versatz erklärt, letzter Schritt für 0.1 benannt (Lauf ohne --to).
Protokoll: Nach-Deploy-Schritte, Lesart von Audit und Rechnungslauf.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-21 15:32:32 +00:00
parent eb658020e5
commit 1c38c820f4
9 changed files with 392 additions and 21 deletions

View file

@ -111,7 +111,7 @@ class ReportPressReleaseViews extends Command
}
if ($kohorten !== []) {
$this->line(' <comment>Aufrufe nach Alter (nur Meldungen seit dem Relaunch)</comment>');
$this->line(" <comment>Aufrufe nach Alter am Stichtag {$data['stichtag']} (nur Meldungen seit dem Relaunch)</comment>");
$this->table(['Alter der Meldung', 'PMs', 'Median', 'Mittelwert'], $kohorten);
// Der Gesamtmedian oben hängt an der Altersmischung des
// Fensters und ist zwischen zwei Messzeitpunkten nicht

View file

@ -73,6 +73,7 @@ class SettleUnpaidLegacyInvoices extends Command
'singles_written_off' => [],
'conflicts' => [],
'untouched_paid' => [],
'already_reissued' => [],
];
if ($this->dryRun) {
@ -161,6 +162,15 @@ class SettleUnpaidLegacyInvoices extends Command
continue;
}
// Ein früherer Lauf hat die Forderung schon als MAN-Rechnung neu
// ausgestellt — der Posten bleibt Bestandsschutz, die Quelle trägt
// `superseded`. Kein Konflikt, nichts zu tun.
if ($status === LegacyInvoice::STATUS_SUPERSEDED) {
$this->report['already_reissued'][] = $this->describe($post, $source).' · ersetzt durch '.($source->raw_snapshot['superseded_by_invoice_number'] ?? '?');
continue;
}
// hold im gemischten Bündel oder Quelle nicht auffindbar → manuell klären.
$this->report['conflicts'][] = $this->describe($post, $source).' · Status '.($status ?? 'unbekannt').' im gemischten Bündel';
}

View file

@ -53,12 +53,14 @@ class ViewDistributionReport
* @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
{
$to ??= Carbon::now();
$reference = Carbon::now();
$to ??= $reference->copy();
$portals = [];
@ -79,13 +81,14 @@ class ViewDistributionReport
'portal' => $portal->value,
'label' => $portal->label(),
'segments' => $segments,
'cohorts' => $this->cohorts($portal, $from, $to),
'cohorts' => $this->cohorts($portal, $from, $to, $reference),
];
}
return [
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'stichtag' => $reference->toDateString(),
'portals' => $portals,
];
}
@ -109,9 +112,15 @@ class ViewDistributionReport
* 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): array
private function cohorts(Portal $portal, Carbon $from, Carbon $to, Carbon $reference): array
{
$bounds = self::Cohorts;
$result = [];
@ -120,15 +129,15 @@ class ViewDistributionReport
$upperDays = $bounds[$i + 1] ?? null;
// Älter heißt: früher veröffentlicht. Die Altersgrenzen drehen sich
// deshalb zu Datumsgrenzen um.
$newest = $to->copy()->subDays($lowerDays);
$oldest = $upperDays === null ? $from : $to->copy()->subDays($upperDays - 1);
// deshalb zu Datumsgrenzen um — und bleiben im Fenster from..to.
$newest = $reference->copy()->subDays($lowerDays)->min($to);
$oldest = ($upperDays === null ? $from : $reference->copy()->subDays($upperDays - 1))->max($from);
if ($oldest->greaterThan($newest)) {
continue;
}
$hits = $this->hits($portal, $oldest->max($from), $newest->min($to), true);
$hits = $this->hits($portal, $oldest, $newest, true);
$count = count($hits);
$result[] = [