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[] = [

View file

@ -0,0 +1,244 @@
{
"from": "2026-06-01",
"to": "2026-09-01",
"portals": [
{
"portal": "presseecho",
"label": "Presseecho",
"segments": [
{
"segment": "gesamt",
"stats": {
"anzahl": 1413,
"min": 24,
"p10": 65,
"p25": 95,
"median": 126,
"p75": 183,
"p90": 229,
"max": 496,
"mittelwert": 141.4,
"verteilung": {
"0–24": 1,
"25–49": 90,
"50–99": 309,
"100–199": 735,
"200–499": 278,
"500–999": 0,
"1000+": 0
}
}
},
{
"segment": "seit Relaunch",
"stats": {
"anzahl": 1137,
"min": 58,
"p10": 86,
"p25": 103,
"median": 128,
"p75": 178,
"p90": 233,
"max": 420,
"mittelwert": 146.7,
"verteilung": {
"0–24": 0,
"25–49": 0,
"50–99": 244,
"100–199": 680,
"200–499": 213,
"500–999": 0,
"1000+": 0
}
}
},
{
"segment": "Altbestand",
"stats": {
"anzahl": 276,
"min": 24,
"p10": 34,
"p25": 43,
"median": 72,
"p75": 196,
"p90": 216,
"max": 496,
"mittelwert": 119.7,
"verteilung": {
"0–24": 1,
"25–49": 90,
"50–99": 65,
"100–199": 55,
"200–499": 65,
"500–999": 0,
"1000+": 0
}
}
}
],
"cohorts": [
{
"alter": "0–6 Tage",
"anzahl": 188,
"median": 109,
"mittelwert": 112.5
},
{
"alter": "7–13 Tage",
"anzahl": 146,
"median": 108,
"mittelwert": 112.2
},
{
"alter": "14–20 Tage",
"anzahl": 171,
"median": 104,
"mittelwert": 114.6
},
{
"alter": "21–27 Tage",
"anzahl": 136,
"median": 128,
"mittelwert": 139.7
},
{
"alter": "28–55 Tage",
"anzahl": 351,
"median": 202,
"mittelwert": 206.8
},
{
"alter": "56–90 Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
},
{
"alter": "91+ Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
}
]
},
{
"portal": "businessportal24",
"label": "Businessportal24",
"segments": [
{
"segment": "gesamt",
"stats": {
"anzahl": 1466,
"min": 26,
"p10": 149,
"p25": 243,
"median": 303,
"p75": 354,
"p90": 409,
"max": 1536,
"mittelwert": 303.5,
"verteilung": {
"0–24": 0,
"25–49": 24,
"50–99": 52,
"100–199": 168,
"200–499": 1152,
"500–999": 61,
"1000+": 9
}
}
},
{
"segment": "seit Relaunch",
"stats": {
"anzahl": 1173,
"min": 99,
"p10": 213,
"p25": 264,
"median": 310,
"p75": 353,
"p90": 395,
"max": 1536,
"mittelwert": 311.6,
"verteilung": {
"0–24": 0,
"25–49": 0,
"50–99": 1,
"100–199": 96,
"200–499": 1047,
"500–999": 28,
"1000+": 1
}
}
},
{
"segment": "Altbestand",
"stats": {
"anzahl": 293,
"min": 26,
"p10": 54,
"p25": 97,
"median": 198,
"p75": 377,
"p90": 591,
"max": 1365,
"mittelwert": 271.1,
"verteilung": {
"0–24": 0,
"25–49": 24,
"50–99": 51,
"100–199": 72,
"200–499": 105,
"500–999": 33,
"1000+": 8
}
}
}
],
"cohorts": [
{
"alter": "0–6 Tage",
"anzahl": 187,
"median": 252,
"mittelwert": 253.9
},
{
"alter": "7–13 Tage",
"anzahl": 154,
"median": 294,
"mittelwert": 298.2
},
{
"alter": "14–20 Tage",
"anzahl": 175,
"median": 336,
"mittelwert": 342.1
},
{
"alter": "21–27 Tage",
"anzahl": 142,
"median": 319,
"mittelwert": 330.8
},
{
"alter": "28–55 Tage",
"anzahl": 368,
"median": 338,
"mittelwert": 338.1
},
{
"alter": "56–90 Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
},
{
"alter": "91+ Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
}
]
}
]
}

View file

@ -70,9 +70,52 @@ erheben.
> `php artisan press-releases:view-distribution --json > basiswert-0.1.json`
>
> **21.09.2026:** Lauf mit `--from=2026-06-01 --to=2026-09-01 --json` auf
> dem Live-Server erfolgt, Datei liegt dort unter
> `dev/2026-09-01/basiswert-0.1.json`. Sobald sie im Repo ist, wird die
> Zeile „28–55 Tage" je Portal hier eingetragen.
> dem Live-System, Datei `basiswert-0.1.json` liegt im Ordner. Auswertung
> im nächsten Abschnitt.
### Erhebung 21.09.2026 — dasselbe Fenster, drei Wochen später
Gleiches Fenster (01.06.–01.09.), gemessen am 21.09. statt am 01.09.
(`basiswert-0.1.json`):
| Portal | PMs | Median 01.09. | **Median 21.09.** | Mittelwert 21.09. | P10 / P90 |
|---|---|---|---|---|---|
| Presseecho | 1413 | 98 | **126** | 141,4 | 65 / 229 |
| Businessportal24 | 1466 | 200 | **303** | 303,5 | 149 / 409 |
Der Gesamtmedian ist in drei Wochen um 29 % (Presseecho) bzw. 52 %
(Businessportal24) gestiegen, ohne dass sich am Fenster etwas geändert hat
— genau der Effekt, vor dem der Abschnitt oben warnt. (Die PM-Zahl ist um
23 bzw. 25 gesunken: seither offline genommene Meldungen.)
Kohorten laut Datei (Alter gerechnet ab `--to` = 01.09., Zähler aber
Stand 21.09. — das **tatsächliche Alter liegt 20 Tage höher**):
| Zeile in der Datei | tatsächliches Alter | Presseecho PMs / Median | Businessportal24 PMs / Median |
|---|---|---|---|
| 0–6 Tage | 20–26 | 188 / 109 | 187 / 252 |
| 7–13 Tage | 27–33 | 146 / 108 | 154 / 294 |
| 14–20 Tage | 34–40 | 171 / 104 | 175 / 336 |
| 21–27 Tage | 41–47 | 136 / 128 | 142 / 319 |
| 28–55 Tage | 48–75 (nur ab Go-Live 20.07.) | 351 / 202 | 368 / 338 |
Lesbar ist daraus schon einiges: Businessportal24 erreicht ab etwa fünf
Wochen ein Plateau um 320–340 Aufrufe; Presseecho liegt bei rund 105–130
und springt nur in der Go-Live-Kohorte (Meldungen vom 20.07.–04.08.) auf
202 — vermutlich der Erstcrawl nach dem Umzug, kein Dauerzustand.
**Als Basiswert taugt diese Kohortenzeile trotzdem nicht:** Der Report
rechnete das Alter ab `--to`, die Zähler stehen aber immer auf heute. Mit
einem Enddatum in der Vergangenheit verschieben sich alle Kohorten um die
Verzögerung des Laufs. Seit dem 21.09.2026 zählt der Report das Alter ab
dem Stichtag der Erhebung (Feld `stichtag` in der JSON-Ausgabe); ein
`--to` in der Vergangenheit lässt die jungen Kohorten leer.
> **Offen (letzter Schritt für 0.1):** einmal mit Stichtag = heute laufen
> lassen, dann steht die Zeile „28–55 Tage" sauber:
> `php artisan press-releases:view-distribution --from=2026-06-01 --json > dev/2026-09-01/basiswert-0.1-kohorten.json`
> (ohne `--to`; auch mit dem Live-Stand vor dem Fix korrekt, weil dann
> Enddatum und Stichtag zusammenfallen).
---

View file

@ -36,7 +36,7 @@ Ohne definierte Konversionspunkte ist keiner der folgenden Blöcke bewertbar. Di
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 0.1 | Basiswert Aufrufzahlen: Median und Verteilung je Portal über die letzten drei Monate | S | keine |
| 0.1 | Basiswert Aufrufzahlen: Median und Verteilung je Portal über die letzten drei Monate — **erhoben 01.09. und 21.09.2026** (`basiswerte-block0.md`); Kohortenzeile „28–55 Tage" braucht noch einen Lauf mit Stichtag = heute | S | keine |
| 0.2 | Basiswert Ablehnungsquote je Quelle (API-Client, 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 |

View file

@ -2337,11 +2337,42 @@ meldet „Seiten- und Sitemap-Caches geleert." (`CanonicalAssignmentTest`
prüft das über den vorher gecachten Sitemap-Stand). Für den Live-Lauf vom
21.09. ist das inzwischen durch die TTL erledigt.
**Noch zu prüfen auf dem Server** (nicht Teil der Ausgabe oben):
`FEED_FETCHES_RECORDED_SINCE=2026-09-21` und
`CRAWLER_HITS_RECORDED_SINCE=2026-09-21` in der `.env` + `config:cache`;
`categories:seed-descriptions` (2.3); `livewire:publish --assets`;
`queue:restart` und FPM-Neustart; Audit-Plan „Live-Schritte nach dem
Deploy" (`company-memberships:audit`, `--classify --before=<Deploy-Zeitpunkt>`,
`schedule:list` auf `billing:expire-grandfathering`); Rechnungs-Paket
`legacy:settle-unpaid --dry-run` → scharf.
**Nach-Deploy-Schritte, Stand 21.09.2026 17:25** (vom Betreiber
ausgeführt, teils schon direkt nach dem Deploy):
| Schritt | Ergebnis |
|---|---|
| `config:cache` | erneut gelaufen |
| `categories:seed-descriptions` | 28 × „bereits vorhanden" — war schon nach dem Deploy gelaufen |
| `company-memberships:audit` | 91.439 responsible-Zuordnungen, 58.275 über eine E-Mail erklärbar, 33.164 nicht; seit Go-Live 17 nicht erklärbare (12 × connektar-Konto 13993 an API-Firmen vom 20.07., 4 × Dr. Frenzel, 1 × t2t-soft) — alle `manual`, bleiben geschützt. CSV in `storage/app`. **`--classify --before=<Deploy-Zeitpunkt>` steht noch aus.** |
| `legacy:settle-unpaid --dry-run` | 7 `untouched_paid`, 1 „Konflikt", sonst 0 — siehe unten |
| `basiswert-0.1.json` | ins Repo übernommen, ausgewertet in `basiswerte-block0.md` |
**Lesart `settle-unpaid`:** Die Dev-Kopie hatte 22 Bestandsschutz-Posten
(7 paid, 9 hold, 5 reminder, 1 pending). Live sieht der Dry-Run nur noch
8: die 7 bezahlten und UPO #13, dessen Quelle `43541-7` schon
`superseded` trägt — den Status schreibt ausschließlich der MAN-Kreis beim
Neuausstellen. Der scharfe Lauf war also bereits erfolgt (hold-Bündel
gekündigt, offene Vorgänge neu ausgestellt); der Befehl kannte nur seine
eigene frühere Arbeit nicht und meldete sie als Konflikt. **Behoben:**
Quelle `superseded` zählt jetzt als `already_reissued` (mit der
ersetzenden MAN-Nummer), kein Konflikt (`LegacySettleUnpaidCommandTest`).
Für User 19076 einmal in Admin ▸ Zahlungen prüfen, dass die ersetzende
MAN-Rechnung offen ist.
**Befund 0.1, behoben:** `view-distribution` rechnete das Kohortenalter ab
`--to`, die Zähler stehen aber immer auf heute — mit `--to=2026-09-01` am
21.09. waren alle Kohorten um 20 Tage zu jung ausgewiesen. Der Report
zählt das Alter jetzt ab dem Stichtag der Erhebung (Feld `stichtag`,
Tabellenüberschrift nennt ihn); ein Enddatum in der Vergangenheit lässt
die jungen Kohorten leer. Die gelieferte Datei ist als Fenster-Verteilung
verwertet (Median 126 / 303 statt 98 / 200 drei Wochen zuvor — der
Beleg, warum der Gesamtmedian kein Basiswert ist). Für die saubere
Kohortenzeile fehlt ein Lauf ohne `--to`:
`press-releases:view-distribution --from=2026-06-01 --json`.
**Noch offen auf dem Server:** `.env` `FEED_FETCHES_RECORDED_SINCE` und
`CRAWLER_HITS_RECORDED_SINCE` auf `2026-09-21` (dann `config:cache`);
`company-memberships:audit --classify --before="<Zeitpunkt des migrate>"`;
`livewire:publish --assets`; `queue:restart` + FPM-Neustart;
`schedule:list` auf `billing:expire-grandfathering` (04:15).

View file

@ -217,7 +217,20 @@ test('dry-run writes nothing and a second real run is a no-op', function () {
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect(Invoice::count())->toBe(1);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
// Zweiter Lauf: die schon ersetzte Alt-Rechnung ist kein Konflikt, sondern erledigt.
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->doesntExpectOutputToContain('Konflikt')
->expectsTable(['Ergebnis', 'Anzahl'], [
['bundles_cancelled', 0],
['man_invoices_voided', 0],
['reopened', 0],
['man_invoices_issued', 0],
['singles_written_off', 0],
['conflicts', 0],
['untouched_paid', 0],
['already_reissued', 1],
])
->assertSuccessful();
expect(Invoice::count())->toBe(1);
expect(UserPaymentOption::query()->where('status', UserPaymentOptionStatus::Cancelled->value)->count())->toBe(1);
});

View file

@ -130,6 +130,27 @@ test('the view distribution groups releases into age cohorts', function () {
->and($cohorts['28–55 Tage']['median'])->toBe(300);
});
test('the age cohorts count from today even when the window ends in the past', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 10, ['published_at' => now()->subDays(2)]);
baselineRelease(Portal::Presseecho, 300, ['published_at' => now()->subDays(40)]);
baselineRelease(Portal::Presseecho, 700, ['published_at' => now()->subDays(100)]);
// `hits` steht auf dem Stand von heute — eine vor 40 Tagen veröffentlichte
// Meldung ist 40 Tage alt, auch wenn das Fenster vor 20 Tagen endet. Die
// Meldung von vorgestern liegt außerhalb des Fensters; die junge Kohorte
// fällt leer aus, statt 20 Tage alte Meldungen als „0–6 Tage" zu führen.
$data = app(ViewDistributionReport::class)->build(now()->subDays(120), now()->subDays(20));
$cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
->keyBy('alter');
expect($data['stichtag'])->toBe(now()->toDateString())
->and($cohorts->has('0–6 Tage'))->toBeFalse()
->and($cohorts['28–55 Tage']['anzahl'])->toBe(1)
->and($cohorts['28–55 Tage']['median'])->toBe(300)
->and($cohorts['91+ Tage']['median'])->toBe(700);
});
test('the age cohorts leave out legacy stock', function () {
/** @var TestCase $this */
baselineRelease(Portal::Presseecho, 50, ['published_at' => now()->subDays(3)]);