mivita/dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md
Kevin 979a12d1cb Doku: Deploy-Stand 25.09. markiert, Challenge-Start 9. Oktober
A1-A4 und E1 live seit 23.09., B2, A07, Abo-Warnung und E1-Nachtraege
live seit 25.09., Settings auf 0, Montenegro beendet. Offen: erster
user:cleanup-Lauf, Seeder fuer den Challenge-Start 09.10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 10:28:27 +00:00

53 KiB
Raw Blame History

Bewertung des Statistik-Audits vom 09.09.2026

Gegenprüfung des Auditberichts und des Prüfprotokolls am Arbeitsstand vom 09.09.2026.

Auftrag: nur prüfen und bewerten, keine Umsetzung. Es wurde kein Anwendungscode geändert.

Methode dieser Gegenprüfung: jede zitierte Codestelle gelesen; zusätzlich — und das ist der Unterschied zum Audit — lesende Abfragen gegen die Entwicklungsdatenbank, um zu klären, welche der beschriebenen Fehlerbilder in den vorhandenen Daten tatsächlich auftreten und welche rein theoretisch sind. Der Audit hat ausschließlich mit synthetischen Fixtures gearbeitet und das auch offen so benannt; genau diese Lücke schließt die folgende Bewertung.


Stand: 10.09.2026

Wie dieses Dokument zu lesen ist. Die Abschnitte 1–7 sind die ursprüngliche Bewertung vom 09.09. — sie beschreiben den Stand vor der Umsetzung und sind an einzelnen Stellen inzwischen überholt (jeweils oben im Abschnitt vermerkt). Die Abschnitte 8–13 sind die Nachträge zur Umsetzung. Maßgeblich ist immer der jüngste Abschnitt. Diese Tabelle hier ist der Einstieg.

Erledigt

Befund Was behoben wurde Details
A02 Persönliche Statistik zählt nur noch freigegebene Buchungen; Storno im Segment seiner Ursprungsbuchung 12
A03 Negative Monatssalden erscheinen im Drilldown; Übersicht und Detail stimmen überein 12
A04 Abgeschlossene Monate werden vollständig eingefroren, kein Live-Overlay mehr 13 · eigenes Dokument
A06 Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet 11
A08 Prognose auch ohne bereits erreichte Qualifikationsstufe 12
A09 Summenzeile im Neupartner-CSV; Breitenprüfung für alle Exporte 12
A10 Kundenabo-Liste lädt die Lieferungszahl vor 12
A12 Kachel und verlinkte Liste stammen aus derselben Quelle 13
A15 Storno trägt seine Ursprungsart; Eigen- und Kundenpunkte überschneidungsfrei 9 · 11
A17 Fälligkeitsgruppen ohne Monatsüberlauf 12
A18 Formelinhalte im CSV als Text gekennzeichnet, in beiden Exporten 12
A19 Fehlgeschlagene Provisionsberechnung ist als solche erkennbar 12
A01 Aktiv-Schalter wirkt nur noch im Admin-Pfad; Mindestlaufzeit nicht mehr umgehbar 14
A16 Statuswechsel werden zentral am Model protokolliert, in jedem Schreibpfad 14
A11 Gleichstände jenseits des Auswahlfensters entscheiden wieder über den Namen 15
A13 Der heutige Liefertag zählt zur Prognose, solange die Ausführung aussteht 15
A14 Tabellen sortieren über unformatierte Werte statt über den Anzeigetext 15
R01–R03 Befunde der Nachprüfung an der eigenen Umsetzung 11
A07 Payline-Kontext (Home-Kacheln „Teampartner/Abos in der Payline“, Payline-Prognose) nimmt die Mitglieder aus dem Provisionsbaum wie Team-Statistik und Level-Prognose — umgesetzt 23.09., live seit 25.09. An der Live-Kopie betrifft das 102 von 162 Beratern mit Payline (z. B. 527 → 403 Mitglieder). cache:clear nach dem Deploy ist erledigt (6-h-Cache payline_context_*). PaylineForecastService::buildPaylineContext(), Test in PaylineForecastServiceTest

Dazu zwei Regeln, die dabei entschieden wurden:

  • Stornoregel — ein Storno mindert nur den Monat der Ursprungsbestellung; abgeschlossene Monate bleiben unberührt. Schalter in den Settings, standardmäßig aus. → Abschnitt 8
  • Monatsabschluss — ein abgerechneter Monat wird vollständig eingefroren, einschließlich Team-Struktur und Detaillisten. → monatsabschluss-eingefrorene-statistik.md

Offen

Befund Warum offen
A22, A23 Kein Problem in dieser Größenordnung (7 VIP-Konten) · Cache-Strategie ohnehin offen
A05, A20, A21 Kundenentscheidungen: Bedeutung der Spalte „Abos" · Sichtbarkeit der Profilfotos · Spanisch im Backoffice (44 Konten)

Deployment des gesamten Pakets

Erledigt am 10.09.2026, ohne Fehler — Einzelheiten und Auswirkung auf die historischen Zahlen in deploy-2026-09-10.md.

php artisan migrate
php artisan backoffice:store-statistics-snapshots --force   # einmalig, Payload-Version 2
php artisan config:clear && php artisan view:clear && php artisan cache:clear

1. Gesamturteil

Der Audit ist fachlich solide und ehrlich gearbeitet. Die zitierten Codestellen stimmen, die Zeilennummern stimmen, die Reproduktionen sind nachvollziehbar, und die Grenzen der eigenen Aussage werden an jeder Stelle mitgeschrieben ("nicht mit Produktionsdaten ermittelt", "kein End-to-End-Test"). Die angegebenen Testergebnisse ließen sich exakt reproduzieren:

Prüfung Audit Nachgeprüft
Statistik-Kernmodule 109 bestanden, 678 Assertions 109 bestanden, 678 Assertions ✔
LocalizationSyncTest 2 fehlgeschlagen (es/fr payment.php) 2 fehlgeschlagen ✔

Von 23 Befunden sind 19 als Codefehler bestätigt. Bei 4 Befunden stimmt die Diagnose, aber die Auswirkung ist in den echten Daten heute null oder deutlich kleiner als dargestellt. Bei einem Befund (A15) ist die Ursachenanalyse richtig, die daraus abgeleitete Wirkung aber falsch — und die tatsächliche, live wirksame Auswirkung ist eine andere als die beschriebene.

Die Kernaussage des Audits — "die Aussage ‚alles umgesetzt und Zahlen korrekt' ist nicht belastbar" — ist berechtigt. Kein Befund entwertet die gelieferten Pakete K1–K9; es sind Rand- und Sonderfälle plus zwei Systematiken (Storno-Zuordnung, Zeitbezug historischer Auswertungen).


2. Bewertung je Befund

Legende: bestätigt = Fehler im Code und in den Daten wirksam · bestätigt, latent = Fehler im Code, tritt mit den vorhandenen Daten heute nicht auf · eingeschränkt = Diagnose richtig, Auswirkung anders als beschrieben · Bewertungssache = kein Fehler, sondern eine Produkt- oder Beschriftungsentscheidung.

ID Audit Meine Bewertung Begründung aus der Gegenprüfung
A01 P1 bestätigt, aber P2 Umgehung real; betrifft aber nur den Eigentümer auf seinem eigenen Abo, nicht fremde Konten.
A02 P2 bestätigt Kein Statusfilter. In den Daten: 1 Zeile Status 3 mit user_id, 1 Zeile Status 0 ohne.
A03 P2 bestätigt HAVING > 0 in allen Metriken; Übersicht und Detail können auseinanderlaufen.
A04 P2 bestätigt overlayCurrentAboStock() rechnet die komplette Kundenabo-Topliste live neu, auch die abgerechneten Monatspunkte.
A05 P2 Bewertungssache, P3 Spalte heißt nur „Abos"; die Seite trägt oben den Hinweis „Abo-Bestände tagesaktuell". Tooltip genügt.
A06 P2 bestätigt, andere Ursache Siehe Abschnitt 3: gemeinsame Wurzel mit A15, aber nicht über status_turnover.
A07 P2 bestätigt paylineContext() filtert nicht, lineByUserId() schon — zwei Mitgliedermengen auf einer Seite.
A08 P2 bestätigt ! is_array($qualLevel) → return null. Trifft neue Berater und jeden Monatsanfang.
A09 P2 bestätigt Kopf 9 Spalten, Summenzeile 8 Werte. Eindeutiger Off-by-one.
A10 P3 bestätigt user_abo_orders fehlt im Eager Loading; getCountPaidOrders() lädt je Zeile nach.
A11 P3 bestätigt, kosmetisch Setzt ≥26 exakt gleichauf liegende Kandidaten voraus.
A12 P2 bestätigt Kachel rechnet den Monatsendbestand zurück, der verlinkte Drilldown filtert heutige Aktivabos.
A13 P2 bestätigt, P3 Entspricht der dokumentierten Formel; Robustheitslücke am Liefertag.
A14 P2 bestätigt, kleiner Kreis Betrifft nur en: es/fr nutzen deutsche Zahl-/Datumsformate. Aktuell 5 Nutzer auf en.
A15 P1 eingeschränkt Prädikate überlappen tatsächlich — die beschriebene Doppelzählung gibt es in den Daten aber nicht. Siehe Abschnitt 3.
A16 P2 bestätigt, latent Kein einziger reactivated-Eintrag in den Daten, und alle 10 deaktivierten Abos sind heute noch inaktiv → bisher kein falscher Historienwert.
A17 P3 bestätigt Carbon-Überlauf; greift nur, wenn heute der 29.–31. ist.
A18 P2 bestätigt, P3 fputcsv() ohne Textschutz. Quelle sind eigene Teamdaten, kein fremder Input.
A19 P2 bestätigt commission() verliert das failed-Flag; Oberfläche zeigt 0,00 €.
A20 P2 Bewertungssache Öffentliche Disk, Dateiname mit uniqid() — nicht aufzählbar, aber ohne Anmeldung abrufbar. Produktentscheidung.
A21 P3 bestätigt Gruppenlabels, Metrik-Labels und CSV-Überschriften stehen hart in PHP.
A22 P2 bestätigt, unkritisch Der Batch läuft über 7 VIP-Konten bei 901 Nutzern gesamt. Kein Speicherproblem in dieser Größenordnung.
A23 P2 bestätigt, bekannt Deckt sich mit der bereits im Septemberplan offen geführten Cache-Invalidierung.

3. Die wichtigste Korrektur: A15 und A06

Teilweise überholt (10.09.2026). Die Analyse der Ursache gilt weiterhin. Die Aussage am Ende des Abschnitts, die Provisionswirkung sei null und nur die Darstellung betroffen, ist durch Abschnitt 9 widerlegt: Die Shop-Provisionsbasis war sehr wohl betroffen. A15 und A06 sind inzwischen behoben.

Der Audit setzt A15 als P1 an und schreibt, Shop-Stornos würden doppelt von den Gesamtpunkten abgezogen, wodurch Gesamtpunkte, Firstlines, Recruiter- und Top-3-Volumen zu niedrig sein könnten.

Die Ursachenanalyse ist richtig, die Wirkung nicht.

Richtig ist: SalesVolumePoints::OWN_PREDICATE (status IN (1,4,5,6)) und SHOP_PREDICATE (status = 2 OR (status = 6 AND status_turnover = 2)) überschneiden sich, und totalPoints() addiert beide. Eine Zeile mit Status 6 und status_turnover = 2 zählt zweimal.

Nicht richtig ist die Schlussfolgerung, denn diese Kombination existiert nicht:

status | status_turnover | Zeilen
   0   |      NULL       |      1
   1   |      NULL       | 19.525
   2   |      NULL       | 24.151
   3   |      NULL       |      8
   4   |        1        |     76
   4   |        2        |     13
   5   |      NULL       |    647
   6   |      NULL       |     20      ← alle Stornos, keiner mit status_turnover = 2

status_turnover wird beim regulären Bestellweg gar nicht gesetzt (SalesPointsVolume::create… schreibt das Feld nicht), sondern nur beim manuellen Gutschrift-Dialog und in BusinessStoreOptimized (dort fest 1). Der Stornoweg übernimmt den Wert der Ursprungsbuchung — und der ist bei Shop-Bestellungen NULL. Im Gegenprobencode des Audits setzt die Hilfsfunktion auditVolume() dagegen jeder Zeile fest 'status_turnover' => 2. Die Doppelzählung ist damit ein Artefakt der Fixture, kein beobachtbarer Produktionszustand.

Der real wirksame Fehler ist ein anderer — und er ist es wert, behoben zu werden. Weil alle Storno-Zeilen status_turnover = NULL haben, fallen sie ausschließlich unter OWN_PREDICATE. Ein storniertes Kunden-/Shop-Geschäft wird damit von den Eigenpunkten abgezogen statt von den Kundenpunkten. In den Daten:

  • 20 Storno-Zeilen insgesamt, davon 13 auf Bestellungen mit payment_for = 6 (Kunde-Shop), Beträge bis −157,50 Punkte, verteilt über 02/2026 bis 08/2026.
  • Folge: Eigenpunkte zu niedrig, Kundenpunkte zu hoch — betroffen sind genau die drei Punktespalten aus K1, die Kundenabo-Ranglisten und die Aufteilung „reine Abo-Punkte / einmalig dazu".
  • Die Gesamtpunkte bleiben korrekt, weil jede Zeile genau einmal gezählt wird. Damit sind Firstlines, Recruiter-Volumen, Top-3 und der 1000-Punkte-Shop-Filter nicht betroffen — anders als im Audit unter B3/B4 und in der Priorisierung angenommen.

Dasselbe erklärt A06 zutreffender als der Audit: Bei einem stornierten Abo-Geschäft greift der Storno gar nicht in customerAboPoints(), weil diese Auswertung SHOP_PREDICATE verlangt. Die Kundenabo-Punkte werden durch einen Storno also überhaupt nicht gemindert, und die Einmal-Zusätze der stornierten Bestellung bleiben in voller Höhe in der Aufteilung stehen. Die Behebung ist für A15 und A06 dieselbe: die Storno-Zeile braucht eine belastbare Herkunftsangabe (Ursprungsstatus statt des heute unzuverlässigen status_turnover), und die Kategorien müssen disjunkt werden.

Bewertung: A15 bleibt P1, aber mit anderer Begründung — nicht wegen falscher Summen, sondern wegen der falschen Aufteilung, die live und rückwirkend seit Februar wirkt.


4. Weitere Korrekturen an der Gewichtung

A16 — kein Datenschaden bisher. Der Audit ist beim Codebefund richtig: AboHelper::advanceAboAfterRecoveryPayment() schreibt active = true, status = 2 ohne Historieneintrag. Wirksam wird das aber erst, wenn ein deaktiviertes Abo wieder aktiviert wird. In den Daten stehen 44 cancelled, 10 deactivated, 6 paused und 0 reactivated Einträge, und alle 10 deaktivierten Abos sind bis heute inaktiv. Es gibt also aktuell keinen verfälschten historischen Bestand. Der Befund ist eine Vorsorge-Korrektur, nicht die Reparatur eines Schadens — das ändert seine Dringlichkeit erheblich.

A22 — Größenordnung stimmt nicht. Der Snapshot-Batch iteriert über Nutzer mit admin >= 1 AND admin < 4; das sind 7 Konten bei 901 Nutzern insgesamt. Die vom Audit beschriebene Summierung der Teilbäume über „viele VIPs" trifft in dieser Größenordnung nicht zu. Der Code-Hinweis bleibt richtig, die Priorität P2 nicht.

A14 — Kreis kleiner als dargestellt. Util::_thousands_separator() und formatDate() schalten nur bei en um; es und fr laufen im deutschen Format und passen damit zum Parser. Betroffen sind derzeit 5 Nutzer. Die Korrektur (unformatierte Sortierwerte als data-Attribute) ist trotzdem sinnvoll, weil sie den Fehler grundsätzlich beseitigt.

A01 — P1 ist zu hoch gegriffen. Der Weg ist real: das Selfservice-Formular sendet den aktuellen Zustand in einem versteckten Feld, und updateStatus() übernimmt jeden gesendeten Wert. Wer den Request manipuliert, kann sein eigenes Abo dauerhaft deaktivieren und so die Mindestlaufzeit aushebeln. Das ist eine Umgehung einer Geschäftsregel durch den Berechtigten auf seinem eigenen Datensatz — ärgerlich, aber kein Zugriff auf fremde Konten, keine Datenpreisgabe, kein Geldfluss. P2 mit kurzer Umsetzung (Feld im Selfservice-Pfad serverseitig ignorieren) ist angemessen.

A20 — kein Fehler, sondern eine Entscheidung. Die Datei liegt auf der öffentlichen Disk und der Pfad ist mit uniqid() vorangestellt, also nicht aufzählbar; wer die URL kennt, kommt ohne Anmeldung heran. Das ist bei allen Bildern der Anwendung so. Ob das für teaminterne Profilfotos akzeptabel ist, sollte die Kundin entscheiden — technisch ist beides machbar.


5. Was der Audit nicht anspricht

Teilweise überholt (10.09.2026). Der zweite Punkt („Rückwirkung auf bereits gezahlte Provisionen … ist null") gilt nicht mehr, siehe Abschnitt 9. Die im ersten Punkt offene Frage zum Zeitpunkt der Stornobuchung ist in Abschnitt 8 entschieden.

  • Was heißt „Kundenpunkte" nach einem Storno? Alle Korrekturen zu A15/A06 setzen eine fachliche Festlegung voraus: Mindert ein Storno den Monat der Ursprungsbestellung oder den Monat der Stornierung? Heute ist es Letzteres (cancelSalesPointsVolume() schreibt in den aktuellen Monat). Eine Umstellung würde abgeschlossene Monate rückwirkend verändern. Das ist eine Frage an die Kundin, keine Entwicklerentscheidung.
  • Rückwirkung auf bereits gezahlte Provisionen. Der Audit sagt richtig, dass die Auswirkung auf reale Auszahlungen nicht ermittelt wurde. Nach der Analyse in Abschnitt 3 ist sie null, weil die Gesamtpunkte je Berater korrekt sind und die Provision auf diesen aufsetzt. Nur die Darstellung der Aufteilung ist falsch. Das ist die entscheidende Entlastung für die Kundenkommunikation.
  • Die 25 fehlenden manual_*-Schlüssel in es/fr stammen aus dem Stripe-/Manuelle-Zahlungen-Paket (Commit 2e71173), nicht aus den Statistikpaketen. Der Audit ordnet das korrekt ein; für die Planung heißt es: eigenes Ticket, nicht Teil der Statistik-Nacharbeit.

6. Vorschlag für die Reihenfolge

Historisch (Stand 09.09.2026). Der Plan ist abgearbeitet bis auf die oben unter „Offen" genannten Punkte. Die Statusspalte zeigt, was daraus geworden ist.

Aufwand grob geschätzt, jeweils inklusive Regressionstest.

Stufe Befunde Warum zuerst Aufwand Status
1 A09 Eindeutiger Off-by-one, falsche Zahlen in einer ausgelieferten CSV. < 1 h erledigt
1 A15 + A06 Einzige live wirksame Systematik; betrifft die Punkteaufteilung seit 02/2026. Braucht vorab die Festlegung aus Abschnitt 5. 1 Tag erledigt
2 A02, A03 Übersicht, Detail und persönliche Statistik müssen dieselbe Punktedefinition benutzen — das hat die Kundin bereits mehrfach angemerkt. 0,5 Tag erledigt
2 A08, A19 Beides sichtbare Löcher für neue Berater bzw. im Fehlerfall; kleine, isolierte Änderungen. 0,5 Tag erledigt
3 A04, A12 Zeitbezug historischer Auswertungen sauber trennen. Konzeptionell die aufwendigste Gruppe. 1–2 Tage erledigt
3 A07 Gemeinsamer Payline-Kontext; berührt K11 und sollte mit dessen Entscheidung zusammen geplant werden. 0,5 Tag erledigt, live 25.09.
4 A10, A17, A18 Kleine, unabhängige Korrekturen; als Sammelpaket sinnvoll. 1 Tag erledigt
4 A21, A14 Mehrsprachigkeit und lokalisierte Sortierung. offen
5 A01, A16 Vorsorge: Regel serverseitig festziehen, Statushistorie in allen Schreibpfaden protokollieren. Kein aktueller Schaden. 0,5 Tag offen
— A05, A11, A13, A20, A22, A23 Tooltip, Randfall, Produktentscheidung oder bereits offen geführtes Thema. Nach Absprache. — offen

Nicht empfohlen: eine Nachberechnung abgeschlossener Monate. Überholt: Die Kundin hat am 10.09. entschieden, dass abgeschlossene Monate vollständig eingefroren werden; dafür war eine einmalige Nachberechnung der Snapshots nötig. Die Punkte der Altmonate wurden dabei nicht verändert — siehe Abschnitt 13.


7. Umgang mit dem Audit als Dokument

Der Audit ist als Prüfbericht brauchbar und sollte im Ordner bleiben. Zwei Hinweise für die Weiterverwendung:

  1. Die Gegenproben aus dem Prüfprotokoll dürfen nicht als Spezifikation übernommen werden. Das sagt das Protokoll selbst; wichtig ist es trotzdem, weil die Erwartungen dort bewusst das fehlerhafte Verhalten festschreiben. Bei einer Behebung müssen sie umgedreht werden — und die A15-Fixture (status_turnover fest auf 2) darf gar nicht übernommen werden.
  2. Für die Kundenkommunikation eignet sich der Bericht nicht unverändert. Formulierungen wie „Gesamtpunkte können zu niedrig sein" würden dort Sorgen auslösen, die nach Abschnitt 3 unbegründet sind. Falls die Befunde nach außen gehen sollen, gehören sie in eine eigene, auf die tatsächliche Auswirkung reduzierte Fassung.

8. Nachtrag 09.09.2026 — Stornoregel entschieden und umgesetzt

Die in Abschnitt 5 offene Frage ist von der Kundin beantwortet:

Befindet sich die Stornierung innerhalb eines Monats, wird sie angewandt und mindert. Ist der Monat abgeschlossen, passiert nichts mehr, denn die Monate werden nach der monatlichen Auswertung fixiert und es werden Gutschriften erstellt. Ein Minus in den neuen Monat mitzunehmen ist nicht nachvollziehbar; so oft kommen Stornierungen nicht vor. Falls es doch zu viel wird, ziehen wir es im laufenden Monat ab — dafür soll es einen Schalter in den Settings geben, zunächst auf false.

Umgesetzt in SalesPointsVolume::cancelSalesPointsVolume():

Fall Verhalten
Ursprungsbuchung im laufenden Monat Punktekorrektur wird gebucht (unverändert).
Monat abgeschlossen, Schalter aus (Standard) Keine Punktekorrektur. Die Stornorechnung wird trotzdem erstellt; der Vorgang wird protokolliert.
Monat abgeschlossen, Schalter an Punktekorrektur wandert als Minus in den laufenden Monat (bisheriges Verhalten).

Schalter: storno-points-current-month (Typ bool), Adminbereich → Einstellungen → „Rechnungen / Gutschriften Einstellungen". Ohne Datensatz liefert Setting::getContentBySlug() false — der Standard gilt also, ohne dass eine Zeile angelegt werden muss.

Abgrenzung: Die Rechnungs-/Gutschriftenerstellung ist nicht betroffen, nur die Punkte- und Umsatzbuchung. Tests: tests/Feature/StornoClosedMonthTest.php.

Zwei Punkte bleiben dadurch offen:

  1. Incentive-Buchung. Wird die Punktekorrektur übersprungen, unterbleibt auch die Incentive-Gegenbuchung (IncentiveTracker::trackStorno() setzt die Storno-Zeile voraus). Für einen abgeschlossenen Monat ist das konsequent; läuft die Incentive-Aktion aber noch, behält der Berater die Incentive-Punkte einer stornierten Bestellung. Zu klären, ob das gewollt ist.
  2. A15/A06 bleiben bestehen. Die Regel klärt den Zeitpunkt der Stornobuchung, nicht ihre Zuordnung. Ein Storno im laufenden Monat mindert weiterhin die Eigenpunkte statt der Kundenpunkte (siehe Abschnitt 3). Diese Korrektur steht noch aus und wirkt ab sofort nur noch auf Stornos innerhalb des laufenden Monats — die 13 Altfälle bleiben nach der neuen Regel unangetastet.

Randbefund: Die Migration definiert user_sales_volumes.status_turnover als NOT NULL DEFAULT 1, in der Produktionsdatenbank enthält die Spalte jedoch rund 44.000 NULL-Werte. Schema und Daten sind also auseinandergelaufen. Das erklärt zusätzlich, warum die Storno-Herkunft über dieses Feld nicht belastbar ist.


9. Nachtrag 09.09.2026 — status_turnover bereinigt (A15/A06 behoben)

Der in Abschnitt 8 als Randbefund notierte Schema-Drift war die Spitze eines Fehlers mit Geldwirkung. Die Nachverfolgung ergab:

Was status_turnover tatsächlich war. Von 44.441 Buchungen tragen nur 89 überhaupt einen Wert, und alle 89 sind manuell erfasste Gutschriften (Status 4). Der reguläre Bestellweg schreibt das Feld nie. Die Stornobuchung übernahm es unverändert aus der Ursprungsbuchung — also NULL. Damit galt jeder Storno als Eigengeschäft, auch der eines Kundenauftrags.

Die Folge in der Provisionsberechnung. In reCalculateSalesPointsVolume() lief Status 6 dadurch immer in den else-Zweig: Punkte nach KP/TP, Nettobetrag nach month_total_net. Die Shop-Werte blieben unberührt. commission_shop_sales errechnet sich aber aus sales_volume_total_shop = month_shop_total_net. Ein Shop-Storno minderte die Shop-Provisionsbasis also nicht. Beispiel Benutzer 6, April 2026: Shopumsatz 7.284,11 €, Storno −49,92 € im selben Monat — month_shop_total_net blieb bei 7.284,11 €, während month_total_net auf −49,92 € rutschte (der eigene Nettoumsatz wurde negativ ausgewiesen).

Quantifiziert: In sieben Fällen zwischen 04/2026 und 08/2026 wurde ein Kundenstorno im selben Monat gebucht, ohne die Shop-Provisionsbasis zu mindern — bei 30 % Marge zusammen 178,82 € zu viel gezahlte Shop-Provision. Die übrigen sechs Kundenstornos fielen in einen späteren Monat als die Ursprungsbestellung und werden nach der Regel aus Abschnitt 8 künftig ohnehin nicht mehr gebucht.

Umgesetzt:

  1. SalesPointsVolume::stornoTurnoverFor() leitet die Herkunft aus dem Status der Ursprungsbuchung ab (Status 2 → Shop, Gutschrift mit Verrechnung auf Shop → Shop, sonst Eigen) statt das leere Feld zu kopieren.
  2. reCalculateSalesPointsVolume() Case 6 arbeitet jetzt exklusiv: ein Shop-Storno mindert die Shop-Werte (Spiegelbild von Case 2), ein Eigen-Storno die Eigenwerte (Spiegelbild von Case 1). Vorher lief der Punktebetrag immer in KP/TP und bei status_turnover = 2 zusätzlich in die Shop-Punkte — genau die vom Audit beschriebene Doppelzählung.
  3. SalesVolumePoints::OWN_PREDICATE schließt Shop-Stornos aus. Eigen- und Kundenpunkte sind damit überschneidungsfrei; jede Zeile zählt genau einmal.
  4. Migration 2026_09_09_140000_...: status_turnover wird nullable ohne Default — der Stand, den die Produktionsdatenbank ohnehin hat. Auf Produktion praktisch wirkungslos, aber Testdatenbank und Neuinstallationen verhalten sich dadurch wie Produktion.

Bewusst nicht gemacht: kein Nachziehen der 20 Altzeilen. Sie haben status_turnover NULL und laufen deshalb unverändert durch den Eigenpunkte-Zweig — abgeschlossene Monate bleiben so, wie sie abgerechnet wurden. Das entspricht der Fixierungsregel aus Abschnitt 8. Die 178,82 € sind damit ein abgeschlossener Sachverhalt; ob sie verrechnet werden sollen, ist eine kaufmännische Entscheidung, keine technische.

Tests: tests/Feature/StornoPointsCategoryTest.php (6 Tests, ohne die Korrektur 4 rot). Gesamtlauf der betroffenen Bereiche: 245 bestanden, 1 vorbestehender, sachfremder Fehler (IncentiveTeaserPageTest, Bildergalerie).

Damit sind A15 und A06 erledigt; die Priorisierung in Abschnitt 6 reduziert sich um Stufe 1.


10. Nachtrag 09.09.2026 — Bewertung der Nachprüfung

Zur Nachprüfung. Alle drei Befunde sind berechtigt; zwei davon sind Fehler in meiner Arbeit. Ich habe R01 und R02 unabhängig nachgestellt und exakt dieselben Werte erhalten wie dort dokumentiert.

Befund Bewertung
R01 — A06 besteht weiter Berechtigt. Nachgestellt: Gesamt 100, Zusatz 20, wiederkehrend 80 statt 0/100.
R02 — Sonderfall Status 4 Berechtigt, von mir eingebaut. Nachgestellt: KP bleibt +100, Shop-Punkte −100.
R03 — Tests hängen an der Systemuhr Berechtigt. date() gegen eingefrorenes Carbon.

R01 — meine Aussage „A06 erledigt" war falsch

Ich habe die gemeinsame Wurzel von A15 und A06 korrigiert — die fehlende Herkunft der Stornobuchung — und daraus geschlossen, dass damit auch A06 erledigt sei. Das stimmt nicht. BackofficeDashboardService::customerAboAddonPoints() sammelt weiterhin Bestellungen und addiert deren ursprünglich positive Positionspunkte, ohne Vorzeichen und Nettowirkung der zugehörigen Buchungen. Diese Datei habe ich gar nicht angefasst. Der Audit hatte die Positionsursache von Anfang an getrennt beschrieben; ich habe sie mit der Herkunftsursache vermengt. A06 bleibt offen.

R02 — der Status-4-Zweig war ein Fehler von mir

stornoTurnoverFor() behandelt eine Gutschrift mit Shop-Verrechnung wie eine Shop-Bestellung. Der Status-4-Zweig der Neuberechnung macht aber etwas anderes: Punkte gehen immer nach KP/TP, nur der Nettoumsatz folgt status_turnover. Nachgestellt (vorher KP 100 / Shop-Netto 100):

Nach dem Storno Ist Soll
KP 100 0
Shop-Punkte −100 0
Shop-Netto 0 0

Der Nettoumsatz wird also korrekt zurückgenommen, die Punkte nicht. Ein einzelnes Feld mit den Werten 1/2 kann „Punkte eigen, Umsatz Shop" nicht ausdrücken — das ist der Kern des Befundes.

Ergänzung zur Reichweite: Alle 89 Status-4-Zeilen haben user_invoice_id = NULL, und der einzige aufrufende Pfad (InvoiceRepository::correctPointsForCancellation()) sucht die Ursprungsbuchung genau über diese Spalte. Der Fall ist über die Anwendung heute nicht erreichbar, es sind also keine Daten betroffen. Das entschuldigt den Zweig nicht — ich habe ihn bewusst eingebaut, ohne den Status-4-Pfad der Neuberechnung dagegen zu halten.

R03 — Uhr

isStornoMonthOpen() und die Stornobuchung verwenden date(), die Tests frieren Carbon ein. Dass beide heute übereinstimmen, ist Zufall des laufenden Kalendermonats. Nach dem Monatswechsel brechen die Tests, ohne dass sich fachlich etwas geändert hat.

Präzisierungen zur Nachprüfung

  • „Eine Monatsabrechnung weicht von der Formel ab": Es handelt sich um einen Cent (2.278,69 € gespeichert gegen 2.278,70 € gerechnet, Benutzer 6, 05/2026) — eine Rundungsdifferenz, keine sachliche Abweichung. Die übrigen fünf stimmen exakt.
  • 178,82 € gegen 178,81 €: Reihenfolge von Runden und Summieren, beide Wege sind belegt.
  • „zu viel ausgezahlt": Die Kritik ist berechtigt. Belegt ist die Differenz der Shop-Provisionsbasis, kein Abgleich tatsächlicher Auszahlungen. Die Formulierung in Abschnitt 9 ist entsprechend zu lesen.
  • Widerspruch innerhalb dieses Dokuments: Die Abschnitte 3, 5 und 6 sagen, die Provisionswirkung sei null und nur die Darstellung betroffen. Das ist durch Abschnitt 9 überholt und gilt nicht mehr. Maßgeblich ist der jüngste Abschnitt.
  • A05: Der Einwand trifft. Der Septemberplan formuliert bei K1 ausdrücklich „Nur bereits abgerechnete Abos." Ob das auch die Spalte „Abos" meint, ist eine Kundenfrage und nicht durch einen Tooltip entschieden.
  • A18, A20, A01, A07, A16, A22: Die Einwände gegen meine Begründungen nehme ich an. Insbesondere bei A18 trägt „kein fremder Input" nicht — Namen und Kontaktdaten im Team stammen von anderen Personen.
  • „21 Befunde nur neu bewertet, nicht behoben": Richtig, und so war es auch gemeint.

Offene Punkte daraus

  1. A06 korrigieren — Nettowirkung je Bestellung auf wiederkehrende und einmalige Positionen bestimmen, erst danach je Berater summieren.
  2. R02 korrigieren — Herkunft von Punkten und Umsatz getrennt abbilden. Sauber: die Ursprungsbuchungsart auf der Gegenbuchung festhalten und Case 6 danach spiegeln. Minimal: den Status-4-Zweig aus stornoTurnoverFor() entfernen, solange der Fall nicht erreichbar ist.
  3. R03 korrigieren — Prüfung und Buchung auf now() umstellen, Monats- und Jahreswechsel unabhängig von der Systemuhr testen; den überholten Kommentar in StornoClosedMonthTest.php entfernen.

11. Nachtrag 10.09.2026 — R01, R02 und R03 behoben

R02 — Ursprungsart der Stornobuchung

Neue Spalte user_sales_volumes.storno_origin_status (nullable, Migration 2026_09_10_090000_...). cancelSalesPointsVolume() schreibt dort den Status der Ursprungsbuchung hinein. Damit trennt Case 6 der Neuberechnung endlich die beiden Regeln, die status_turnover allein nicht ausdrücken kann:

Storno von Punkte Umsatz
Shop-Bestellung (2) Shop Shop
Eigenbestellung (1), Registrierung (5) KP/TP Eigen
Gutschrift (4) mit Shop-Verrechnung KP/TP Shop
Altzeile ohne Ursprungsart KP/TP Eigen (unverändert)

Die letzte Zeile ist der Grund, warum keine Altdaten angefasst werden mussten: ohne Ursprungsart fällt die Auswertung auf das bisherige Verhalten zurück. SalesVolumePoints benutzt dieselbe Regel über die Konstante STORNO_ORIGIN — mit COALESCE/CASE statt NOT (...), weil ein NOT über NULL-Spalten die Zeile aus beiden Kategorien geworfen hätte.

stornoTurnoverFor() beschreibt jetzt ausschließlich das Umsatzziel; eine Gutschrift behält ihre eigene Zuordnung, statt pauschal als Shop-Bestellung zu gelten.

R01 / A06 — Aufteilung der Zusatzpunkte

customerAboAddonPoints() ermittelt je Berater und Bestellung zusätzlich die gebuchte Nettosumme und die ursprünglich positive Summe. Aus deren Verhältnis ergibt sich, wie viel von der Bestellung im Monat stehen geblieben ist: Vollstorno 0, unstorniert 1, Teilstorno anteilig. Die Zusatzpunkte der Bestellung werden mit diesem Faktor gewichtet.

Der anteilige Ansatz beim Teilstorno ist bewusst eine Näherung: Die Stornobuchung hat keine Positionsaufteilung, es ist ihr also nicht zu entnehmen, ob der Einmal-Zusatz oder die Abo-Position storniert wurde. Das ist im Code vermerkt.

R03 — Uhr

isStornoMonthOpen() und die Stornobuchung verwenden now() statt date(). Prüfung, gebuchter Monat, Datum und Protokolltext folgen damit derselben Uhr und sind testbar. Der überholte Kommentar in StornoClosedMonthTest.php ist entfernt.

Tests

Datei Umfang
tests/Feature/CustomerAboAddonSplitTest.php neu, 3 Tests: Vollstorno, unstornierte Bestellung, Teilstorno
tests/Feature/StornoPointsCategoryTest.php auf 9 Tests erweitert: Gutschrift-Spiegelung, Ursprungsart, Eigenpunkte-Zuordnung
tests/Feature/StornoClosedMonthTest.php auf 6 Tests erweitert: Unabhängigkeit von der Systemuhr
tests/Unit/Services/BackofficeDashboardServiceTest.php Spalte im handgebauten Testschema ergänzt

Rot-Nachweis mit chirurgisch zurückgedrehten Hunks statt pauschalem Stash — die betroffenen Dateien tragen auch ältere, noch nicht eingecheckte Arbeit:

  • nur A06-Hunk zurück → 2 der 3 neuen Aufteilungstests rot, der unstornierte Fall bleibt grün
  • nur R02/R03-Hunks zurück → genau die 4 neuen Tests rot

Gesamtlauf der betroffenen Bereiche: 266 bestanden, 1 vorbestehender Fehler (IncentiveTeaserPageTest, Bildergalerie, unabhängig gegengeprüft).

Deployment

Zwei Migrationen: 2026_09_09_140000 (status_turnover nullable) und 2026_09_10_090000 (storno_origin_status). Beide additiv, keine Datenänderung. Danach php artisan view:clear.

Damit sind A15, A06, R01, R02 und R03 erledigt. Offen aus dem Audit bleiben die übrigen Befunde gemäß Abschnitt 6 — sie wurden neu bewertet, aber nicht behoben.


12. Nachtrag 10.09.2026 — Pakete 1 und 2 sowie der Kleinkram umgesetzt

Paket 1 — dieselbe Zahl an allen Stellen

A09 — Neupartner-CSV. Die Summenzeile hat jetzt neun Werte für neun Spalten; Kundenpunkte und Gesamtpunkte stehen unter ihren Überschriften statt unter „Sponsor" und „Punkte seiner Kunden". Statt die Exportspalten zu zentralisieren — das wäre ein größerer Umbau — sichert ein Datensatz-Test für alle fünf Exportarten ab, dass Kopf- und Summenzeile gleich breit bleiben. Damit fällt dieselbe Fehlerklasse künftig sofort auf.

A02 — persönliche Statistik. Die Abfrage filtert auf SalesVolumePoints::countableStatuses() (neu, die eine Liste für „zählbare Buchung"), Status 0 und 3 zählen also nicht mehr mit. Zusätzlich wird ein Storno dem Segment seiner Ursprungsbuchung zugeordnet statt pauschal „Sonstige" — möglich geworden durch storno_origin_status aus Abschnitt 11. Altzeilen ohne Ursprungsart bleiben unter „Sonstige", ihre Herkunft ist nicht rekonstruierbar.

A03 — negative Salden im Drilldown. HAVING > 0 ist zu <> 0 geworden; nur die echte Qualifikationsliste „mindestens 1000 Punkte" behält ihre Schwelle. Zusätzlich waren in index.blade.php die Detail-Links mit > 0 bewacht — ein negativer Wert war also nicht anklickbar. Auch das ist auf != 0 umgestellt.

Warum A03 dringlicher wurde: Der Fehler bestand vorher auch, aber die negativen Beträge lagen bei den Eigenpunkten. Seit der Stornokorrektur landen sie bei den Kundenpunkten — also in genau den Kennzahlen, in die aus der Übersicht hineingeklickt wird.

Paket 2 — sichtbare Löcher

A08 — Prognose ohne erreichte Stufe. „Noch nicht qualifiziert" ist jetzt ein gültiger Ausgangszustand. Abgebrochen wird nur noch bei einem echten Berechnungsfehler oder wenn es weder eine erreichte noch eine nächste Stufe gibt — dann fehlt der Prognose jeder Bezugspunkt. Ohne erreichte Stufe bleibt die PP-Provision fachlich korrekt bei 0; damit die Anzeige nicht „auf Basis Level ." schreibt, gibt es den neuen Satz home.forecast_commission_no_level in allen vier Sprachen.

A19 — fehlgeschlagene Berechnung. commission() reicht failed durch. Die Kachel zeigt dann „—" statt „0,00 €", wird grau statt gelb, verliert das Live-Abzeichen und trägt den Hinweis home.commission_unavailable (ebenfalls vier Sprachen).

Kleinkram

Befund Änderung
A10 user_abo_orders wird vorgeladen; die Relationsliste steht als Konstante im Controller, damit der Test an der Produktionsdefinition hängt statt sie zu kopieren.
A17 addMonthNoOverflow() statt addMonth() — vom 31.01. endet „innerhalb eines Monats" am 28.02., nicht am 03.03.
A18 Neue csvSafeRow() kennzeichnet Felder, die mit =, +, - oder @ beginnen, als Text. Greift in beiden Exporten (Drilldown und Übersicht); Zahlen bleiben unangetastet.

Tests

tests/Feature/StatisticsConsistencyTest.php ist neu (18 Tests, A09/A02/A03/A18/A10/A17), PaylineForecastServiceTest und LiveCommissionServiceTest sind um zusammen fünf Tests gewachsen.

Rot-Nachweis wieder mit chirurgisch zurückgedrehten Hunks: Paket 1 → 5 von 11 rot, Paket 2 → 3 rot, Kleinkram → 6 von 18 rot.

Gesamtlauf über Statistik, Storno, Provision, Abo, Incentive, DATEV und Sprachdateien: 341 bestanden, 3 vorbestehende Fehler — zwei davon LocalizationSyncTest (die 25 manual_* Schlüssel aus dem Stripe-Paket fehlen in es/fr), einer IncentiveTeaserPageTest (Bildergalerie). Beide unabhängig gegengeprüft, keiner hängt an diesen Änderungen. Die neuen home.php-Schlüssel sind in allen vier Sprachen paritätisch.

Stand der Befundliste

Erledigt: A02, A03, A06, A08, A09, A10, A15, A17, A18, A19.

Offen und bewusst nicht angefasst:

  • A04, A12 — Zeitbezug historischer Auswertungen; braucht vorher die Entscheidung, ob ein Klick auf einen vergangenen Monat den Stand von damals oder den korrigierten Stand von heute zeigt.
  • A07 — gemeinsamer Payline-Kontext; sinnvoll zusammen mit K11.
  • A01, A16, A22, A14, A11, A23 — kein Schaden in den Daten bzw. Randfälle.
  • A05, A20, A21 — Kundenentscheidungen: Bedeutung der Spalte „Abos", Sichtbarkeit der Profilfotos, und ob das Backoffice überhaupt auf Spanisch genutzt wird (44 Konten).

13. Nachtrag 10.09.2026 — A04 und A12 behoben: abgeschlossene Monate sind eingefroren

Die Kundin hat Variante 1 gewählt, mit einer Begründung, die über die Statistik hinausgeht: Am ersten des Folgemonats läuft die Abrechnung, es werden Gutschriften ausgestellt, der Monat ist kaufmännisch geschlossen. Teampartner können danach dazukommen oder verschoben werden — der abgerechnete Monat darf sich dadurch nicht mehr ändern. Dieselbe Logik, die schon für Stornos gilt.

Die vollständige Beschreibung steht in monatsabschluss-eingefrorene-statistik.md. Hier nur der Abgleich mit den Befunden:

A04 — Snapshot fror nur einen Teil ein. Das Live-Overlay ist entfallen: ein abgeschlossener Monat wird unverändert aus dem Snapshot ausgeliefert. Damit ist auch die Top-10-Tabelle eingefroren, deren Spalte „Abgerechnet (Punkte)" vorher live nachgerechnet wurde. Zusätzlich lasen die Detaillisten bisher grundsätzlich heutige Daten; sie kommen jetzt aus dem gespeicherten Stand.

A12 — Kachel und verlinkte Liste. Beide stammen jetzt aus derselben Quelle. Ein im August gestartetes und im September gekündigtes Abo bleibt in der August-Kachel und in der verlinkten Liste.

Payload-Version 2. Die Linienzeilen tragen zusätzlich abo_ids (Team-/Kundenabos, jeweils auch „neu"); user_ids waren schon vorher enthalten. Aus beiden bauen sich die Detaillisten. Abos werden dort mit withTrashed() geladen — sonst wäre die Liste kürzer als die Zahl, aus der sie aufgerufen wurde. Snapshots der Version 1 bleiben lesbar und fallen bei den Detaillisten auf die Live-Auswertung zurück.

Nachberechnung. backoffice:store-statistics-snapshots --force wurde ausgeführt: 56 Snapshots (7 VIP-Konten × 8 Monate), alle jetzt Version 2, rund 36 KB je Snapshot. Die eingefrorene Abo-Zahl und die Länge der zugehörigen ID-Liste stimmen überein.

Grenze, die im Dokument steht und in die Kundenkommunikation gehört: Für Monate vor dem 10.09.2026 friert der Rebuild die Struktur des Ausführungstags ein, nicht die des damaligen Monatsendes — frühere Sponsor-Zuordnungen sind nirgends historisiert. Einheitlichkeit über alle Monate war hier die bewusste Abwägung. Ab dem Abschluss 01.10.2026 ist der eingefrorene Stand der tatsächliche Monatsstand.

Anzeige. Das Abzeichen „Abo-Bestände tagesaktuell" ist ersetzt durch „Monat abgeschlossen · Stand eingefroren" mit einem Tooltip, der die Regel benennt.

Tests. tests/Feature/BackofficeSnapshotFreezeTest.php neu (6 Tests: Korrektur nach Abschluss, Kündigung, später hinzugekommener Teampartner, gelöschtes Abo in der Detailliste, Kachel gegen Liste, laufender Monat weiterhin live). Ohne die Drilldown-Änderung sind 3 davon rot.

Zwei Tests in BackofficeStatisticsListsTest.php hielten die alte Regel fest (August-Entscheidung F7, „Abo-Bestände tagesaktuell"). Sie beschreiben jetzt die neue — umgeschrieben, nicht entfernt, mit Verweis auf die Entscheidung im Testkommentar.

Gesamtlauf: 293 bestanden, dazu die drei bekannten vorbestehenden Fehler (2 × LocalizationSyncTest, 1 × IncentiveTeaserPageTest).

Deployment: php artisan migrate, dann einmalig php artisan backoffice:store-statistics-snapshots --force, dann php artisan view:clear.

Damit sind aus Abschnitt 6 auch A04 und A12 erledigt. Offen bleiben A07 (gehört zu K11), die Randfälle ohne Datenschaden und die drei Kundenfragen.


14. Nachtrag 10.09.2026 — A01 und A16 behoben (Regel und Protokoll)

Beide Befunde sind Vorsorge: in den Daten steckt heute kein Schaden. A01 beschreibt eine umgehbare Geschäftsregel, A16 eine Protokolllücke, die erst bei der ersten Reaktivierung eines deaktivierten Abos sichtbar geworden wäre. Sie gehören zusammen, weil beide am selben Schreibpfad hängen.

A01 — Aktiv-Schalter im Selbstbedienungspfad

Das Selbstbedienungsformular hat keinen Aktiv-Schalter; es sendet den aktuellen Zustand nur in einem versteckten Feld zurück. updateStatus() hat diesen Wert trotzdem übernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft deaktivieren und damit die Mindestlaufzeit umgehen — der Ein-Zyklus-Pausenpfad mit Sperrfrist, Mindestlaufzeit und Pausenabstand wurde dabei komplett übersprungen. Ein fehlendes Feld zählte zusätzlich als „aus".

app/Repositories/AboRepository.php liest das Feld jetzt nur noch im Admin-Pfad; sonst gilt der gespeicherte Zustand:

$active = $isAdminUpdate
    ? (bool) ($data['abo_is_active'] ?? false)
    : (bool) $this->model->active;

Damit laufen Zustandswechsel im Selbstbedienungspfad ausschließlich über die zugelassenen Aktionen Pause und Kündigung — beide prüfen ihre Regeln bereits serverseitig. Der Admin-Pfad bleibt unverändert.

A16 — Statusprotokoll in allen Schreibpfaden

Bisher protokollierte nur das Repository. Die Systempfade — Recovery-Zahlung (AboHelper::advanceAboAfterRecoveryPayment()), Retry (AboRetryPaymentService::markAboSuccess()), externe Erledigung (AboExternalCompletionService) und die DSGVO-Anonymisierung — schreiben active direkt auf das Modell, ohne Eintrag.

Das ist deshalb heikel, weil die historische Bestandsrückrechnung in TeamAboOverviewService::aboStockQuery() ein Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine spätere protokollierte Reaktivierung aufgehoben ist. Ein stiller active = true nimmt das Abo dauerhaft aus allen Folgemonaten.

Der Eintrag liegt jetzt am Model, nicht in den einzelnen Diensten. UserAbo::booted() hängt sich an updated und protokolliert jeden Wechsel von active (UserAbo::logStatusTransition()). Damit kann ein künftiger Schreibpfad ihn nicht mehr vergessen, und der Eintrag entsteht innerhalb derselben Transaktion wie die Statusänderung.

  • Richtung: active wird wahr → reactivated. active wird falsch → cancelled bei Status 4, sonst deactivated.
  • Kanal und Akteur: \Auth::id() ergibt sich von selbst; den Formularkanal (me, ot, portal, admin) setzt AboRepository::update() über UserAbo::withStatusLogView() für die Dauer des Schreibvorgangs. Systempfade lassen ihn leer und erscheinen als „System".
  • Was das Repository behält: die Pause protokolliert weiterhin selbst — sie ändert active nicht und führt die Datumsverschiebung mit.
  • Grenzen: Model-Events feuern nicht bei Query-Builder-Massenupdates und nicht bei saveQuietly(). Das Löschen braucht bewusst keinen Eintrag; dort wertet die Rückrechnung deleted_at aus.

Nebeneffekt, der eine Lücke schließt: Die DSGVO-Anonymisierung setzt active = false ohne cancel_date. Bisher fiel ein so abgeschaltetes Abo rückwirkend aus allen vergangenen Monaten, weil es weder aktiv war noch ein datierbares Ende hatte. Jetzt entsteht ein datierter Eintrag, und die vergangenen Monate bleiben korrekt.

Gleicher Zeitstempel

created_at ist sekundengenau. Deaktivierung und Reaktivierung im selben Request tragen denselben Wert, und der Vergleich r.created_at > h.created_at ließ die Reaktivierung dann unter den Tisch fallen — das Abo verschwand aus dem Bestand. aboStockQuery() zieht bei Gleichstand jetzt die Reihenfolge der Einträge (r.id > h.id) heran. Das ist der Fall, den die Abnahmekriterien des Audits ausdrücklich verlangen.

Tests

tests/Feature/AboStatusGuardTest.php neu, 13 Tests:

  • A01: Aktiv-Feld auf 0 für me, ot und portal; fehlendes Feld; Reaktivierungsversuch; wiederholtes Umschalten; Mindestlaufzeit trotz manipuliertem Feld; Admin darf weiterhin.
  • A16: Kanal und Akteur aus dem Admin-Formular; Kündigung aus dem Selbstbedienungspfad; Reaktivierung aus einem Systemschreibpfad ohne Kanal; kein Eintrag ohne Zustandswechsel; kein Eintrag beim Löschen.

In TeamAboOverviewTest.php zwei weitere: ein systemseitig reaktiviertes Abo zählt ab dem Reaktivierungsmonat wieder, und die Reaktivierung in derselben Sekunde gilt. Ohne die Gleichstandsregel ist der zweite rot — gegengeprüft.

In AboLockDaysSettingTest.php hielt ein Test genau das Verhalten fest, das A01 beschreibt: ein Selbstbedienungs-Request ohne abo_is_active deaktivierte das Abo, und der Test hieß dabei „erlaubt Pausieren". Er prüft jetzt die echte Pause-Aktion — das Abo bleibt aktiv, nur der Liefertag wandert in den Folgemonat.

Vorbestehende Fehler im Gesamtlauf

Der Gesamtlauf zeigt Fehler, die nicht aus diesem Paket stammen — gegengeprüft durch einen Lauf mit zurückgesetzten Änderungen: keine neue Rotfärbung, im Gegenteil 13 Tests weniger rot.

  • LocalizationSyncTest / FrenchLocalizationTest: die manual_*-Schlüssel aus dem Stripe-/Manuell-Zahlung-Commit fehlen in en, es und fr. Diesen Fehler hat schon der Audit selbst gemeldet.
  • AboPendingInitialPaymentTest > zeigt gesendete Abo-Zahlungslinks: rendert eine View mit Spatie-Formular ohne Session. Auch mit zurückgesetzten View-Änderungen rot.
  • Diverse Unit-Tests mit „no such table: settings".

Die gehören in ein eigenes Aufräumpaket und sind hier bewusst nicht mitbehandelt.


15. Nachtrag 10.09.2026 — A11, A13 und A14 behoben (Randfälle)

Drei Befunde ohne Datenschaden, aber jeder mit einem klaren Fehlverhalten. Sie stehen zusammen, weil sie alle die Darstellung betreffen und keine Entscheidung der Kundin brauchen.

A11 — Gleichstände in den Top 3

TopResultsService::rankRows() sortierte die Kandidaten nach Wert, schnitt auf 25 ab und wandte den Namens-Tiebreak danach an. Ein alphabetisch vorrangiger Kandidat auf Position 26 konnte damit nicht mehr gewinnen — bei Gleichstand entschied die Reihenfolge der Datenbankabfrage statt des Namens. Zusätzlich konnten Kandidaten ohne Account das Auffüllen der drei Plätze verhindern, weil sie erst nach dem Abschneiden aussortiert wurden.

Zwei Schritte vor dem Hydrieren, beide in derselben Methode:

  1. Kandidaten ohne Account fallen vorher weg — eine leichte Id-Abfrage, keine Hydrierung.
  2. Die Auswahlgrenze wird um alle Gleichstände an dieser Grenze erweitert (takeIncludingTies()). Damit bleibt jeder Kandidat in der Auswahl, der die Top 3 über den Namens-Tiebreak noch erreichen kann.

Als letzte Regel entscheidet die user_id: bei gleichem Wert und gleichem Namen war die Reihenfolge sonst nicht reproduzierbar — für eine gecachte Rangliste ein echtes Problem.

A13 — Der Ausführungstag

Offene Ausführungen mussten next_date > heute 23:59:59 erfüllen. Ein heute fälliges Abo galt damit ab Mitternacht als erledigt — der Ausführungsjob läuft aber erst um 04:00. In diesem Fenster fehlten die Punkte in der Prognose und im Ist: die Oberfläche behauptete „steckt schon im Ist", bevor überhaupt gebucht war.

Neu ist ein gemeinsamer Scope UserAbo::scopeOpenExecutionUntil(), den alle drei Prognosen benutzen (Payline-Kachel, persönliche Terminvorschau, Team-Übersicht). Er zählt den heutigen Liefertag mit, solange für das Abo heute noch keine Ausführung angelegt wurde. Genau diese Bedingung verhindert die Doppelzählung: sobald der Job die Ausführung erzeugt hat, stecken die Punkte im Ist und das Abo fällt aus der Prognose.

Ein liegengebliebener Vortag zählt bewusst nicht — das gehört in die Nachbearbeitung, nicht in eine Prognose. Und am 25. (letzter Liefertag) endet das Fenster jetzt erst mit dem Tag, nicht mit seinem Beginn.

A14 — Sortierung in fremden Sprachen

Util::_thousands_separator() und formatDate() schalten bei en auf 1,000.00 und Y-m-d um. Die Tabellensortierung las den angezeigten Zellentext: der Datumsparser erkannte nur dd.mm.yyyy (also alles 0 unter en), und der Zahlenparser machte aus 1,000.00 eine 1 und aus 999.00 eine 99900 — die Reihenfolge konnte sich umkehren.

Die Zellen tragen jetzt data-sort-value mit dem unformatierten Wert: Zahl mit Punkt als Dezimaltrenner, Datum als Y-m-d. Der Anzeigetext bleibt lokalisiert. Fehlt das Attribut, gilt weiter der alte Textweg — der Umbau bricht also keine Tabelle, die noch keine Sortierwerte liefert.

Betroffen sind beide Tabellen mit dieser Sortierung: die Kundenabo-Liste und die Statistik-Detailliste. Der Drilldown-Dienst liefert dafür *_sort-Schlüssel über ein neues sortDate() neben dem bestehenden formatDate(); die CSV-Exporte holen ihre Spalten namentlich und bleiben unberührt.

Zwei Dinge kamen dabei mit:

  • Textspalten wurden mit < / > verglichen, wodurch Umlaute hinter dem Z landeten. Jetzt entscheidet localeCompare.
  • Leere Werte ergaben eine 0 und landeten damit mitten zwischen den negativen Punkteständen aus Stornos. Sie sortieren jetzt geschlossen an ein Ende.
  • next_date wurde doppelt formatiert: Der Accessor liefert bereits einen formatierten String, den formatDate() erneut geparst hat. Das ging nur gut, weil beide Formate zufällig parsebar sind. Der Dienst liest den Rohwert.

Tests

  • A11: drei neue in TopResultsServiceTest.php — 26 Gleichstände mit „Alpha" an letzter Position, 25 Kandidaten ohne Account vor einem mit, und Namensgleichheit. Die ersten beiden sind auf dem alten Stand rot, gegengeprüft.
  • A13: vier neue in PaylineForecastServiceTest.php (heute fällig ohne Ausführung, heute bereits ausgeführt, gestern fällig, persönliche Vorschau) plus eine in TeamAboOverviewTest.php für den 25./26. Zwei davon sind auf dem alten Stand rot, die beiden Gegenproben grün — der Test prüft also wirklich die Änderung und nicht sich selbst.
  • A14: eine Datensatz-Prüfung über de und en in CustomerAboListViewTest.php, dazu zwei in BackofficeStatisticsDrilldownTest.php (ISO-Sortierwerte aus dem Dienst, Attribute in der gerenderten Tabelle).

Gesamtlauf unverändert gegenüber dem Stand vor diesem Paket: dieselben vorbestehenden Fehler, kein neuer. Die JavaScript-Sortierung selbst ist nicht automatisiert geprüft — getestet ist, dass die richtigen Werte in der Seite ankommen.

Damit sind aus Abschnitt 6 alle Befunde erledigt, für die keine Entscheidung der Kundin nötig ist. Offen bleiben A07 (gehört zu K11), A22/A23 (kein Problem in dieser Größenordnung bzw. Cache-Strategie) und die drei Kundenfragen A05, A20, A21.