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)]);