diff --git a/app/Console/Commands/ReportPressReleaseViews.php b/app/Console/Commands/ReportPressReleaseViews.php
index 502c6371..1bd857b4 100644
--- a/app/Console/Commands/ReportPressReleaseViews.php
+++ b/app/Console/Commands/ReportPressReleaseViews.php
@@ -111,7 +111,7 @@ class ReportPressReleaseViews extends Command
}
if ($kohorten !== []) {
- $this->line(' Aufrufe nach Alter (nur Meldungen seit dem Relaunch)');
+ $this->line(" Aufrufe nach Alter am Stichtag {$data['stichtag']} (nur Meldungen seit dem Relaunch)");
$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
diff --git a/app/Console/Commands/SettleUnpaidLegacyInvoices.php b/app/Console/Commands/SettleUnpaidLegacyInvoices.php
index 6e6fc2c8..25875513 100644
--- a/app/Console/Commands/SettleUnpaidLegacyInvoices.php
+++ b/app/Console/Commands/SettleUnpaidLegacyInvoices.php
@@ -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';
}
diff --git a/app/Services/PressRelease/ViewDistributionReport.php b/app/Services/PressRelease/ViewDistributionReport.php
index cb085d28..366c31fd 100644
--- a/app/Services/PressRelease/ViewDistributionReport.php
+++ b/app/Services/PressRelease/ViewDistributionReport.php
@@ -53,12 +53,14 @@ class ViewDistributionReport
* @return array{
* from: string,
* to: string,
+ * stichtag: string,
* portals: list}}>, cohorts: list}>,
* }
*/
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
*/
- 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[] = [
diff --git a/dev/2026-09-01/basiswert-0.1.json b/dev/2026-09-01/basiswert-0.1.json
new file mode 100644
index 00000000..a9c42018
--- /dev/null
+++ b/dev/2026-09-01/basiswert-0.1.json
@@ -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
+ }
+ ]
+ }
+ ]
+}
diff --git a/dev/2026-09-01/basiswerte-block0.md b/dev/2026-09-01/basiswerte-block0.md
index 07b395e3..2606ae92 100644
--- a/dev/2026-09-01/basiswerte-block0.md
+++ b/dev/2026-09-01/basiswerte-block0.md
@@ -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).
---
diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md
index d1c2270e..5681c0d1 100644
--- a/dev/2026-09-01/roadmap-2026.md
+++ b/dev/2026-09-01/roadmap-2026.md
@@ -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 |
diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md
index 29276ec4..9def30e6 100644
--- a/dev/2026-09-01/umsetzung-protokoll.md
+++ b/dev/2026-09-01/umsetzung-protokoll.md
@@ -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=`,
-`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=` 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=""`;
+`livewire:publish --assets`; `queue:restart` + FPM-Neustart;
+`schedule:list` auf `billing:expire-grandfathering` (04:15).
diff --git a/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php b/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
index 69adec1e..e81bef70 100644
--- a/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
+++ b/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
@@ -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);
});
diff --git a/tests/Feature/ConversionBaselineReportsTest.php b/tests/Feature/ConversionBaselineReportsTest.php
index a8c249dc..c5ae69d1 100644
--- a/tests/Feature/ConversionBaselineReportsTest.php
+++ b/tests/Feature/ConversionBaselineReportsTest.php
@@ -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)]);