From 233cd01bb5fb10eafc6573c3609730fecfa61155 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 10 Sep 2026 11:19:50 +0000 Subject: [PATCH] Doku: Statistik-Audit 09.09., Bewertung und Umsetzungsstand Der Ordner dev/2026-07-22 ist ueber Monate gewachsen; ohne Einstieg findet man darin nichts mehr. Deshalb ein README als Wegweiser, mit dem Hinweis, dass bei Widerspruechen immer die juengere Entscheidung gilt (Beispiel: F7 wurde am 10.09. abgeloest). Die Bewertung traegt oben einen Stand-Block mit erledigten und offenen Befunden samt Sprungmarken. Ueberholte Aussagen der ersten Fassung sind markiert statt geloescht, damit der Weg nachvollziehbar bleibt. Der Monatsabschluss steht als eigenes Dokument, weil es eine dauerhafte Fachregel ist und keine Audit-Antwort: was eingefroren wird, was die Regel nicht leisten kann, und die Betriebsbefehle. Co-Authored-By: Claude Opus 5 (1M context) --- dev/2026-07-22/README.md | 42 + .../audit-statistiken-2026-09-09-bewertung.md | 858 ++++++++++++++++++ ...dit-statistiken-2026-09-09-nachpruefung.md | 453 +++++++++ ...t-statistiken-2026-09-09-pruefprotokoll.md | 451 +++++++++ .../audit-statistiken-2026-09-09.md | 383 ++++++++ .../monatsabschluss-eingefrorene-statistik.md | 90 ++ 6 files changed, 2277 insertions(+) create mode 100644 dev/2026-07-22/README.md create mode 100644 dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md create mode 100644 dev/2026-07-22/audit-statistiken-2026-09-09-nachpruefung.md create mode 100644 dev/2026-07-22/audit-statistiken-2026-09-09-pruefprotokoll.md create mode 100644 dev/2026-07-22/audit-statistiken-2026-09-09.md create mode 100644 dev/2026-07-22/monatsabschluss-eingefrorene-statistik.md diff --git a/dev/2026-07-22/README.md b/dev/2026-07-22/README.md new file mode 100644 index 00000000..16a4f4dd --- /dev/null +++ b/dev/2026-07-22/README.md @@ -0,0 +1,42 @@ +# Statistiken & Backoffice — Wegweiser durch diesen Ordner + +Der Ordner ist über mehrere Monate gewachsen. Diese Seite sagt, welches Dokument wofür gilt und +welches der aktuelle Stand ist. + +## Wenn du wissen willst, wo wir stehen + +**[audit-statistiken-2026-09-09-bewertung.md](audit-statistiken-2026-09-09-bewertung.md)** — ganz +oben unter „Stand" stehen erledigte und offene Befunde mit Sprungmarken. Das ist der Einstieg. + +## Entscheidungen, die dauerhaft gelten + +| Dokument | Inhalt | +| --- | --- | +| [monatsabschluss-eingefrorene-statistik.md](monatsabschluss-eingefrorene-statistik.md) | Warum ein abgerechneter Monat vollständig eingefroren wird — Statistik, Team-Struktur, Detaillisten. Grenzen und Betriebsbefehle. | +| [audit-statistiken-2026-09-09-bewertung.md, Abschnitt 8](audit-statistiken-2026-09-09-bewertung.md) | Stornoregel: ein Storno mindert nur den Monat der Ursprungsbestellung. Settings-Schalter für den Ausnahmefall. | +| [audit-statistiken-2026-09-09-bewertung.md, Abschnitt 14](audit-statistiken-2026-09-09-bewertung.md) | Abo-Zustand: Berater wechseln ihn nur über Pause und Kündigung, nie über ein Aktiv-Feld. Jeder Wechsel wird zentral protokolliert. | + +## Prüfungen vom September 2026 + +Chronologisch, jede Runde prüft die vorige: + +1. [audit-statistiken-2026-09-09.md](audit-statistiken-2026-09-09.md) — externes Audit, 23 Befunde (A01–A23) +2. [audit-statistiken-2026-09-09-pruefprotokoll.md](audit-statistiken-2026-09-09-pruefprotokoll.md) — dessen Gegenproben und Testläufe +3. [audit-statistiken-2026-09-09-bewertung.md](audit-statistiken-2026-09-09-bewertung.md) — unsere Bewertung, Korrekturen an der Gewichtung, **und alle Umsetzungs-Nachträge** +4. [audit-statistiken-2026-09-09-nachpruefung.md](audit-statistiken-2026-09-09-nachpruefung.md) — externe Nachprüfung der Umsetzung (R01–R03); bewertet in Abschnitt 10 der Bewertung + +## Anforderungen und Kundenrückmeldungen + +| Dokument | Stand | +| --- | --- | +| [entwicklungsplan-2026-09-07.md](entwicklungsplan-2026-09-07.md) | **aktuellster Sollstand**: K1–K9 umgesetzt, K10–K15 offen | +| [feedback-an-kundin-2026-09-07.md](feedback-an-kundin-2026-09-07.md) | Kundenbrief dazu, mit den offenen Fragen | +| [entwicklungsplan-2026-08-19.md](entwicklungsplan-2026-08-19.md) | August-Pakete N1–N8 | +| [feedback-an-kundin-2026-08-25.md](feedback-an-kundin-2026-08-25.md) | Kundenbrief August | +| [entwicklungskonzept.md](entwicklungskonzept.md), [todos.md](todos.md) | Juli-Grundanforderungen A1/A2, B1–B10 | +| [feedback-analyse-2026-07-24.md](feedback-analyse-2026-07-24.md), [feedback-an-kundin-statistiken.md](feedback-an-kundin-statistiken.md) | Juli-Korrekturrunde | +| [neuerungen-fuer-berater.md](neuerungen-fuer-berater.md) | Text für die Berater | + +> **Achtung bei Widersprüchen:** Es gilt immer die jüngere Entscheidung. Beispiel: Die +> August-Entscheidung F7 („Abo-Bestände tagesaktuell") wurde am 10.09. abgelöst — abgeschlossene +> Monate sind seitdem vollständig eingefroren. diff --git a/dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md b/dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md new file mode 100644 index 00000000..eecab090 --- /dev/null +++ b/dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md @@ -0,0 +1,858 @@ +# Bewertung des Statistik-Audits vom 09.09.2026 + +Gegenprüfung des [Auditberichts](audit-statistiken-2026-09-09.md) und des +[Prüfprotokolls](audit-statistiken-2026-09-09-pruefprotokoll.md) 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](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A03** | Negative Monatssalden erscheinen im Drilldown; Übersicht und Detail stimmen überein | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A04** | Abgeschlossene Monate werden vollständig eingefroren, kein Live-Overlay mehr | [13](#13-nachtrag-10092026--a04-und-a12-behoben-abgeschlossene-monate-sind-eingefroren) · [eigenes Dokument](monatsabschluss-eingefrorene-statistik.md) | +| **A06** | Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet | [11](#11-nachtrag-10092026--r01-r02-und-r03-behoben) | +| **A08** | Prognose auch ohne bereits erreichte Qualifikationsstufe | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A09** | Summenzeile im Neupartner-CSV; Breitenprüfung für alle Exporte | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A10** | Kundenabo-Liste lädt die Lieferungszahl vor | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A12** | Kachel und verlinkte Liste stammen aus derselben Quelle | [13](#13-nachtrag-10092026--a04-und-a12-behoben-abgeschlossene-monate-sind-eingefroren) | +| **A15** | Storno trägt seine Ursprungsart; Eigen- und Kundenpunkte überschneidungsfrei | [9](#9-nachtrag-09092026--status_turnover-bereinigt-a15a06-behoben) · [11](#11-nachtrag-10092026--r01-r02-und-r03-behoben) | +| **A17** | Fälligkeitsgruppen ohne Monatsüberlauf | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A18** | Formelinhalte im CSV als Text gekennzeichnet, in beiden Exporten | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A19** | Fehlgeschlagene Provisionsberechnung ist als solche erkennbar | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | +| **A01** | Aktiv-Schalter wirkt nur noch im Admin-Pfad; Mindestlaufzeit nicht mehr umgehbar | [14](#14-nachtrag-10092026--a01-und-a16-behoben-regel-und-protokoll) | +| **A16** | Statuswechsel werden zentral am Model protokolliert, in jedem Schreibpfad | [14](#14-nachtrag-10092026--a01-und-a16-behoben-regel-und-protokoll) | +| **A11** | Gleichstände jenseits des Auswahlfensters entscheiden wieder über den Namen | [15](#15-nachtrag-10092026--a11-a13-und-a14-behoben-randfälle) | +| **A13** | Der heutige Liefertag zählt zur Prognose, solange die Ausführung aussteht | [15](#15-nachtrag-10092026--a11-a13-und-a14-behoben-randfälle) | +| **A14** | Tabellen sortieren über unformatierte Werte statt über den Anzeigetext | [15](#15-nachtrag-10092026--a11-a13-und-a14-behoben-randfälle) | +| **R01–R03** | Befunde der Nachprüfung an der eigenen Umsetzung | [11](#11-nachtrag-10092026--r01-r02-und-r03-behoben) | + +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](#8-nachtrag-09092026--stornoregel-entschieden-und-umgesetzt) +- **Monatsabschluss** — ein abgerechneter Monat wird vollständig eingefroren, einschließlich + Team-Struktur und Detaillisten. → [monatsabschluss-eingefrorene-statistik.md](monatsabschluss-eingefrorene-statistik.md) + +## Offen + +| Befund | Warum offen | +| --- | --- | +| **A07** | Gemeinsamer Payline-Kontext — gehört planerisch zu K11 („Statistik = Struktur") | +| **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 + +```bash +php artisan migrate +php artisan backoffice:store-statistics-snapshots --force # einmalig, Payload-Version 2 +php artisan view: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](#9-nachtrag-09092026--status_turnover-bereinigt-a15a06-behoben) 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](#9-nachtrag-09092026--status_turnover-bereinigt-a15a06-behoben). Die im ersten Punkt +> offene Frage zum Zeitpunkt der Stornobuchung ist in +> [Abschnitt 8](#8-nachtrag-09092026--stornoregel-entschieden-und-umgesetzt) 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 | **offen** | +| 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](#13-nachtrag-10092026--a04-und-a12-behoben-abgeschlossene-monate-sind-eingefroren). + +--- + +## 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](audit-statistiken-2026-09-09-nachpruefung.md). **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](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: + +```php +$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**. diff --git a/dev/2026-07-22/audit-statistiken-2026-09-09-nachpruefung.md b/dev/2026-07-22/audit-statistiken-2026-09-09-nachpruefung.md new file mode 100644 index 00000000..31a7755e --- /dev/null +++ b/dev/2026-07-22/audit-statistiken-2026-09-09-nachpruefung.md @@ -0,0 +1,453 @@ +# Nachprüfung der Audit-Bewertung und Storno-Korrekturen — 09.09.2026 + +Geprüft wurden die [Bewertung einschließlich Nachträgen 8 und 9](audit-statistiken-2026-09-09-bewertung.md), die Änderungen gegenüber dem [ursprünglichen Audit](audit-statistiken-2026-09-09.md), vorhandene Regressionstests und zusätzliche Gegenbeispiele. Ergänzend erfolgten ausschließlich lesende Aggregatabfragen gegen die im Entwicklungscontainer konfigurierte MySQL-Datenbank. Diese Datenbank wird hier nicht ungeprüft mit dem laufenden Produktionssystem gleichgesetzt. + +## 1. Ergebnis + +**Die Korrektur für reguläre Eigen- und Shop-Bestellungen ist nachvollziehbar und durch Tests bestätigt. Die abschließende Aussage „A15 und A06 erledigt“ ist jedoch zu weitgehend: A06 besteht weiterhin.** Außerdem hat die neue Herkunftszuordnung einen nicht abgedeckten Sonderfall bei manuellen Gutschriften, und die neuen Tests zur Monatsregel sind nicht unabhängig vom tatsächlichen Systemmonat. + +| Gegenstand | Ergebnis der Nachprüfung | +| --- | --- | +| A15: doppelte Berücksichtigung eines Shop-Stornos | **Behoben:** Eigen-/Shop-Prädikate sind disjunkt. Regulärer Shop-Storno mindert Shop-Punkte und Shop-Nettobasis genau einmal. | +| A15: früher falsche Herkunft bei regulären Shop-Bestellungen | **Für neue Stornos behoben:** Status 2 des Originals führt zuverlässig zu `status_turnover=2`. Vorhandene Altzeilen bleiben absichtlich unverändert. | +| A06: Aufteilung in wiederkehrende Punkte und Einmal-Zusätze | **Nicht behoben:** Zusätze einer vollständig stornierten Bestellung bleiben in der Monatsaufteilung enthalten; Nachweis R01. | +| Neue Zuordnung für Status 4/Shop-Umsatz | **Fehlerhafter Sonderfall:** Storno spiegelt die ursprünglichen Punktekategorien nicht; Nachweis R02. | +| Neue Monatsregel und Settings-Schalter | Im derzeitigen Monat funktionieren die dokumentierten Kalenderfälle. Schalter ist ohne Eintrag aus; eingeschaltet wird im laufenden Monat gebucht. Testuhr wird jedoch umgangen; R03. | +| Migration `status_turnover` | Passt zur gelesenen Datenbankdefinition: nullable, kein Default. Kein Daten-Backfill enthalten. | +| Übrige 21 Audit-Befunde | Die zugehörige Implementierung wurde gegenüber der ursprünglichen Prüfbasis nicht korrigiert. Änderungen ihrer Priorität sind Bewertungen, keine Behebungen. | + +Die notwendigen nächsten Schritte sind damit überschaubar: A06 offen lassen und die Positionsaufteilung korrigieren, die Herkunft von Punkten und Nettoumsatz beim Status-4-Sonderfall getrennt behandeln und die Monatstests von der Systemuhr lösen. Für diese Korrekturen muss keine erneute Entscheidung über den bereits festgelegten Zeitpunkt der Stornobuchung abgewartet werden. + +## 2. Tatsächlich geänderter Umfang + +Der SHA-256-Vergleich mit der ursprünglichen Audit-Prüfbasis zeigt folgende neue bzw. geänderte Implementierung: + +- `app/Services/BusinessPlan/SalesPointsVolume.php`: Monatsregel, Schalterabfrage, Herkunft des Stornos und exklusive Eigen-/Shop-Neuberechnung. +- `app/Services/Backoffice/SalesVolumePoints.php`: disjunkte Eigen-/Shop-Prädikate. +- `resources/views/admin/settings/index.blade.php`: neuer Schalter. +- `database/migrations/2026_09_09_140000_make_status_turnover_nullable_on_user_sales_volumes.php`: neue Migration. +- `tests/Feature/StornoClosedMonthTest.php` und `StornoPointsCategoryTest.php`: elf neue Tests. + +Die Änderungen wurden gegen den damaligen **Arbeitsstand** verglichen, nicht nur gegen Git-HEAD. Dadurch werden bereits vor dem Audit vorhandene, noch nicht eingecheckte Änderungen nicht versehentlich als jetzige Korrekturen gewertet. Die Anwendungsdatei `BackofficeDashboardService.php`, in der A06 entsteht, ist unverändert. + +## 3. Verbliebene und neu erkannte Probleme + +### R01 — P2: A06 bleibt trotz korrigierter Storno-Herkunft bestehen + +**Stellen:** `app/Services/Backoffice/BackofficeDashboardService.php:753`, `:851–883`; neuer Schreibpfad `app/Services/BusinessPlan/SalesPointsVolume.php:352–403`. + +Die neue Herkunftsangabe bewirkt, dass ein regulärer Shop-Storno die abgerechneten Kundenabo-Gesamtpunkte korrekt mindert. Die Zusatzpunkte werden aber weiterhin aus unterschiedlichen Benutzer-/Bestellungs-Paaren und deren ursprünglich positiven Positionen ermittelt. Der Nettosaldo der zugehörigen Buchungen und deren Vorzeichen fehlen in dieser Berechnung. + +**Mit dem echten korrigierten Storno-Service nachgestellt:** + +1. Kundenabo-Bestellung mit 100 Punkten, davon 20 Einmal-Zusatzpunkte. Das Original hat wie im Bestellpfad `status_turnover=NULL`. +2. Vollstorno über `cancelSalesPointsVolume()`. Der neue Code erzeugt selbst `status_turnover=2`; die Gegenprobe setzt den Stornostatus nicht künstlich. +3. Zweite, nicht stornierte Kundenabo-Bestellung mit 100 reinen wiederkehrenden Punkten. +4. Monatsrangliste öffnen. + +| Ausgabe | Ist | Soll | +| --- | ---: | ---: | +| Gesamt abgerechnet | 100 | 100 | +| Einmal-Zusatzpunkte | **20** | **0** | +| Wiederkehrende Punkte | **80** | **100** | + +Der ursprüngliche Audit beschrieb hier eine eigenständige Positions-/Vorzeichenursache. Die falsche Herkunft im alten Buchungspfad war ein zusätzliches Problem. Das Beheben der Herkunft beseitigt die Positionsursache nicht. + +**Empfehlung:** Zuerst pro Bestellung die Nettowirkung der im Monat zählbaren Buchungen auf wiederkehrende und einmalige Positionen bestimmen, danach je Berater summieren. Die pauschale Begrenzung des Zusatzwertes auf die Monatsgesamtsumme reicht nicht. Regressionen für Vollstorno mit weiterer Bestellung, Teilstorno und optional erlaubtem Folgemonatsstorno ergänzen. A06 nicht als erledigt führen. + +### R02 — P2: Storno einer manuellen Gutschrift kann falsche Punktekategorien erzeugen + +**Stellen:** `app/Services/BusinessPlan/SalesPointsVolume.php:99–104,128–133,301–310`; `app/Services/Backoffice/SalesVolumePoints.php:36–38`. + +Die neue Methode behandelt Status 4 mit `status_turnover=2` pauschal wie eine Shop-Bestellung. Der ursprüngliche Status-4-Zweig macht jedoch etwas anderes: **Punkte gehen nach KP/TP, nur der Nettoumsatz wird dem Shop zugeordnet.** Der Storno verschiebt dagegen sowohl Punkte als auch Nettoumsatz in die Shop-Kategorie. + +**Nachgestellt:** Manuelle Gutschrift mit 100 Punkten, 100 Nettoumsatz, `status=4`, `status_turnover=2`, `status_points=2` (nur KP); anschließend Vollstorno über den neuen Service. + +| Kennzahl nach Vollstorno | Ist | Soll bei vollständiger Gegenbuchung | +| --- | ---: | ---: | +| KP | **100** | 0 | +| Shop-Punkte | **−100** | 0 | +| Shop-Nettoumsatz | 0 | 0 | +| Statistik: Eigenpunkte | **100** | 0 | +| Statistik: Kundenpunkte | **−100** | 0 | +| Punkte ohne Payline-Wirkung | **100** | 0 | + +Die Gesamtpunktesumme ist zwar null, aber die ursprünglichen KP bleiben stehen und im Shop entsteht ein negativer Punktesaldo, den die Ursprungsbuchung dort nie aufgebaut hat. Bei `status_points=1` ist zusätzlich die TP-Zuordnung betroffen. + +**Reichweite:** Der Sonderfall ist im neuen Service ausdrücklich vorgesehen und wurde auf Service-Ebene nachgewiesen. In der gelesenen Datenbank existieren 13 Status-4-Buchungen mit Shop-Nettoumsatz; ein bereits erfolgter Storno genau dieser Buchungen wurde nicht nachgewiesen. Die Erreichbarkeit für sämtliche administrativen Rechnungsarten wurde nicht per Oberfläche getestet. Dies ist daher keine Behauptung eines bereits entstandenen neuen Auszahlungsschadens. + +**Empfehlung:** Herkunft der Punkte und Herkunft des Nettoumsatzes getrennt abbilden bzw. die Ursprungsbuchungsart bei der Gegenbuchung erhalten. Eine gemeinsame Zahl 1/2 reicht für den bestehenden Status-4-Sonderfall nicht. Tests für Status 4 × Umsatzzuordnung 1/2 × Punkteart KP bzw. KP+TP ergänzen; jede Gegenbuchung muss alle Kategorien des Originals aufheben. + +### R03 — P2: Die Monatstests laufen nur zufällig im passenden Systemmonat + +**Stellen:** `app/Services/BusinessPlan/SalesPointsVolume.php:320–323,368–370`; `tests/Feature/StornoClosedMonthTest.php:13–14,50–60`; `tests/Feature/StornoPointsCategoryTest.php:13`. + +Die neuen Tests frieren Carbon auf September 2026 ein. Der neue Monatsvergleich und die Stornobuchung verwenden dagegen PHP-`date()`. Diese Funktion folgt weiterhin der tatsächlichen Systemuhr. Die Fixtures und Erwartungen bleiben fest bei September 2026. + +**Nachgestellt:** Reale Systemuhr September 2026; Anwendungs-/Carbon-Uhr auf Oktober 2026 gestellt; Ursprungsbuchung Oktober 2026. `isStornoMonthOpen()` ergibt `false`, obwohl Original und eingefrorener Anwendungsmonat identisch sind. + +Die elf Tests bestehen heute. Nach einem tatsächlichen Monatswechsel werden mehrere trotz unveränderter fachlicher Implementation fehlschlagen bzw. andere Buchungsmonate verwenden. Das ist zunächst ein Reproduzierbarkeitsfehler der Tests; es ist kein Nachweis, dass die Produktionsanwendung heute grundsätzlich im falschen Monat bucht. + +**Empfehlung:** Für Prüfung und Buchung denselben einmal ermittelten, testbaren Zeitpunkt verwenden, etwa `now()` oder eine injizierte Uhr. Monat/Jahr daraus gemeinsam ableiten. Monats- und Jahreswechsel unabhängig von der Systemuhr testen. Der Kommentar in `StornoClosedMonthTest.php`, die Testmigration verbiete `NULL`, ist durch die neue Migration ebenfalls überholt. + +## 4. Was an den Korrekturen bestätigt ist + +**Normale Shop-/Eigenstornos:** Ein Shop-Original mit `status_turnover=NULL` erhält im neuen Storno die Herkunft 2. Ein Vollstorno von 100 Punkten/100 Nettoumsatz ergibt KP 0, Shop-Punkte 0, Shop-Nettobasis 0 und Gesamtpunkte 0. Die ursprüngliche doppelte Berücksichtigung ist beseitigt. Eigenstornos und unveränderte Behandlung alter NULL-Stornos werden von den neuen vorhandenen Tests ebenfalls abgedeckt. + +**Zeitpunktregel:** Der frühe Rücksprung verhindert bei ausgeschaltetem Schalter eine Punkte-/Umsatzzeile für ältere Kalendermonate. Der einzige gefundene aufrufende Rechnungspfad (`InvoiceRepository::correctPointsForCancellation()`, Zeile 511) dereferenziert das nun nullable Ergebnis nicht. Die Rechnungserstellung wird dadurch nicht durch einen neuen Nullzugriff unterbrochen. Der gesamte PDF-/Mail-Ablauf wurde nicht live ausgelöst. + +**Settings:** `Setting::getContentBySlug()` liefert ohne Datensatz `false`; bei Typ `bool` liefert es einen echten Boolean. Der Controller setzt ein nicht übermitteltes Checkbox-Feld bei vorhandenem Settings-Eintrag auf false. Ein- und Ausschalten sind damit auch im Formularpfad nachvollziehbar, nicht nur bei direktem Schreiben des Settings. + +**Grenze der Abschlussregel:** Implementiert ist „aktueller Kalendermonat“, keine Prüfung, ob tatsächlich schon eine Monatsauswertung/Gutschrift erstellt wurde. Bei der dokumentierten Auslegung ist das konsistent. Falls künftig ein expliziter Abschlussstatus maßgeblich werden soll, genügt die aktuelle Funktion nicht. + +**Incentive:** Der frühe Rücksprung überspringt weiterhin auch `IncentiveTracker::trackStorno()`. Die Bewertung benennt den Fall einer noch laufenden Incentive-Aktion zu Recht als offen. Die neue Kategorienkorrektur löst diese Produktentscheidung nicht. + +**Migration:** Die Entwicklungsdatenbank hat bereits `tinyint unsigned`, nullable, Default NULL. Die Migration ändert keine vorhandenen Werte. Der leere `down()`-Pfad ist offen kommentiert: Ein Rollback stellt die alte NOT-NULL-Definition nicht wieder her. „Kein Rückbau“ sollte auch in einer späteren Release-Notiz stehen; es wurde hier keine Migration gegen die Entwicklungsdatenbank ausgeführt. + +## 5. Gegenprüfung der Daten- und Prioritätsaussagen + +### Lesende Datenbanknachweise + +Die Abfragen liefen über eine separate PDO-Verbindung in `START TRANSACTION READ ONLY`, anschließend Rollback. Keine Laravel-Anwendung wurde dafür gebootet; keine Zugangsdaten, Namen, E-Mails oder einzelnen Kundendatensätze wurden ausgegeben. Die folgenden Zahlen sind Aggregate des konfigurierten Entwicklungsdatenbestands zum Prüfzeitpunkt. + +| Aussage der Bewertung | Eigene Nachprüfung | +| --- | --- | +| 20 Stornos, alle `status_turnover=NULL` | Bestätigt. Kein vorhandener Storno mit Herkunft 2. | +| 13 Stornos auf Kunden-/Shop-Bestellungen | Bestätigt über `shopping_orders.payment_for=6`. | +| Statusverteilung mit 19.525 Eigen-, 24.151 Shop-, 76/13 manuellen und 647 Registrierungszeilen | Bestätigt; zusätzlich 1 Status-0- und 8 Status-3-Zeilen wie dokumentiert. | +| 7 VIP-Konten, 5 englischsprachige Nutzer | Bestätigt bei nicht gelöschten Konten. | +| 901 Nutzer insgesamt | Präzisierung: **901 nicht gelöschte Konten**, 1.634 physische Benutzerzeilen einschließlich Soft-Deletes. Kein abweichendes Wachstumsszenario daraus abgeleitet. | +| 44 Kündigungen, 10 Deaktivierungen, 6 Pausen, keine Reaktivierungshistorie | Bestätigt. Die 10 Abos mit Deaktivierungshistorie sind derzeit inaktiv. | +| 7 gleichmonatige Shop-Stornos | Bestätigt durch Verknüpfung Stornozeile → Originalrechnung → Ursprungsbuchung. Sie verteilen sich auf 6 Benutzer-/Monatskombinationen; betroffener Nettobetrag insgesamt −596,05 €. | +| 178,82 € Shop-Provisionswirkung | **Als rechnerische Differenz der Shop-Basis bestätigt**, siehe Einschränkung unten. | + +Für die sechs Benutzer-/Monatskombinationen wurden die gespeicherten Monatsabrechnungen verknüpft. Die dortigen Shop-Margen betragen jeweils 30 %. Die Differenz + +`SUM(ROUND(Shopbasis × Marge / 100, 2) − ROUND((Shopbasis + Storno-Netto) × Marge / 100, 2))` + +ergibt **178,82 €**. Einfaches Runden jedes einzelnen Stornoanteils und anschließendes Summieren kann hiervon um einen Cent abweichen; die Monatsberechnung ist daher ausdrücklich angegeben. + +**Nicht bestätigt ist die Formulierung „178,82 € zu viel ausgezahlt“.** Es wurden keine Auszahlungstransaktionen abgestimmt. Zudem stimmt bei einer der sechs gespeicherten Monatsabrechnungen `commission_shop_sales` nicht exakt mit der bloßen Formel aus Shopbasis und Marge überein. Die Ursache dieser Abweichung ist nicht Gegenstand der Nachprüfung. Eine sichere Auszahlungsaussage braucht den Abgleich der tatsächlich festgesetzten und ausgezahlten Beträge einschließlich vorhandener Korrekturen. + +### Einordnung der Bewertung + +- **A15, Bestandsdaten:** Die Konkretisierung ist richtig: Die alte Doppelberücksichtigung war in den gelesenen Daten nicht aktiv, weil dort Herkunft 2 bei Stornos fehlte. Der ursprüngliche Audit hatte eine bedingte Codewirkung beschrieben und die fehlende Produktionsprüfung ausdrücklich benannt. Das darf nicht nachträglich als schon belegter Schaden an Gesamtpunkten gelesen werden. Die Fixture ist ein gültiger Test des überlappenden Zweigs; sie war kein Beleg dafür, dass dieser Datenzustand damals im Bestand vorkam. Durch den korrigierten Writer wird Herkunft 2 für neue Shop-Stornos jetzt tatsächlich erzeugt. +- **Provisionswirkung:** Die Aussagen in Abschnitten 3, 5 und 6 der Bewertung, die Auswirkung auf Provisionen sei null bzw. nur Darstellung betroffen, sind durch den eigenen Nachtrag 9 überholt. Gesamtpunkte allein bestimmen die Shop-Provision nicht; `BusinessUserItemOptimized.php:233–236` verwendet die Shop-Nettobasis. Bei Weiterverwendung des Dokuments sollte der gültige Endstand vorne klar stehen. +- **A16 und A22:** Die gelesenen Bestandsgrößen rechtfertigen eine geringere kurzfristige Dringlichkeit. Keine heute aktive der zehn Deaktivierungen ist aber kein Beweis für vollständige historische Richtigkeit sämtlicher Zwischenzustände, Teamwechsel und anderer Schreibpfade. Beim Speicherbefund wurde ursprünglich ein Skalierungsrisiko beschrieben, kein bereits eingetretener Absturz. +- **A14:** Die Begrenzung auf Englisch stimmt. Schon der ursprüngliche Befund demonstrierte englische Formate; die Empfehlung, alle vier Sprachen zu testen, behauptet keinen Fehler in allen vier Sprachen. Fünf betroffene Nutzer senken die Reichweite, nicht die Fehlerhaftigkeit ihrer Sortierung. +- **A05:** „Nur ein Tooltip genügt“ ist keine vollständig abgesicherte Schlussfolgerung. Das Juli-Dokument empfiehlt tatsächlich `customerAboCounts()`; das neuere September-PDF verlangt aber ausdrücklich nur bereits abgerechnete Abos. Es besteht ein Widerspruch zwischen ursprünglichem Umsetzungsdetail und erneuertem Kundenwunsch. Aktuellen Bestand klar beschriften ist sinnvoll; ob damit die geforderte Anzahl abgegolten ist, bleibt fachlich zu entscheiden. +- **A01:** P1/P2 ist eine Prioritätsentscheidung. Dass nur das eigene Abo betroffen ist, war bereits im ursprünglichen Audit abgegrenzt. Ausfall künftiger Abo-Lieferungen/-Zahlungen durch Umgehung einer Mindestlaufzeit ist trotzdem eine relevante Geschäftsregelverletzung. Ohne Untersuchung von Änderungsereignissen ist „kein aktueller Schaden“ hier nicht belegt. +- **A18:** Teamdaten sind nicht ausschließlich vom exportierenden Benutzer kontrollierter Input. Namen, Kontaktdaten und weitere Textfelder können von anderen Personen stammen. Die Teamgrenze beseitigt die Formel-Interpretation im Tabellenprogramm nicht. Eine niedrigere organisatorische Priorität kann vereinbart werden; die Begründung „kein fremder Input“ trägt technisch nicht. +- **A20:** Öffentliche Bilder können eine bewusst akzeptierte Produktentscheidung sein. Ein mit `uniqid()` präfigierter Dateiname ist aber keine Team-Zugriffskontrolle. „Teamintern“ muss entweder nur die Listenauswahl oder auch den Bildabruf meinen; das sollte ausdrücklich festgelegt sein. +- **A07:** Die noch offene allgemeine Vereinheitlichung K11 blockiert nicht zwangsläufig die Konsistenz zweier bereits vorhandener Payline-Prognosen. Gemeinsame Filter innerhalb derselben Prognosesicht können gesondert korrigiert werden. + +## 6. Tests und Aussagegrenzen + +Alle schreibenden Testaktivitäten liefen in `/tmp/mivita-statistics-recheck`, einer eigenen Kopie mit separaten `vendor`-Dateien, isolierter Testkonfiguration und SQLite `:memory:`. Die neuen/angepassten Dateien wurden aus dem Arbeitsstand übernommen. Die Bestandsdatenbank wurde ausschließlich für die obigen lesenden Abfragen verwendet. + +| Lauf | Ergebnis | +| --- | --- | +| Bestehende Statistik-Kernmodule plus beide neuen Storno-Testdateien | **120 bestanden, 711 Assertions** | +| Zusätzliche Gegenproben R01–R04 | **4 bestanden, 12 Assertions**; R01–R03 bestätigen ausdrücklich das beanstandete Ist-Verhalten, R04 bestätigt die gelungene reguläre Korrektur | +| Erweiterter Lauf Incentive/Sponsor-Reparatur/Storno/DATEV | **98 bestanden, 3 fehlgeschlagen, 275 Assertions** | +| Derselbe erweiterte Filter auf isoliertem Stand vor den jetzigen Korrekturen | **87 bestanden, dieselben 3 fehlgeschlagen, 242 Assertions** | + +Die drei Fehler betreffen `IncentiveTeaserPageTest` (Galerie) und zwei Fälle in `RegenerateCancellationInvoicePdfCommandTest`. Sie treten im gleichen isolierten Umfeld bereits ohne die jetzigen Änderungen auf. Damit sind sie keine nachgewiesenen Regressionen dieser Korrektur. Der Filter `Storno` erfasst auch Testbeschreibungen und schließt deshalb PDF-Regenerierungstests ein. Die in der Bewertung genannte Zusammenstellung „245 bestanden, 1 Fehler“ wurde ohne deren vollständigen Testbefehl nicht als identischer Lauf behauptet. + +Die PHPUnit-Warnung zur veralteten XML-Konfiguration besteht weiterhin. Es wurden kein umfassender Browser-/Lasttest, keine echte Rechnungserstellung mit Versand und kein Zahlungsabgleich ausgeführt. + +Die initiale Einrichtung der zusätzlichen Fixtures erforderte das Entfernen doppelter Hilfsfunktionsnamen und das explizite Setzen von `status_points=1`, bevor ein direkt erzeugtes Eloquent-Objekt an den Writer übergeben wurde. Diese Fixturefehler sind keine Anwendungsbefunde. Die oben genannten finalen Ergebnisse stammen aus dem bereinigten Lauf. + +## 7. Ausgeführte Testbefehle + +Im isolierten Nachprüfungsverzeichnis: + +```bash +php artisan test --compact --filter='StornoClosedMonthTest|StornoPointsCategoryTest|BackofficeStatistics|BackofficeDashboardService|PersonalStatisticsService|HomeMonthlyStats|PaylineForecastService|LiveCommissionService|TeamAboOverview|TeamFirstlinesPage|TopResultsService|AnnualFeeDueService|CustomerAboListView|PointsAboHighlight|TeamStructureAboBadges|TeamCustomerAbosRedirect' +php artisan test --compact --filter=StatisticsRecheckProbeTest +php artisan test --compact --filter='Incentive|SponsorBuyingPointsRepairTest|Storno|DatevExportCancellationTest' +``` + +Der letzte Befehl wurde zusätzlich in `/tmp/mivita-statistics-audit` mit dem Stand vor den jetzigen Korrekturen ausgeführt, um die drei Fehlschläge abzugrenzen. Keine dieser Prüfungen änderte die Testdateien im Repository. + +## 8. Integrität und dauerhafte Nachweise + +Vor Beginn der Nachprüfung wurde eine neue SHA-256-Prüfbasis für **10.391 vorhandene Dateien** erstellt. Der Abschlussvergleich erfolgt gegen diese Basis einschließlich des Bewertungsdokuments und aller inzwischen erfolgten Implementierungskorrekturen. Neu angelegt wurde nur dieses Nachprüfungsdokument. Die vorangegangenen Auditberichte und die Bewertung wurden nicht redigiert. + +Die nachfolgenden Anhänge enthalten die ausgeführten zusätzlichen Gegenproben und anonymen Aggregatergebnisse dauerhaft im Auditordner. Die temporären Dateien werden zum Verständnis des Ergebnisses nicht benötigt. + +### Anhang A: Zusätzliche isolierte Gegenproben + +Die Erwartungen von R01–R03 beschreiben den beobachteten Fehlerzustand. Bei einer späteren Behebung müssen daraus Soll-Regressionen werden; der grüne Gegenproben-Lauf bedeutet keine Freigabe dieser drei Verhaltensweisen. R04 prüft dagegen bereits das gewünschte Ergebnis. Die Beispiele wurden am 09.09.2026 ausgeführt; R03 wechselt die Carbon-Uhr bewusst relativ zum realen Systemmonat. + +```php + Carbon::setTestNow()); +function recheckRecord(string $case, array $data): void { + file_put_contents('/tmp/statistics-recheck-results.jsonl', json_encode(['case'=>$case]+$data)."\n", FILE_APPEND); +} +function recheckUser(?int $sponsor = null): User { + return User::forceCreate(['email'=>'audit-'.uniqid().'@example.test','password'=>'test','lang'=>'de','admin'=>0,'m_level'=>1,'m_sponsor'=>$sponsor,'active_date'=>'2026-01-01','payment_account'=>'2027-01-01']); +} +function recheckVolume(User $u, float $points, int $status = 1, ?ShoppingOrder $order = null, int $month = 9): UserSalesVolume { + return UserSalesVolume::forceCreate(['user_id'=>$u->id,'shopping_order_id'=>$order?->id,'month'=>$month,'year'=>2026,'date'=>"2026-{$month}-01",'status'=>$status,'status_points'=>1,'status_turnover'=>null,'points'=>$points,'total_net'=>$points]); +} +function recheckOrder(User $u): ShoppingOrder { + return ShoppingOrder::create(['shopping_user_id'=>0,'auth_user_id'=>$u->id,'member_id'=>$u->id,'country_id'=>1,'language'=>'de','user_shop_id'=>1,'payment_for'=>6,'total'=>100,'subtotal'=>100,'points'=>100,'paid'=>true,'shipped'=>0,'is_abo'=>true,'mode'=>'test']); +} +it('R01 addon split remains wrong after cancellation through the corrected writer', function () { + $u=recheckUser(); $a=recheckUser($u->id); $o=recheckOrder($a); + ShoppingOrderItem::forceCreate(['shopping_order_id'=>$o->id,'product_id'=>0,'qty'=>1,'price'=>20,'points'=>20,'is_abo_addon'=>true]); + $v=recheckVolume($a,100,2,$o); + $cancel=SalesPointsVolume::cancelSalesPointsVolume($v,9991); + recheckVolume($a,100,2,recheckOrder($a)); + $r=app(BackofficeDashboardService::class)->overview($u,9,2026)['top_customer_abos']['by_points'][0]; + recheckRecord('R01',['original_turnover'=>$v->status_turnover,'cancel_turnover'=>$cancel->status_turnover,'actual'=>$r['points_actual'],'addon'=>$r['points_addon'],'recurring'=>$r['points_recurring']]); + expect($cancel->status_turnover)->toBe(2)->and($r['points_actual'])->toBe(100.0)->and($r['points_addon'])->toBe(20.0)->and($r['points_recurring'])->toBe(80.0); +}); +it('R02 cancellation of manual shop-net credit does not reverse its original point categories', function () { + $u=recheckUser(); $v=recheckVolume($u,100,4); $v->update(['status_turnover'=>2,'status_points'=>2]); + SalesPointsVolume::reCalculateSalesPointsVolume($u->id,9,2026); + $before=$v->fresh(); + $cancel=SalesPointsVolume::cancelSalesPointsVolume($v,9992)->fresh(); + $aggregate=UserSalesVolume::where('user_id',$u->id)->selectRaw(SalesVolumePoints::ownPoints().' as own')->selectRaw(SalesVolumePoints::externalPoints().' as shop')->selectRaw(SalesVolumePoints::nonPaylinePoints().' as nonpayline')->first(); + recheckRecord('R02',['before_KP'=>$before->month_KP_points,'before_shop_points'=>$before->month_shop_points,'after_KP'=>$cancel->month_KP_points,'after_shop_points'=>$cancel->month_shop_points,'after_shop_net'=>$cancel->month_shop_total_net,'aggregate_own'=>(float)$aggregate->own,'aggregate_shop'=>(float)$aggregate->shop,'nonpayline'=>(float)$aggregate->nonpayline]); + expect($cancel->month_KP_points)->toBe(100.0)->and($cancel->month_shop_points)->toBe(-100.0)->and((float)$aggregate->nonpayline)->toBe(100.0); +}); +it('R03 month check ignores the frozen application clock', function () { + $month=(int)date('m'); $year=(int)date('Y'); + Carbon::setTestNow(Carbon::create($year,$month,15)->addMonth()); + $u=recheckUser();$v=recheckVolume($u,100,2);$v->update(['month'=>now()->month,'year'=>now()->year]); + $open=SalesPointsVolume::isStornoMonthOpen($v); + recheckRecord('R03',['system_month'=>date('Y-m'),'app_month'=>now()->format('Y-m'),'original_month'=>$v->year.'-'.str_pad($v->month,2,'0',STR_PAD_LEFT),'considered_open'=>$open]); + expect($open)->toBeFalse(); +}); +it('R04 ordinary customer cancellation now reverses totals and shop basis exactly once', function () { + $u=recheckUser(); $v=recheckVolume($u,100,2); $c=SalesPointsVolume::cancelSalesPointsVolume($v,9993)->fresh(); + $total=SalesVolumePoints::totalPointsByUser([$u->id],9,2026)->get($u->id); + recheckRecord('R04',['turnover'=>$c->status_turnover,'KP'=>$c->month_KP_points,'shop_points'=>$c->month_shop_points,'shop_net'=>$c->month_shop_total_net,'total'=>$total]); + expect($c->status_turnover)->toBe(2)->and($c->month_shop_points)->toBe(0.0)->and($c->month_shop_total_net)->toBe(0.0)->and($total)->toBe(0.0); +}); +``` + +Aufgezeichnete Ergebnisse: + +```jsonl +{"case":"R01","original_turnover":null,"cancel_turnover":2,"actual":100,"addon":20,"recurring":80} +{"case":"R02","before_KP":100,"before_shop_points":0,"after_KP":100,"after_shop_points":-100,"after_shop_net":0,"aggregate_own":100,"aggregate_shop":-100,"nonpayline":100} +{"case":"R03","system_month":"2026-09","app_month":"2026-10","original_month":"2026-10","considered_open":false} +{"case":"R04","turnover":2,"KP":0,"shop_points":0,"shop_net":0,"total":0} +``` + +### Anhang B: Anonyme Datenbankaggregate + +Erste Abfragegruppe: + +```json +{ + "status_counts": [ + { + "status": 0, + "status_turnover": null, + "n": 1 + }, + { + "status": 1, + "status_turnover": null, + "n": 19525 + }, + { + "status": 2, + "status_turnover": null, + "n": 24151 + }, + { + "status": 3, + "status_turnover": null, + "n": 8 + }, + { + "status": 4, + "status_turnover": 1, + "n": 76 + }, + { + "status": 4, + "status_turnover": 2, + "n": 13 + }, + { + "status": 5, + "status_turnover": null, + "n": 647 + }, + { + "status": 6, + "status_turnover": null, + "n": 20 + } + ], + "users": [ + { + "total": 1634, + "vip": "7", + "english": "5" + } + ], + "history": [ + { + "action": "paused", + "n": 6 + }, + { + "action": "cancelled", + "n": 44 + }, + { + "action": "deactivated", + "n": 10 + } + ], + "deactivated_now": [ + { + "deactivated_abos": 10, + "active_history_rows": "0" + } + ], + "storno_orders": [ + { + "payment_for": 1, + "n": 1 + }, + { + "payment_for": 6, + "n": 13 + }, + { + "payment_for": 3, + "n": 6 + } + ], + "same_month_shop": [ + { + "n": 7, + "cancelled_net": "-596.05", + "hypothetical_margin_30": "178.81" + } + ], + "turnover_schema": [ + { + "COLUMN_TYPE": "tinyint unsigned", + "IS_NULLABLE": "YES", + "COLUMN_DEFAULT": null + } + ] +} +``` + +Ergänzende Abfragegruppe zur Rundung und gespeicherten Shop-Basis: + +```json +{ + "users_scope": [ + { + "physical_rows": 1634, + "not_deleted": "901" + } + ], + "rounding": [ + { + "affected_user_months": 6, + "net": "-596.05", + "round_after_sum": "178.82", + "sum_after_round": "178.81" + } + ], + "stored_business": [ + { + "matches": 6, + "min_margin": "30.00", + "max_margin": "30.00", + "commission_basis_difference": "178.82", + "existing_commission_mismatches": "1" + } + ] +} +``` + +Wesentliche Abfragen zur Reproduktion; ausschließlich lesend in einer entsprechenden Read-only-Transaktion ausführen. Keine Identitäten ausgeben: + +```sql +SELECT status, status_turnover, COUNT(*) AS n +FROM user_sales_volumes +GROUP BY status, status_turnover +ORDER BY status, status_turnover; + +SELECT COUNT(*) AS physical_rows, + SUM(deleted_at IS NULL) AS not_deleted, + SUM(admin >= 1 AND admin < 4 AND deleted_at IS NULL) AS vip, + SUM(lang = 'en' AND deleted_at IS NULL) AS english +FROM users; + +SELECT action, COUNT(*) AS n +FROM user_abo_status_histories +GROUP BY action; + +SELECT COUNT(DISTINCT a.id) AS deactivated_abos, + SUM(a.active = 1) AS active_history_rows +FROM user_abo_status_histories h +JOIN user_abos a ON a.id = h.user_abo_id +WHERE h.action = 'deactivated'; + +SELECT o.payment_for, COUNT(*) AS n +FROM user_sales_volumes v +JOIN shopping_orders o ON o.id = v.shopping_order_id +WHERE v.status = 6 +GROUP BY o.payment_for; + +SELECT COUNT(*) AS matches, + MIN(b.margin_shop) AS min_margin, + MAX(b.margin_shop) AS max_margin, + SUM(ROUND(b.sales_volume_total_shop * b.margin_shop / 100, 2) + - ROUND((b.sales_volume_total_shop + q.net) * b.margin_shop / 100, 2)) + AS commission_basis_difference, + SUM(ABS(b.commission_shop_sales + - ROUND(b.sales_volume_total_shop * b.margin_shop / 100, 2)) > 0.005) + AS existing_commission_mismatches +FROM ( + SELECT c.user_id, c.month, c.year, SUM(c.total_net) AS net + FROM user_sales_volumes c + JOIN user_invoices i ON i.cancellation_id = c.user_invoice_id + JOIN user_sales_volumes v ON v.user_invoice_id = i.id + WHERE c.status = 6 AND v.status = 2 + AND c.month = v.month AND c.year = v.year + GROUP BY c.user_id, c.month, c.year +) q +JOIN user_businesses b + ON b.user_id = q.user_id AND b.month = q.month AND b.year = q.year; +``` + +### Anhang C: Geprüfte Korrekturfassungen + +SHA-256 der zentralen geänderten Dateien: + +| Datei | SHA-256 | +| --- | --- | +| `app/Services/BusinessPlan/SalesPointsVolume.php` | `4d668b5f91885926b40c8f27b9f501f02837d6775f835d851cef33cb58b5895d` | +| `app/Services/Backoffice/SalesVolumePoints.php` | `5dcd93954b37f6562ddf07df0895f7c36b02b8648745b79aac2bb78aca0a8fd5` | +| `resources/views/admin/settings/index.blade.php` | `3b39e30d70cf259e85a45ec0090b08d85ee0e554ec25817d750219d105d9e5e8` | +| `database/migrations/2026_09_09_140000_make_status_turnover_nullable_on_user_sales_volumes.php` | `9effa46911e9edc2b67ad190e45018d9bcff755288a8a5148d1d47170e24cc2f` | +| `tests/Feature/StornoClosedMonthTest.php` | `e364eac7144875d847c90555d12c12e6994ae13727bf1100c941c2742b07d3e3` | +| `tests/Feature/StornoPointsCategoryTest.php` | `d92a65fb09befad2b0972f040b6925cbc5edf4043ea025a92f3c1aebaa3747b8` | + +**Abschluss des Dateivergleichs:** 10.391 vorhandene Dateien geprüft, 0 verändert oder entfernt; ausschließlich dieses Nachprüfungsdokument neu angelegt. Anwendungscode, bestehende Tests, Konfiguration und vorhandene Dokumente blieben unverändert. diff --git a/dev/2026-07-22/audit-statistiken-2026-09-09-pruefprotokoll.md b/dev/2026-07-22/audit-statistiken-2026-09-09-pruefprotokoll.md new file mode 100644 index 00000000..0f7bca1d --- /dev/null +++ b/dev/2026-07-22/audit-statistiken-2026-09-09-pruefprotokoll.md @@ -0,0 +1,451 @@ +# Prüfprotokoll zum Statistik-Audit — 09.09.2026 + +Dieses Dokument gehört zum [Auditbericht](audit-statistiken-2026-09-09.md). Es enthält die Ausführungsergebnisse, Prüfmethode und vollständigen zusätzlichen Gegenproben. Die temporären Arbeitsdateien sind für das Verständnis nicht erforderlich; ihre relevanten Inhalte stehen hier dauerhaft im Auditordner. + +## 1. Arbeitsstand und Isolation + +- Repository: `/var/www/html`, Referenz-Commit `384b2dcff1533168d4a122f4c2e93a5e0336bd75`. +- Geprüft wurde der aktuelle Arbeitsstand einschließlich bereits vorhandener unversionierter Dateien und Änderungen. Aus einem Befund wird keine unbelegte Zuordnung zu einem bestimmten Entwickler oder Commit abgeleitet. +- Vor Beginn wurden SHA-256-Prüfsummen von 10.385 vorhandenen regulären, von Git erfassten bzw. nicht ignorierten Dateien gespeichert. Bereits vorhandene Dateien wurden nicht bearbeitet. +- Testkopie: `/tmp/mivita-statistics-audit`; Anwendung, Tests, Migrationen, Konfiguration, Routen, Views, lokale Pakete und eigene Kopie der vorhandenen `vendor`-Abhängigkeiten. Die Produktions-/Entwicklungs-`.env` wurde nicht übernommen, Bootstrap-Cache wurde nicht übernommen, temporäre Runtime-Verzeichnisse wurden separat angelegt. +- Datenbank: SQLite `:memory:` gemäß Testkonfiguration. Mail/Cache im Testbetrieb, keine produktiven Zugangsdaten. Zum vollständigen Bootstrap wurden ausschließlich Testvariablen aus `phpunit.xml`, ein synthetischer APP_KEY, `APP_URL=http://localhost` und ein Null-Logkanal in der temporären Testumgebung verwendet. +- Es wurden keine neuen Pakete im Repository installiert. PDF-Text-/Bildprüfung erfolgte mit einem ausschließlich unter `/tmp` entpackten PyMuPDF-Wheel. Die Testausführung verwendete die vorhandenen PHP-Abhängigkeiten in der Kopie. +- Es wurden keine Testdateien im Repository angelegt. Der unten dokumentierte PHP-Code existierte als ausführbare Datei nur in der isolierten Kopie. + +Die erste Testvorbereitung scheiterte am fehlenden `PAYONE_KEY_HASH` beim Bootstrap. Nach Übernahme der Testvariablen liefen Tests zunächst mit Warnungen wegen einer fehlenden `.env`; die isolierte Test-`.env` beseitigte diese Ursache. Die unten angegebenen Endergebnisse stammen aus den anschließend abgeschlossenen Läufen, nicht aus diesen Vorbereitungsversuchen. + +## 2. Ausgeführte vorhandene Tests + +Alle folgenden Befehle wurden im Verzeichnis `/tmp/mivita-statistics-audit` ausgeführt, nicht im Anwendungs-Arbeitsverzeichnis. + +### Statistik-Kernmodule + +```bash +php artisan test --compact --filter='BackofficeStatistics|BackofficeDashboardService|PersonalStatisticsService|HomeMonthlyStats|PaylineForecastService|LiveCommissionService|TeamAboOverview|TeamFirstlinesPage|TopResultsService|AnnualFeeDueService|CustomerAboListView|PointsAboHighlight|TeamStructureAboBadges|TeamCustomerAbosRedirect' +``` + +Ergebnis: **109 bestanden, 678 Assertions, 1,74 s**. Erfasst werden die vorhandenen Tests zu Zugriff, Monatsübersicht, Listen/Details, persönlichen Zahlen, Abo-/Payline-Kennzahlen, Prognosen, Firstlines, Ranglisten, Jahresgebühr und den neuen Ansichten. + +### Angrenzende Funktionen + +```bash +php artisan test --compact --filter='AboPauseTest|AboOneAboRuleTest|AboLockDaysSettingTest|AboUpdateAuthorizationTest|AboExternalCompletionTest|AboOneTimeViewTest|ProfileImageTest|GdprDeletionTest|CheckoutOrderSourceValidationTest|YardShippingTaxTest|IncentiveRankingTest|IncentiveParticipantRankOrderingTest|IncentivePartnerRepairSponsorChangeTest|SponsorBuyingPointsRepairTest|LocalizationSyncTest' +``` + +Ergebnis: **105 bestanden, 2 fehlgeschlagen, 553 Assertions, 1,84 s**. Beide Fehler liegen in `Tests\Feature\LocalizationSyncTest`, Vergleich der Sprachschlüssel gegen Deutsch, `tests/Feature/LocalizationSyncTest.php:17`. + +Die gesonderte schreibfreie Prüfung der `payment.php`-Schlüssel ergab: + +```text +en: 0 missing +es: 25 missing +fr: 25 missing +``` + +Fehlende Schlüssel in es und fr: + +```text +manual_amount +manual_bank_reference +manual_bank_reference_help +manual_confirm_deviation +manual_error_already_paid +manual_error_amount_deviation +manual_error_cancelled +manual_error_future_date +manual_error_general +manual_error_payment_mismatch +manual_error_reference_required +manual_expected_amount +manual_method +manual_method_bank_transfer +manual_method_cash +manual_method_other +manual_note +manual_paid_at +manual_record_bank_hint +manual_record_button +manual_record_intro +manual_record_title +manual_recorded_by +manual_send_mail +manual_success +``` + +Diese Fehler sind vorhandene Übersetzungslücken im geprüften Stand. Sie belegen keine neu verursachte Statistikregression. Der übrige Testlauf bleibt von dieser Einordnung unabhängig. + +In den finalen PHP-Läufen blieb die PHPUnit-Warnung erhalten: + +```text +Your XML configuration validates against a deprecated schema. +Migrate your XML configuration using "--migrate-configuration"! +``` + +Die vorgeschlagene Änderung wurde nicht ausgeführt. Die genannten Laufzeiten sind Messwerte der kleinen SQLite-Testläufe, keine Performanceaussage zur Produktionsanwendung. Es wurde nicht die komplette Repository-Testsuite ausgeführt. + +## 3. Zusätzliche PHP-Gegenproben + +Ausführung in der isolierten Kopie: + +```bash +php artisan test --compact --filter=StatisticsAuditProbeTest +``` + +Endergebnis: **16 bestanden, 30 Assertions, 0,55 s**. + +**Bedeutung:** Die Assertions erwarten das im Audit beanstandete Ist-Verhalten. Ein bestandener Gegenproben-Test bestätigt den Fehlerzustand. Diese Tests dürfen bei einer späteren Behebung nicht unverändert als gewünschte fachliche Spezifikation übernommen werden; die Erwartungen müssen dann auf das im Audit beschriebene Soll umgestellt werden. + +Prüfuhr überwiegend 09.09.2026, 12:00 Uhr; Liefertag-Probe 10.09.2026, 00:01 Uhr. Alle Benutzer, Namen, Konten, Produkte, Bestellungen und Buchungen wurden synthetisch erzeugt. Es wurden keine Kundendaten aus PDFs oder Datenbanken übernommen. + +Methodische Grenzen: + +- A01 ruft den echten User-Controller mit angemeldetem Eigentümer auf. Es handelt sich um eine Prüfung der serverseitigen Zustandsänderung, nicht um einen Browser-/CSRF-End-to-End-Test. Die entsprechenden Routen wurden zusätzlich gelesen. +- A07 belegt die unterschiedliche Aufnahme eines Teammitglieds. Punkte und Euro werden nicht als identische Einheiten verglichen; die Codeprüfung erklärt den fehlenden Beitrag zur zweiten Prognose. +- A09 verwendet die echten privaten Export-Zuordnungsmethoden per Reflection. Der anschließende unveränderte `fputcsv`-Schreibpfad wurde gelesen. Ein Tabellenprogramm wurde nicht mit einer gefährlichen Formel geöffnet. +- A10 misst gezielt die nach dem Vorladen verbleibenden Queries für die Lieferungszähler, nicht die gesamte HTTP-Anfrage. +- A16 stellt den vom Recovery-Pfad erzeugbaren Datenzustand nach. Der Zahlungs-/Webhook-Pfad selbst wurde nicht ausgeführt. +- Kleine Schemaergänzungen im `beforeEach` entsprechen den für diese isolierten Tests benötigten Testfeldern; sie sind keine Migrationen im Repository. Vorhandene Tests ergänzen einzelne fehlende Business-Felder ebenfalls. Die Gegenproben ersetzen keine Prüfung des vollständigen Produktionsschemas. +- Die Query für `start_date` wurde mit einem synthetischen Start am 02.08. angesetzt, um einen SQLite-spezifischen Vergleich von reinen Datumsstrings am ersten Monatstag gegen Datetime-Grenzen nicht mit den untersuchten Bestandsfehlern zu vermischen. +- Jede Gegenprobe leert den Testcache, damit Benutzer-IDs aus einer vorherigen In-memory-Datenbank nicht mit deren Cacheeinträgen wiederverwendet werden. Ein solcher anfänglicher Fixture-Konflikt wurde vor dem finalen Lauf korrigiert. + +### Aufgezeichnete Ergebnisse + +```jsonl +{"case":"A01","paid_deliveries":0,"active":false,"status":6} +{"case":"A02","personal":175,"canonical":100} +{"case":"A03","overview":60,"details":100} +{"case":"A04","source":"snapshot","frozen_total":100,"ranking":200} +{"case":"A05","billed":100,"displayed_abo_count":0} +{"case":"A06","actual":100,"addon":20,"recurring":80} +{"case":"A07","day25_upcoming":50,"level_commission":0} +{"case":"A08","progress_exists":true,"forecast":null} +{"case":"A09","header_count":9,"summary_count":8,"summary":["Summe","1 Eintraege","","",100,300,"",""],"name":"=1+1"} +{"case":"A10","abos":5,"additional_queries":5} +{"case":"A11","expected_first_name":"Alpha","actual_first_name":"Zulu00"} +{"case":"A12","home_badge":1,"linked_details":0} +{"case":"A13","unexecuted_points":50,"forecast_day25":0} +{"case":"A15","booked_net_points":0,"aggregate_points":-100} +{"case":"A16","august_stock":0,"september_stock":1} +{"case":"A17","reference":"2026-01-31","due":"2026-03-02","bucket":"due_1_month"} +``` + +A14 ist ein JavaScript-Befund und folgt separat. A18 verwendet zusätzlich den in A09 dokumentierten unveränderten Formelstring. + +### Vollständiger isolierter Prüfcode + +Nur zur Reproduktion in einer separaten Testkopie. Er ist bewusst hier als Dokumentation abgelegt; im Anwendungsrepository wurde daraus keine PHP-Datei erstellt. + +```php +setLocale('de'); + foreach (['sales_volume_points_KP_sum'=>'float','payline_points_qual_kp'=>'float','qual_user_level_next'=>'text'] as $col=>$type) { if (!Schema::hasColumn('user_businesses',$col)) Schema::table('user_businesses', fn($t)=>$t->{$type}($col)->nullable()); } + URL::resolveMissingNamedRoutesUsing(fn ($name, $parameters = []) => '/'.$name); + if (!Schema::hasColumn('products', 'slug')) Schema::table('products', fn ($t) => $t->string('slug')->nullable()); +}); +afterEach(fn () => Carbon::setTestNow()); +function auditUser(?int $sponsor = null): User { + return User::forceCreate(['email'=>'audit-'.uniqid().'@example.test','password'=>'test','lang'=>'de','admin'=>0,'m_level'=>1,'m_sponsor'=>$sponsor,'active_date'=>'2026-01-01','payment_account'=>'2027-01-01']); +} +function auditVolume(User $u, float $points, int $status = 1, ?ShoppingOrder $order = null, int $month = 9): UserSalesVolume { + return UserSalesVolume::forceCreate(['user_id'=>$u->id,'shopping_order_id'=>$order?->id,'month'=>$month,'year'=>2026,'date'=>"2026-{$month}-01",'status'=>$status,'status_turnover'=>2,'points'=>$points,'total_net'=>$points]); +} +function auditOrder(User $u): ShoppingOrder { + return ShoppingOrder::create(['shopping_user_id'=>0,'auth_user_id'=>$u->id,'member_id'=>$u->id,'country_id'=>1,'language'=>'de','user_shop_id'=>1,'payment_for'=>6,'total'=>100,'subtotal'=>100,'points'=>100,'paid'=>true,'shipped'=>0,'is_abo'=>true,'mode'=>'test']); +} +function auditAbo(User $u, string $date = '2026-09-20', int $points = 50): UserAbo { + $a=UserAbo::create(['user_id'=>$u->id,'member_id'=>$u->id,'shopping_user_id'=>0,'email'=>$u->email,'is_for'=>'ot','payone_userid'=>0,'clearingtype'=>'cc','amount'=>5000,'active'=>true,'status'=>2,'abo_interval'=>20,'start_date'=>'2026-08-02','next_date'=>$date]); + $p=Product::forceCreate(['name'=>'audit-'.uniqid(),'title'=>'Audit','price'=>10,'tax'=>19,'active'=>true,'points'=>$points,'weight'=>100,'slug'=>'audit-'.uniqid()]); + UserAboItem::create(['user_abo_id'=>$a->id,'product_id'=>$p->id,'comp'=>0,'qty'=>1,'status'=>1]); + return $a; +} +function auditRecord(string $case, array $data): void { + file_put_contents('/tmp/statistics-audit-probes.jsonl',json_encode(['case'=>$case]+$data,JSON_UNESCAPED_UNICODE)."\n", FILE_APPEND); +} +it('AUDIT A01 selfservice accepts the hidden admin active toggle before six deliveries', function () { + $u=auditUser(); $a=auditAbo($u); $this->actingAs($u); + app('request')->merge(['action'=>'abo_update_settings','id'=>$a->id,'abo_interval'=>20,'abo_is_active'=>0]); + (new AboController(app(AboRepository::class)))->update('ot',$a->id); + auditRecord('A01',['paid_deliveries'=>$a->getCountPaidOrders(),'active'=>$a->fresh()->active,'status'=>$a->fresh()->status]); + expect($a->fresh()->active)->toBeFalse()->and($a->fresh()->status)->toBe(6); +}); +it('AUDIT A02 personal totals include unassigned and pending points', function () { + $u=auditUser(); auditVolume($u,100); auditVolume($u,50,3); auditVolume($u,25,0); + $p=app(PersonalStatisticsService::class)->monthly($u,9,2026); + $canonical=SalesVolumePoints::totalPointsByUser([$u->id],9,2026)->get($u->id); + auditRecord('A02',['personal'=>$p['totals']['total_points'],'canonical'=>$canonical]); + expect($p['totals']['total_points'])->toBe(175.0)->and($canonical)->toBe(100.0); +}); +it('AUDIT A03 drilldown loses negative balances', function () { + $u=auditUser(); $a=auditUser($u->id); $b=auditUser($u->id); auditVolume($a,100); auditVolume($b,-40,4); + $overview=app(BackofficeDashboardService::class)->overview($u,9,2026); + $detail=app(BackofficeDrilldownService::class)->details($u,0,'total_points',9,2026); + auditRecord('A03',['overview'=>$overview['totals']['total_points'],'details'=>$detail['summary']['total_points']]); + expect($overview['totals']['total_points'])->toBe(60.0)->and($detail['summary']['total_points'])->toBe(100.0); +}); +it('AUDIT A04 snapshot overlay replaces billed historical ranking', function () { + $u=auditUser(); $a=auditUser($u->id); $order=auditOrder($a); $v=auditVolume($a,100,2,$order,8); + $service=app(BackofficeDashboardService::class); $service->storeSnapshot($u,8,2026); + $v->update(['points'=>200]); + $overview=$service->overview($u,8,2026); + auditRecord('A04',['source'=>$overview['_meta']['source'],'frozen_total'=>$overview['totals']['total_points'],'ranking'=>$overview['top_customer_abos']['by_points'][0]['points_actual']]); + expect((float) $overview['totals']['total_points'])->toBe(100.0)->and($overview['top_customer_abos']['by_points'][0]['points_actual'])->toBe(200.0); +}); +it('AUDIT A05 billed ranking uses current subscription count', function () { + $u=auditUser(); $a=auditUser($u->id); $abo=auditAbo($a); $abo->update(['active'=>false,'status'=>4,'cancel_date'=>'2026-09-08']); + auditVolume($a,100,2,auditOrder($a)); + $row=app(BackofficeDashboardService::class)->overview($u,9,2026)['top_customer_abos']['by_points'][0]; + auditRecord('A05',['billed'=>$row['points_actual'],'displayed_abo_count'=>$row['abo_count']]); + expect($row['points_actual'])->toBe(100.0)->and($row['abo_count'])->toBe(0); +}); +it('AUDIT A06 addon allocation survives full reversal of its order', function () { + $u=auditUser(); $a=auditUser($u->id); $order=auditOrder($a); + ShoppingOrderItem::forceCreate(['shopping_order_id'=>$order->id,'product_id'=>0,'qty'=>1,'price'=>20,'points'=>20,'is_abo_addon'=>true]); + auditVolume($a,100,2,$order); auditVolume($a,-100,6,$order); auditVolume($a,100,2,auditOrder($a)); + $row=app(BackofficeDashboardService::class)->overview($u,9,2026)['top_customer_abos']['by_points'][0]; + auditRecord('A06',['actual'=>$row['points_actual'],'addon'=>$row['points_addon'],'recurring'=>$row['points_recurring']]); + expect($row['points_actual'])->toBe(100.0)->and($row['points_addon'])->toBe(20.0)->and($row['points_recurring'])->toBe(80.0); +}); +it('AUDIT A07 two forecast methods disagree about team membership', function () { + $u=auditUser(); $a=auditUser($u->id); $a->forceFill(['m_level'=>null])->save(); auditAbo($a); + UserBusiness::forceCreate(['user_id'=>$u->id,'month'=>9,'year'=>2026,'version'=>1,'qual_user_level'=>['name'=>'Test','paylines'=>1,'qual_kp'=>0,'qual_pp'=>0,'pr_line_1'=>10],'sales_volume_points_KP_sum'=>0]); + $s=app(PaylineForecastService::class); $days=$s->forecastDays($u,0); $level=$s->levelAndCommissionForecast($u,9,2026); + auditRecord('A07',['day25_upcoming'=>$days['days'][3]['upcoming'],'level_commission'=>$level['estimated_commission']]); + expect($days['days'][3]['upcoming'])->toBe(50.0)->and($level['estimated_commission'])->toBe(0.0); +}); +it('AUDIT A08 forecast absent before first monthly qualification', function () { + $u=auditUser(); auditAbo($u); + UserBusiness::forceCreate(['user_id'=>$u->id,'month'=>9,'year'=>2026,'version'=>1,'qual_user_level'=>null,'qual_user_level_next'=>['name'=>'First','paylines'=>1,'qual_kp'=>50,'qual_pp'=>0],'sales_volume_points_KP_sum'=>0]); + $progress=app(LiveCommissionService::class)->levelProgress($u,9,2026); + $forecast=app(PaylineForecastService::class)->levelAndCommissionForecast($u,9,2026); + auditRecord('A08',['progress_exists'=>$progress!==null,'forecast'=>$forecast]); + expect($progress)->not->toBeNull()->and($forecast)->toBeNull(); +}); +it('AUDIT A09 CSV summary has shifted columns and text is unprotected', function () { + $c=app(BackofficeStatisticsController::class); + $headers=(new ReflectionMethod($c,'csvHeaders'))->invoke($c,'new_partners'); + $summary=(new ReflectionMethod($c,'csvSummaryRow'))->invoke($c,['metric'=>'new_partners','summary'=>['count'=>1,'customer_points'=>100,'total_points'=>300]]); + $row=(new ReflectionMethod($c,'csvRow'))->invoke($c,'new_partners',['name'=>'=1+1']); + auditRecord('A09',['header_count'=>count($headers),'summary_count'=>count($summary),'summary'=>$summary,'name'=>$row[0]]); + expect(count($headers))->toBe(9)->and(count($summary))->toBe(8)->and($summary[4])->toBe(100)->and($row[0])->toBe('=1+1'); +}); +it('AUDIT A10 deliveries cause one additional query per subscription', function () { + $u=auditUser(); for($i=0;$i<5;$i++) auditAbo($u); + $abos=UserAbo::with(['user_abo_items.product','shopping_user'])->where('member_id',$u->id)->get(); + DB::enableQueryLog(); DB::flushQueryLog(); + foreach($abos as $abo) $abo->getCountPaidOrders(); + $count=count(DB::getQueryLog()); DB::disableQueryLog(); + auditRecord('A10',['abos'=>5,'additional_queries'=>$count]); + expect($count)->toBe(5); +}); +it('AUDIT A11 top three truncates tied candidates before sorting their names', function () { + $values=[]; $expectedId=null; + for($i=0;$i<26;$i++) { + $u=auditUser(); $account=UserAccount::forceCreate(['first_name'=>$i===25?'Alpha':'Zulu'.sprintf('%02d',$i),'last_name'=>'Audit']); + $u->forceFill(['account_id'=>$account->id])->save(); $values[$u->id]=100; + if($i===25) $expectedId=$u->id; + } + $s=app(TopResultsService::class); $rows=(new ReflectionMethod($s,'rankRows'))->invoke($s,$values,0); + auditRecord('A11',['expected_first_name'=>'Alpha','actual_first_name'=>$rows[0]['first_name']]); + expect(array_column($rows,'user_id'))->not->toContain($expectedId); +}); +it('AUDIT A12 historical new abo badge differs from its linked VIP detail', function () { + $u=auditUser(); $a=auditUser($u->id); $abo=auditAbo($a); $abo->update(['active'=>false,'status'=>4,'cancel_date'=>'2026-09-08']); + $n=app(TeamAboOverviewService::class)->teamKpis($u,8,2026)['team_customer_abos_new']; + $d=app(BackofficeDrilldownService::class)->details($u,0,'team_customer_abos',8,2026,true); + auditRecord('A12',['home_badge'=>$n,'linked_details'=>$d['summary']['abo_count']]); + expect($n)->toBe(1)->and($d['summary']['abo_count'])->toBe(0); +}); +it('AUDIT A13 current-day unexecuted subscriptions disappear from forecast', function () { + $u=auditUser(); Carbon::setTestNow('2026-09-10 00:01:00'); auditAbo($u,'2026-09-10'); + $f=app(PaylineForecastService::class)->personalCustomerAboForecast($u); + auditRecord('A13',['unexecuted_points'=>50,'forecast_day25'=>$f['days'][3]['total']]); + expect($f['days'][3]['total'])->toBe(0.0); +}); + +it('AUDIT A15 shop reversal is included twice in total points', function () { + $u=auditUser(); auditVolume($u,100,2); auditVolume($u,-100,6); + $total=SalesVolumePoints::totalPointsByUser([$u->id],9,2026)->get($u->id); + auditRecord('A15',['booked_net_points'=>0,'aggregate_points'=>$total]); + expect($total)->toBe(-100.0); +}); +it('AUDIT A16 unlogged recovery reactivation leaves historical stock empty', function () { + Carbon::setTestNow('2026-07-02 12:00:00'); + $u=auditUser(); $a=auditAbo($u); $a->update(['start_date'=>'2026-07-02']); + Carbon::setTestNow('2026-07-25 12:00:00'); + $a->update(['active'=>false,'status'=>6]); + \App\Models\UserAboStatusHistory::forceCreate(['user_abo_id'=>$a->id,'action'=>'deactivated']); + // Reproduce persisted state written by AboHelper recovery, without executing payment code. + Carbon::setTestNow('2026-08-15 12:00:00'); + $a->update(['active'=>true,'status'=>2]); + Carbon::setTestNow('2026-09-09 12:00:00'); + $service=app(TeamAboOverviewService::class); + $historical=$service->aboStockCount([$u->id],'member_id','ot',8,2026); + $current=$service->aboStockCount([$u->id],'member_id','ot',9,2026); + auditRecord('A16',['august_stock'=>$historical,'september_stock'=>$current]); + expect($historical)->toBe(0)->and($current)->toBe(1); +}); +it('AUDIT A17 month overflow includes March in January one month bucket', function () { + $service=app(\App\Services\Team\AnnualFeeDueService::class); + $key=(new ReflectionMethod($service,'groupKeyFor'))->invoke($service,Carbon::parse('2026-03-02'),Carbon::parse('2026-01-31')); + auditRecord('A17',['reference'=>'2026-01-31','due'=>'2026-03-02','bucket'=>$key]); + expect($key)->toBe(\App\Services\Team\AnnualFeeDueService::GROUP_DUE_1_MONTH); +}); +``` + +## 4. Gegenprobe der JavaScript-Sortierung (A14) + +Die Funktion wurde direkt aus der bestehenden Blade-Datei extrahiert und mit einem minimalen Ersatz für `$.trim` ausgeführt. Sie wurde nicht umgeschrieben: + +```javascript +const fs = require('fs'); +const source = fs.readFileSync( + 'resources/views/components/_sortable_table.blade.php', 'utf8' +); +const start = source.indexOf(' function getSortValue('); +const fn = source.slice(start, source.indexOf('\n });', start)); +const $ = { trim: value => value.trim() }; +eval(fn); +const results = ['2026-09-20', '2026-08-05'].map(value => ({ + value, type: 'date', parsed: getSortValue(value, 'date') +})).concat(['999.00', '1,000.00'].map(value => ({ + value, type: 'number', parsed: getSortValue(value, 'number') +}))); +console.log(JSON.stringify(results)); +``` + +Tatsächliche Ausgabe: + +```json +[ + { + "value": "2026-09-20", + "type": "date", + "parsed": 0 + }, + { + "value": "2026-08-05", + "type": "date", + "parsed": 0 + }, + { + "value": "999.00", + "type": "number", + "parsed": 99900 + }, + { + "value": "1,000.00", + "type": "number", + "parsed": 1 + } +] +``` + +Damit ist der Parserfehler nachgewiesen. Nicht durchgeführt wurde ein vollständiger Browser-Test von DOM-Ereignissen, Tastaturbedienung oder sämtlichen eingebundenen Tabellen. + +## 5. Geprüfte Implementierungsbereiche + +Die Kernservices wurden inhaltlich durchgelesen und mit den aufrufenden Controllern, Datenmodellen, Templates und Tests abgeglichen. Bei großen, übergreifenden Legacy-Dateien wurden die einschlägigen Methoden und Schreib-/Lesepfade geprüft; die Auflistung behauptet keinen vollständigen Audit aller übrigen Funktionen dieser Dateien. + +### Berechnung und Datenzugriff + +- `app/Services/Backoffice/BackofficeDashboardService.php`: Monatsübersicht, Baumaufbau, aktive Berater, Snapshots, Bestandsüberlagerung, Punkteaggregate, Neupartner, Führungskräfte, Abo-Toplisten und Recruiter-Ketten. +- `app/Services/Backoffice/BackofficeDrilldownService.php`: Mitgliederauswahl, Detailzeilen, Punktegruppen, Abo-Listen, Telefon-/Namensfelder und Summen. +- `app/Services/Backoffice/PersonalStatisticsService.php` und `SalesVolumePoints.php`: persönliche Gruppierung und zentrale SQL-Prädikate. +- `app/Services/Abo/TeamAboOverviewService.php`: Monatsendbestände, Team-/Eigenkennzahlen, Wachstum, Abo-Zeilen und Ausführungsdaten. +- `app/Services/Abo/PaylineForecastService.php`: Kontext/Cache, Payline-Filter, Abo-Soll/Ist, allgemeine und persönliche Terminvorschau, Level-/Provisionsprognose. +- `app/Services/BusinessPlan/LiveCommissionService.php`: gespeicherte/live Werte, Bestandteile, Qualifikationsdaten, Cache und Fehlerpfad. +- `app/Services/Team/TopResultsService.php`, `AnnualFeeDueService.php`: Ranglisten, Fotos, Fälligkeitsgruppen und Teamumfang. +- `app/Services/BusinessPlan/SalesPointsVolume.php`, `BusinessUserItemOptimized.php`: relevante Status-/Stornobehandlung und Provisionsbaumfilter. + +### Abo-Lebenszyklus, Modelle und Persistenz + +- `app/Repositories/AboRepository.php`: Statusänderung, Pause, Kündigung, Mindestdauer, Sperrfristen, administrative Ausnahme und Protokollierung. +- `app/Services/AboHelper.php`: Zähl-/Berechtigungsregeln, Ein-Abo-Prüfung, Pausenfähigkeit, Datumslogik und Recovery-Fortschreibung. +- `app/User.php`, `app/Models/UserAbo.php`, `UserAboOrder.php`, `UserAboStatusHistory.php`, `UserAccount.php`, `UserBusiness.php`, `UserSalesVolume.php`: relevante Beziehungen, Scopes, Attribute und Statusinterpretation. +- `app/Models/BackofficeStatisticsSnapshot.php`, `app/Console/Commands/BackofficeStoreStatisticsSnapshots.php`, `AboStoreChartSnapshots.php`, `app/Console/Kernel.php`, `app/Providers/AppServiceProvider.php`: Speicherung, Zeitplanung und Lebensdauer der Services. +- Migrationen für Statistik-Snapshots, Abo-Statushistorie und Profilfoto; kein Ausführen gegen die Arbeitsdatenbank. + +### Controller, Ansichten, Routen und angrenzende Änderungen + +- `app/Http/Controllers/HomeController.php`, `User/BackofficeStatisticsController.php`, `User/TeamController.php`, `User/AboController.php`, `Portal/AboController.php`: relevante Dashboard-/Listen-/Export-/Abo-Aktionen. +- `routes/domains/crm.php`, `routes/domains/portal.php`, relevante gemeinsame Routen sowie `config/domains.php` und `config/subdomain.php`: Zugangs- und Hostanbindung. +- `resources/views/user/backoffice/statistics/index.blade.php`, `details.blade.php`, `my.blade.php`. +- `resources/views/dashboard/_statistics.blade.php`, `_points.blade.php`, `_top_results.blade.php` und einschlägige Teamansichten `firstlines`, `annual-fee`, `abos`, `points`. +- `resources/views/user/abo/_customer_abo_list.blade.php`, Abo-Einstellungen und `resources/views/components/_sortable_table.blade.php`. +- `app/Http/Controllers/ProfileImageController.php`, `app/Services/Slim.php`, Bild-URL/-Löschung und Einbindung in den vorhandenen Löschprozess. +- Herkunftsfeld und Validierung im Checkout, Steueroptionen in `app/Services/HTMLHelper.php`, Versandsteuer in `app/Services/Yard.php` und zugehörige Tests. +- Relevante Incentive-Rangfolge, Teilnehmerordnung und Sponsor-/Punkte-Reparaturen anhand des Feedbacks und vorhandener Tests; kein vollständiger Audit sämtlicher Incentive- oder Provisionsfunktionen. + +## 6. Abnahmeplanung für spätere Korrekturen + +Die Befunde sollten als getrennte, nachvollziehbare Änderungen umgesetzt werden. Eine geeignete Reihenfolge der Regressionen ist: + +1. Serverseitige Geschäftsregeln gegen manipulierte/fehlende Formularfelder (A01). +2. Disjunkte Status-/Stornokategorien und identische Netto-Summen in Übersicht, Detail und Export (A15, A02, A03, A06). +3. Zeitbezug: Snapshot, Korrekturbuchung, Monatswechsel, Statushistorie und Teamwechsel (A04, A12, A16). +4. Ein konsistenter Payline-Kontext und gültige Prognosen ohne bereits erreichtes Monatslevel, am Ausführungstag und bei Fehlerzuständen (A07, A08, A13, A19, A23). +5. CSV-Spaltenschema/Formeltexte, lokalisierte Sortierung, Ranggleichstände und Fälligkeitsgrenzen (A09, A11, A14, A17, A18, A21). +6. Query-/Speicherbudgets und Zugriff auf Profilfotos (A10, A20, A22). + +Die offenen K10–K15 sollten separat geplant werden. Ihre Umsetzung ist keine Voraussetzung dafür, die belegten Fehler der bereits gelieferten Funktionen zu beheben. Produktionsnachberechnungen, umfassende Cache-Löschungen und Kundenkommunikation sind aus diesem Audit nicht ausgeführt worden. + +## 7. Integrität vorhandener Dateien + +Der Abschlussvergleich der vorab erfassten Datei-Prüfsummen ergab: + +```text +Vorhandene Dateien in der Prüfbasis: 10385 +Abweichende oder fehlende Dateien: 0 +``` + +Die Prüfbasis umfasst reguläre, getrackte und nicht ignorierte ungetrackte Dateien, einschließlich bereits vorhandener Dokumente und Implementierungsänderungen. Ignorierte Laufzeitdateien wurden nicht mit dieser Aussage gleichgesetzt; insbesondere wurde die Anwendungstest-Runtime deshalb separat gehalten. + +Neu angelegt wurden ausschließlich: + +- `dev/2026-07-22/audit-statistiken-2026-09-09.md` +- `dev/2026-07-22/audit-statistiken-2026-09-09-pruefprotokoll.md` + +Es wurde weder ein Commit erstellt noch eine bestehende Datei korrigiert, formatiert oder überschrieben. + +## 8. Fingerabdrücke zentraler geprüfter Dateien + +Da der Arbeitsstand nicht vollständig eingecheckt ist, identifizieren diese SHA-256-Werte die tatsächlich geprüften Fassungen zusätzlich zum Referenz-Commit. Sie ersetzen keine Quellcode-Sicherung. + +| Datei | SHA-256 | +| --- | --- | +| `app/Services/Backoffice/BackofficeDashboardService.php` | `2867ed5c2af5d3369587f55e6516f56f73e9024133ab3eab537f115c8ae7a914` | +| `app/Services/Backoffice/BackofficeDrilldownService.php` | `7bc5be4c19b5e9fbc2502b77ece6ba65f5d1296c951f127f59ecd60b2e8325c8` | +| `app/Services/Backoffice/PersonalStatisticsService.php` | `d1ac5253ec0a5725115df8a442d0edd9638d4c6f234156b908fd9ef3df689607` | +| `app/Services/Backoffice/SalesVolumePoints.php` | `4266fe107dde4fd4521a4e80411c432f2b3cdcc9c50158bca0ee46bc12af699d` | +| `app/Services/Abo/TeamAboOverviewService.php` | `59d08a6c3e4eda3b01fe220283c2ddf8f5af83cf4f4b6e7551df8561ed74d03d` | +| `app/Services/Abo/PaylineForecastService.php` | `f63db7453f06c81f2167266923810a9c95f235365c27ec69c6384296b991e492` | +| `app/Services/BusinessPlan/LiveCommissionService.php` | `ca0ce2549b3baaeaa9af98316b461fd37ad2d7ff6471a70ce9e07ba43d67c260` | +| `app/Services/BusinessPlan/SalesPointsVolume.php` | `036e0acbcb281c06602e20334220038082e0e7214e20171d184aca00ff7699f7` | +| `app/Services/Team/TopResultsService.php` | `cbb4d7598b2778bf2f5db85cd931ff1315d01848fbd22c611b1be1332c11feb1` | +| `app/Services/Team/AnnualFeeDueService.php` | `c8f0c831ac4bd6089e145af484f4b0ccf3c4d138c6319daff95b0a69e6bb084d` | +| `app/Repositories/AboRepository.php` | `ff335e21ac6dfc77b7dbd60cb273126924b3671ae5c994ad44cfcbe0d58119e6` | +| `app/Services/AboHelper.php` | `452b7d44449ea4b307f7f61654cf90408a8ae01b320a5bb1bd3de7a9a929ac9e` | +| `app/Http/Controllers/User/BackofficeStatisticsController.php` | `3ee2aecb9206c7e6be1a68c77c14fe5887ce260dd4eff686c315765a126ae43c` | +| `app/Http/Controllers/User/AboController.php` | `a5b0a804b014ae330e918da36d573d5ee5efdbc226fca628091f6277240f0e1a` | +| `resources/views/components/_sortable_table.blade.php` | `266f501ee613718e940de430bbfde92e727c7c6ca5aa8ab22bd3312251ae770b` | +| `resources/views/user/abo/_customer_abo_list.blade.php` | `e3196841806651fc6282ab06e634d9f83c891fe6f54cf4f414b5b5aaf3164253` | +| `resources/views/user/backoffice/statistics/index.blade.php` | `60a71fd73304753cfe846b9c1cab88e344606846a6311e73477266ebd1e439c7` | +| `app/Console/Commands/BackofficeStoreStatisticsSnapshots.php` | `48d2649454305e0ff8466804e15fda0c78a6e7b8bc327b9539654ea49e69389c` | diff --git a/dev/2026-07-22/audit-statistiken-2026-09-09.md b/dev/2026-07-22/audit-statistiken-2026-09-09.md new file mode 100644 index 00000000..ca153c7f --- /dev/null +++ b/dev/2026-07-22/audit-statistiken-2026-09-09.md @@ -0,0 +1,383 @@ +# Audit der Statistik- und Auswertungsmodule — 09.09.2026 + +Prüfgegenstand: Entwicklungspläne und Rückmeldungen in diesem Ordner einschließlich der zugehörigen Implementierung im aktuellen Arbeitsverzeichnis. Referenz-Commit: `384b2dcff1533168d4a122f4c2e93a5e0336bd75`. Der Arbeitsstand enthält bereits zahlreiche nicht eingecheckte Änderungen; diese wurden ausdrücklich mitgeprüft. Der Bericht bewertet daher **den Arbeitsstand, nicht ausschließlich diesen Commit und nicht den unbekannten Produktionsstand**. + +Es wurden ausschließlich neue Audit-Dokumente angelegt. Anwendungscode, vorhandene Dokumente und Konfiguration wurden nicht geändert. Tests und zusätzliche Gegenproben liefen in einer isolierten Kopie mit SQLite im Arbeitsspeicher. Es wurden keine produktiven Datenbankabfragen, Zahlungen, Nachrichten, Migrationen, Cache-Löschungen oder Snapshot-Neuberechnungen ausgeführt. + +Die vollständigen synthetischen Gegenproben, Testergebnisse und Prüfgrenzen stehen im [Prüfprotokoll](audit-statistiken-2026-09-09-pruefprotokoll.md). + +## 1. Ergebnis und Prioritäten + +Die geplanten Ansichten und Berechnungsbausteine sind überwiegend vorhanden. Die Aussage „alles umgesetzt und Zahlen korrekt“ ist für den geprüften Stand jedoch nicht belastbar. Es bestehen insbesondere Fehler bei Abo-Regeln, Stornos, historischen Auswertungen und dem Abgleich zwischen Übersicht und Detailansicht. + +Der Bericht enthält **23 nummerierte Befunde**. Darunter sind nachgestellte Fehler, aus dem Code belegte Risiken sowie ausdrücklich gekennzeichnete Robustheits- und Optimierungsthemen. Die Nummerierung ist keine Reihenfolge der Wichtigkeit. + +- **P1:** zuerst beheben bzw. fachlich absichern; betrifft eine verbindliche Geschäftsregel oder die gemeinsame Punktebasis. +- **P2:** reguläre Fehlerbehebung mit Regressionstest; fehlerhafte Auswertung, Export, Zugriffseigenschaft oder relevante Betriebsstabilität. +- **P3:** Randfall, kleinere Umsetzungslücke oder Optimierung. + +| ID | Priorität | Befund | Nachweis | +| --- | --- | --- | --- | +| A01 | P1 | Abo-Mindestlaufzeit und Pausenregeln über Aktiv-Feld umgehbar | Controller-Gegenprobe | +| A02 | P2 | Persönliche Statistik zählt nicht freigegebene Buchungsstatus mit | Service-Gegenprobe | +| A03 | P2 | Negative Salden fehlen in Punkte-Details und deren CSV | Service-Gegenprobe | +| A04 | P2 | Historischer Snapshot wird mit neu berechneten Monatswerten vermischt | Snapshot-Gegenprobe und Code | +| A05 | P2 | Umsatzrangliste zeigt aktuelle statt abgerechnete Abo-Anzahl | Service-Gegenprobe | +| A06 | P2 | Einmal-Zusätze werden bei Storno nicht korrekt zurückgenommen | Service-Gegenprobe | +| A07 | P2 | Zwei Payline-Prognosen verwenden unterschiedliche Mitgliedermengen | Service-Gegenprobe | +| A08 | P2 | Prognose fehlt vor der ersten Monatsqualifikation | Service-Gegenprobe | +| A09 | P2 | Summenzeile im Neupartner-CSV um eine Spalte verschoben | Exportmethoden-Gegenprobe | +| A10 | P3 | Kundenabo-Liste erzeugt zusätzliche Abfrage je Abo | Gemessene SQL-Anzahl | +| A11 | P3 | Top 3 schneidet Gleichstände vor der Namenssortierung ab | Service-Gegenprobe | +| A12 | P2 | Historisches „+N neu“ führt zu aktueller, abweichender Detailliste | Service-Gegenprobe und Linkprüfung | +| A13 | P2 | Noch nicht ausgeführte Abos verschwinden am Liefertag aus der Prognose | Gegenprobe; Robustheitslücke | +| A14 | P2 | Tabellensortierung verarbeitet englische Datums-/Zahlenformate falsch | Ausgeführte Original-JS-Funktion | +| A15 | P1 | Shop-Stornos können doppelt in Gesamtpunkten abgezogen werden | SQL-Gegenprobe und Buchungscode | +| A16 | P2 | Historischer Abo-Bestand ist auch ab Juli nicht durchgehend exakt | Zustands-Gegenprobe und Schreibpfad | +| A17 | P3 | Monatsüberlauf verschiebt Jahresgebühr-Gruppierung | Carbon-/Service-Gegenprobe | +| A18 | P2 | CSV übernimmt potenzielle Tabellenformeln unverändert | Exportmethoden-Gegenprobe und Code | +| A19 | P2 | Gescheiterte Provisionsberechnung wird als Nullbetrag angezeigt | Durchgängige Codeprüfung | +| A20 | P2 | „Teaminternes“ Profilfoto erhält öffentliche Storage-URL | Architektur-/Konfigurationsprüfung | +| A21 | P3 | Mehrsprachigkeit der neuen Ansichten unvollständig | Templates, Services, Sprachtests | +| A22 | P2 | Snapshot-Batch hält alle bereits berechneten Teambäume im Speicher | Lebensdauer-/Referenzanalyse | +| A23 | P2 | Gemischte Cache-Zeitstände können Prognosen verfälschen | Datenflussanalyse; Betriebsrisiko | + +**Sinnvolle erste Reihenfolge:** A01 → A15 → A02/A03/A06 → A04/A12/A16 → A07/A08/A13/A19/A23 → Exporte/Zugriff/weitere Befunde. A15 betrifft bereits die gemeinsame Buchungslogik; eine ausschließlich kosmetische Statistikänderung wäre hier unzureichend. + +## 2. Unterlagen, Sollstand und Abdeckung + +Gelesen wurden alle neun vorhandenen Markdown-Dokumente dieses Ordners, alle drei PDFs sowie der Screenshot vom 25.08. Die PDFs wurden einschließlich der Seiten mit Bildinhalten geprüft. Personenbezogene Beispieldaten daraus werden hier nicht wiederholt. + +| Unterlage | Rolle im Audit | +| --- | --- | +| [todos.md](todos.md), [entwicklungskonzept.md](entwicklungskonzept.md) | Juli-Grundanforderungen A1/A2 und B1–B10 sowie dokumentierte Entscheidungen | +| [Statistiken kontrolliert.pdf](), [feedback-analyse-2026-07-24.md](feedback-analyse-2026-07-24.md) | Korrekturen zu Zählung, Ranglisten, Führungskräften, Incentive, Versand und Darstellung | +| [feedback-an-kundin-statistiken.md](feedback-an-kundin-statistiken.md), [neuerungen-fuer-berater.md](neuerungen-fuer-berater.md) | Nach außen zugesagte Funktionen und Erklärungen | +| [Statistik-Aenderungen-19-08-2026.pdf](Statistik-Aenderungen-19-08-2026.pdf), [entwicklungsplan-2026-08-19.md](entwicklungsplan-2026-08-19.md) | August-Pakete N1–N8 und nachträgliche Review-Ergebnisse | +| [feedback-an-kundin-2026-08-25.md](feedback-an-kundin-2026-08-25.md), [Screenshot]() | Erläuterungen, Abgrenzung Incentive/Bestand, offene Detailfragen | +| [mivita_Statistiken_07_09_2026.pdf](mivita_Statistiken_07_09_2026.pdf), [entwicklungsplan-2026-09-07.md](entwicklungsplan-2026-09-07.md), [feedback-an-kundin-2026-09-07.md](feedback-an-kundin-2026-09-07.md) | Aktuellster Sollstand K1–K9, ausdrücklich offene K10–K15 | + +Bei widersprüchlichen Versionen wurden spätere dokumentierte Entscheidungen berücksichtigt: Führungskräfte umfassen die ganze Downline; im September entfallen die Direkt-Spalten. Kundenabo-Umsatzranglisten richten sich nach abgerechneten Punkten. Home zeigt historische Monatsendbestände; VIP-Abo-Bestände bleiben nach dokumentierter Entscheidung aktuell. + +| Paket | Implementierung und Bewertung | +| --- | --- | +| Juli A1/A2: Pause/Kündigung | Eigene Aktionen, Mindestlieferungen, Sperrfrist, externe Erledigungen, Historie und neues Abo nach Kündigung vorhanden. Kritischer Nebenweg A01; unvollständige Historie A16. | +| B1: Provision Stand heute | Live-Service, gespeicherte Monatsabrechnung, PP/Shop/Wachstum und Cache vorhanden. Fehlerzustand A19; gemeinsame Punktebasis A15. | +| B2: Monatsstatistik/Snapshots | Speicherung und Live-Bestandsüberlagerung vorhanden. Monatswerte/Details nicht konsistent eingefroren: A04, A12. | +| B3: Neupartner | Sponsor, Kunden-/Gesamtpunkte und spätere Telefonergänzung vorhanden. A09, A18; gemeinsame Punktebasis A15. | +| B4: 1000-Punkte-Shop | Eigenberücksichtigung vorhanden; Schwellenfilter grundsätzlich sachgerecht. A15 kann die Basis beeinflussen. | +| B5/B6: Top-10 Kundenabos | Mengen- und Punkterangliste, Eigenberücksichtigung und Ist-Sortierung vorhanden. A05/A06. | +| B7: Führungskräfte | Gesamte Downline und deduplizierte Kopfsummen vorhanden; Direkt-Spalten nach September-Anforderung entfernt. Skalierung siehe O2. | +| B8/B10: Payline-Abos/Terminvorschau | Soll/Ist, Anzahl und vier kumulierte Termine vorhanden. A07/A08/A13/A23. | +| B9: neue Abos öffnen | `only_new` und Links vorhanden. Historischer Linkkontext A12. | +| Juli-Nachbesserungen | Punkte-/Strukturabgrenzung, Abo-Kennzeichnung im Baum, Incentive-Rangfolge und Versandsteuer im jeweiligen Teil geprüft. Keine zusätzliche bestätigte Abweichung bei Ranking-/Versandkorrektur; kein vollständiger Payment-Audit. | +| August N1: Firstlines/Qualifikation | Kacheln, Liste und monatlicher Fortschritt vorhanden. Nicht mit dauerhaftem Karriere-Level gleichzusetzen; A08 im angrenzenden Prognosepfad. | +| N2: historische Home-Abos | Monatsend-Rückrechnung vorhanden. A12/A16 und heutige Teamzuordnung begrenzen die historische Aussage. | +| N3: Recruiter | Rekursive Neupartner-Kette, Starterpaketpunkte und Details vorhanden; September-Kopfsumme dedupliziert. A15/O2. | +| N4: Level-/Provisionsprognose | Vorhanden und als Schätzung zu behandeln; A07/A08/A13/A23. | +| N5: Checkout-Herkunft | Freitext und serverseitige Pflichtvalidierung vorhanden; sprachliche Lücke A21. | +| N6: Top 3/Profilfoto | Team einschließlich Betrachter, drei Kategorien, Foto/Initialen, Einwilligungsprüfung und Löschpfad vorhanden. A11/A20/A23; Scope-Vorbereitung siehe D3. | +| N7: Steueroptionen | Reihenfolge geändert, zugrunde liegende Werte erhalten. Keine bestätigte funktionale Abweichung in diesem Teil. | +| N8: Jahresgebühr | Eigene Teamliste für Berater, Kontakte und vier Fälligkeitsgruppen vorhanden. A17; angekündigter Export fehlt, siehe D4. | +| September K1 | Drei Punktespalten vorhanden; Stornoaufteilung A06. | +| K2/K3/K4 | Direkt-Spalten entfernt, Prognosebasis beschriftet, Abo-Summe ergänzt. Keine zusätzliche Abweichung in diesen Darstellungsänderungen. | +| K5 | Abo-Bestellungen farblich abgesetzt, einschließlich angrenzender Punkteansichten im Arbeitsstand; vorhandene Tests bestanden. | +| K6 | Telefonnummer mit Mobil-Fallback und verständlichere Punktespalten vorhanden; CSV-Summe A09. | +| K7 | Recruiter-Kopfsumme dedupliziert vorhanden. Überlappende Zeilenvolumina sind fachlich beabsichtigt. | +| K8 | Such-/sortierbare Kundenabo-Tabelle vorhanden. A10/A14. | +| K9 | Persönliche Kundenabo-Vorschau vorhanden; ausdrücklich ohne Eigenabo und mit Basis 0. A13. | +| K10–K15 | Laut jüngstem Plan bewusst offen. Kein Implementierungsfehler wegen Nichterfüllung dieser Pakete angesetzt. | + +## 3. Befunde mit Nachweis und Behebungskriterien + +### A01 — P1: Abo-Regeln über `abo_is_active` umgehbar + +**Stellen:** `app/Repositories/AboRepository.php:122–151`, `app/Http/Controllers/User/AboController.php` (`update`), Abo-Einstellungsformular. + +Die regulären Pause-/Kündigungsaktionen prüfen Mindestlieferungen und Pausenabstand. Daneben übernimmt `updateStatus()` das übergebene Aktiv-Feld auch bei Selbstbedienung. Dieser Zweig prüft bei Deaktivierung lediglich die Sperrfrist vor der nächsten Ausführung. Die Tatsache, dass der sichtbare Schalter im Formular nur administrativ angeboten wird, schützt die serverseitige Verarbeitung nicht. Auch ein fehlendes Feld wird als `false` interpretiert. + +**Nachgestellt:** Eigentümer eines Kundenabos, null bezahlte Lieferungen, nächste Ausführung 20.09., Prüfdatum 09.09.; regulärer User-Controller mit `action=abo_update_settings`, unverändertem Intervall und `abo_is_active=0`. Ergebnis: `active=false`, Status 6. Die Mindestlaufzeit kann so durch dauerhafte Deaktivierung umgangen werden; der separate Ein-Zyklus-Pausenpfad wird nicht benutzt. Fremdzugriff auf ein anderes Konto wurde damit nicht nachgewiesen. + +**Behebung:** Aktiv-Toggle im Selbstbedienungspfad serverseitig zurückweisen oder ignorieren; Zustandswechsel ausschließlich über die dafür zugelassenen Aktionen. Administrative Ausnahme nur aus vertrauenswürdigem Controller-Kontext. **Abnahme:** Owner-Requests für `me`, `ot`, Portal, fehlendes Feld, `0`, wiederholte De-/Reaktivierung und legitimen Admin-Pfad; Mindestlieferungen und Pausenabstand müssen unabhängig vom Formular gelten. + +### A02 — P2: Persönliche Monatsstatistik summiert Status 0 und 3 + +**Stellen:** `app/Services/Backoffice/PersonalStatisticsService.php:29–57,68–81`; Vergleich `app/Services/Backoffice/SalesVolumePoints.php:20–34`. + +Die persönliche Auswertung lädt sämtliche Monatsbuchungen des Benutzers und summiert deren Punkte und Nettowerte. Die gemeinsame Punkteauswertung schließt Status 0/nicht zugeordnet und 3/pending ausdrücklich aus. Bei einem einem Benutzer zugeordneten Pending-/Importdatensatz entstehen unterschiedliche Gesamtwerte. + +**Nachgestellt:** 100 gültige + 50 Pending- + 25 Status-0-Punkte ergeben persönlich 175 statt 100. Stornos landen zudem pauschal in „Sonstige“, statt das zugehörige Abo-/Shop-Segment zu mindern. + +**Behebung:** Gemeinsame Definition für zählbare Buchungen und eine vorzeichengetreue Gruppierung anhand der Ursprungsbuchung. Offene Buchungen gegebenenfalls separat anzeigen. **Abnahme:** Alle relevanten Status, nicht zugeordnete Zeilen mit/ohne `user_id`, Teil-/Vollstorno und Summengleichheit zu bereinigter gemeinsamer Punktebasis. + +### A03 — P2: Negative Salden fehlen im Drilldown + +**Stelle:** `app/Services/Backoffice/BackofficeDrilldownService.php:284–321`. + +Die Detailabfragen verwenden für Punktekennzahlen `HAVING ... > 0`. Damit verschwinden Berater mit negativem Monatssaldo, obwohl deren Beträge in der Übersicht enthalten sind. Die CSV-Ausgabe übernimmt dieselbe gefilterte Liste. + +**Nachgestellt:** Zwei Teammitglieder mit +100 und −40 Punkten: Übersicht 60, Detail-Summe 100. Ein reiner Stornomonat kann eine leere Liste trotz negativem Übersichtswert erzeugen. + +**Behebung:** Negative, relevante Salden einschließen; nur für echte Qualifikationslisten wie „mindestens 1000 Punkte“ den Schwellenfilter behalten. Auch die Verlinkung negativer Werte in der Übersicht prüfen. **Abnahme:** Übersicht = Detail = CSV für positive, negative und saldierte Monatswerte. + +### A04 — P2: Snapshot friert nur einen Teil der Monatsauswertung ein + +**Stellen:** `app/Services/Backoffice/BackofficeDashboardService.php:56–70,162–224,447–456`; `app/Services/Backoffice/BackofficeDrilldownService.php:19–45`. + +Beim Lesen eines gespeicherten Monats überschreibt `overlayCurrentAboStock()` die gesamte Kundenabo-Topliste. Diese enthält nicht nur den ausdrücklich aktuellen Bestand, sondern auch abgerechnete Monatswerte und deren Aufteilung. Dadurch wird die fachlich erlaubte Bestandsüberlagerung auf eingefrorene Umsatzwerte ausgedehnt. + +**Nachgestellt:** August-Snapshot mit 100 Punkten speichern, zugrunde liegende Buchung auf 200 korrigieren, August wieder öffnen: `_meta.source=snapshot`, Hauptsumme 100, abgerechnete Ranglistenpunkte 200. + +Zusätzlich lesen Detaillisten Monatsbuchungen und Teamzuordnung grundsätzlich neu; sie verwenden weder den gespeicherten Zahlenstand noch dessen Mitgliedermenge. Nach Korrektur, Sponsorwechsel oder Löschung kann ein historischer Klick andere Ergebnisse liefern. Auch „neue Abos“ werden beim erstmaligen Snapshot-Bau über den heutigen Aktivbestand ermittelt. Eine Kündigung zwischen Monatsende und Speicherung kann den vergangenen Monat bereits beim Einfrieren verfälschen. + +**Behebung:** Aktuelle Bestandsfelder und historische Monatswerte getrennt halten. Für historische Details denselben Auswertungsstand bereitstellen oder ausdrücklich einen getrennten aktuellen Korrekturstand ausweisen. Payload-Version, Berechnungszeitpunkt und Korrekturversion speichern. **Abnahme:** Snapshot vor/nach Punkteänderung, Kündigung und Sponsorwechsel; nur ausdrücklich aktuelle Bestandsfelder dürfen sich verändern. Keine pauschale erzwungene Neuberechnung historischer Werte als Ersatz für diese Trennung. + +### A05 — P2: „Abgerechnete“ Kundenabos werden mit aktuellem Bestand gezählt + +**Stellen:** `app/Services/Backoffice/BackofficeDashboardService.php:732–779,1052`; Umsatzrangliste im Statistik-Template. + +Die Umsatzliste sortiert richtig nach `points_actual`, bezieht `abo_count` aber aus heute aktiven Kundenabos. Das beantwortet nicht den Kundenwunsch, mit wie vielen **abgerechneten** Abos die Punkte entstanden sind. Die Filterung lässt außerdem Zeilen mit positivem Soll und null Ist in die Umsatzliste, solange Platz vorhanden ist. + +**Nachgestellt:** Ein inzwischen gekündigtes Kundenabo hat im gewählten Monat 100 Punkte erzeugt. Ranglistenanzeige: 100 abgerechnete Punkte, 0 Abos. Umgekehrt erhöhen neue, noch nicht abgerechnete Abos die angezeigte Anzahl. + +**Behebung:** Abgerechnete, unterschiedliche Abo-IDs aus Monatsbestellungen/Verknüpfungen ermitteln; aktuellen Bestand separat beschriften. Storno-/Teilstorno-Regel für diese Anzahl festlegen. **Abnahme:** Kündigung nach Abrechnung, Neuabo vor Erstabbuchung, mehrere Ausführungen desselben Abos, vollständig stornierte Ausführung. + +### A06 — P2: Einmal-Zusätze bleiben nach Vollstorno stehen + +**Stellen:** `app/Services/Backoffice/BackofficeDashboardService.php:748–765,851–883`. + +Für Zusätze werden unterschiedliche Benutzer-/Bestellungs-Paare gesammelt und anschließend die ursprünglichen positiven Positionspunkte addiert. Vorzeichen und Nettowirkung der Monatsbuchungen gehen dabei verloren. Das abschließende Begrenzen auf die Gesamtpunkte garantiert lediglich eine rechnerisch passende Summe, keine fachlich richtige Aufteilung. + +**Nachgestellt:** Bestellung 100 mit 20 Zusatzpunkten, vollständig mit −100 storniert; weitere reine Abo-Bestellung 100. Ausgabe: Gesamt 100, Zusatz 20, wiederkehrend 80. Erwartet: Gesamt 100, Zusatz 0, wiederkehrend 100. Bei negativem Gesamtwert kann die Begrenzung zudem einen negativen Zusatzanteil unabhängig von dessen tatsächlicher Herkunft erzeugen. + +**Behebung:** Vorzeichen und Stornoanteile pro Bestellung berücksichtigen, erst anschließend je Berater summieren. **Abnahme:** Voll-/Teilstorno, Storno im Folgemonat, mehrere Zuordnungen, Mengen größer 1 und Buchungen ohne rekonstruierbare Positionsdaten; für letztere einen nachvollziehbaren Restposten verwenden. + +### A07 — P2: Unterschiedliche Teamfilter innerhalb der Payline-Prognosen + +**Stellen:** `app/Services/Abo/PaylineForecastService.php:335–376,500–505`; Vergleich `app/Services/BusinessPlan/BusinessUserItemOptimized.php:1068`. + +Die Terminvorschau übernimmt alle Mitglieder der ersten N Ebenen aus `lineBuckets()`. Die Level-/Provisionsvorschau entfernt dagegen System-/Adminkonten, Benutzer ohne Mitgliedsebene/Abrechnungskonto/Aktivierungsdatum und deren Unterbäume entsprechend dem Provisionsbaum. + +**Nachgestellt:** Ein Mitglied ohne `m_level`, aber mit aktivem 50-Punkte-Kundenabo, liefert 50 offene Punkte in der Terminvorschau und keinen Beitrag zur Level-/Provisionsvorschau. Die unterschiedliche Berücksichtigung liegt im Mitgliederscope, nicht im Provisionsprozentsatz. + +**Behebung:** Einen gemeinsamen, expliziten Provisionsbaum-Kontext für dieselbe Payline-Sicht verwenden. Das ist von der noch offenen generellen Umstellung „Statistik = Struktur“ K11 zu unterscheiden. **Abnahme:** Ausgeschlossene Zwischenknoten mit aktiver Downline, Admin-/Systemkonto, fehlende Stammdaten, Aktivierung nach Monatsende und identische Mitgliedermenge in allen Payline-Kacheln. + +### A08 — P2: Nächstes Level wird ohne bestehende Monatsqualifikation nicht prognostiziert + +**Stellen:** `app/Services/Abo/PaylineForecastService.php:268–274`; `app/Services/BusinessPlan/LiveCommissionService.php:162`. + +`buildLevelAndCommissionForecast()` beendet die Berechnung, sobald `qual_level` kein Array ist. Ein vorhandenes nächstes Level und zukünftige persönliche Abo-Punkte reichen nicht aus. Die Fortschrittsanzeige verarbeitet diesen Zustand bereits sinnvoll. + +**Nachgestellt:** Monat ohne qualifiziertes Level, nächstes Level mit 50 persönlichen Punkten als Anforderung, offenes eigenes Kundenabo mit 50 Punkten: Fortschritt vorhanden, Prognose `null`. + +**Behebung:** „Noch nicht qualifiziert“ als gültigen Ausgangszustand behandeln; tatsächlichen Berechnungsfehler gesondert lassen. **Abnahme:** Neue Berater und Monatsbeginn ohne Qualifikation, Erreichen der ersten Stufe durch eigene Kundenabos, keinerlei künftige Punkte und echter Fehlerzustand. + +### A09 — P2: Summenzeile im Neupartner-CSV hat acht statt neun Felder + +**Stellen:** `app/Http/Controllers/User/BackofficeStatisticsController.php:241,338`. + +Nach Ergänzung der Telefonspalte wurde die Summenzeile nicht entsprechend erweitert. 100 Kundenpunkte landen in „Sponsor“, 300 Gesamtpunkte in „Punkte seiner Kunden“; die eigentliche Gesamtpunktespalte bleibt leer. + +**Nachgestellt:** Kopf 9 Spalten, Summe 8; Array `['Summe', '1 Eintraege', '', '', 100, 300, '', '']`. + +**Behebung:** Exportspalten zentral definieren und Summen anhand derselben Spaltenzuordnung erzeugen. **Abnahme:** CSV zurückparsen und Werte anhand der Überschriften prüfen; leere Liste und alle anderen Exportmetriken einschließen. + +### A10 — P3: N+1-Abfragen in der eigenen Kundenabo-Liste + +**Stellen:** `app/Http/Controllers/User/AboController.php:79`; `resources/views/user/abo/_customer_abo_list.blade.php:87`; `app/Models/UserAbo.php` (`getCountPaidOrders`). + +Der Controller lädt Artikel/Produkte und Kunden vorab, aber nicht die für jede Tabellenzeile ausgewerteten Abo-Aufträge. Der Zugriff auf die Anzahl bezahlter Lieferungen lädt diese pro Abo nach. + +**Gemessen:** Fünf bereits mit den Controller-Relationen geladene Abos verursachen beim Aufruf der Lieferungszahl fünf weitere SQL-Abfragen. Kein Produktions-Latenzwert wurde gemessen. + +**Optimierung:** Passendes `withCount` mit derselben fachlichen Bezahlt-/Erledigt-Bedingung oder gebündeltes Laden der tatsächlich benötigten Aufträge. **Abnahme:** Query-Anzahl wächst nicht linear mit der Anzahl dargestellter Abos; externe Erledigungen bleiben korrekt enthalten. + +### A11 — P3: Top-3-Gleichstände sind von der Eingabereihenfolge abhängig + +**Stelle:** `app/Services/Team/TopResultsService.php:219–250`. + +Vor dem vorgesehenen Namens-Tiebreak werden nur die ersten 25 Kandidaten behalten. Ein alphabetisch vorrangiger gleichwertiger Kandidat außerhalb dieses Fensters kann nicht mehr gewinnen. Viele Kandidaten ohne Account können außerdem das zugesagte Auffüllen verhindern. + +**Nachgestellt:** 26 Kandidaten mit jeweils 100 Punkten; „Alpha“ kommt an Position 26, davor „Zulu00“ bis „Zulu24“. Ergebnis beginnt mit „Zulu00“, „Alpha“ fehlt. + +**Behebung:** Vollständigen Sortierschlüssel vor Begrenzung anwenden oder alle Gleichstände an der Auswahlgrenze nachladen; stabile ID als letzte Tie-Break-Regel. **Abnahme:** Mehr als 25 Gleichstände, umgekehrte Eingabereihenfolge, fehlende Accounts. + +### A12 — P2: Historische Neuabo-Kachel und verlinkte Liste passen nicht zusammen + +**Stellen:** `app/Services/Abo/TeamAboOverviewService.php:88–96,492`; `resources/views/dashboard/_statistics.blade.php:16–19`; `app/Services/Backoffice/BackofficeDrilldownService.php:203–247`. + +Home rekonstruiert einen vergangenen Monatsendbestand. Der Link des Neu-Badges führt dagegen in den VIP-Drilldown, der nur heute aktive Abos filtert. Der Monatsparameter allein stellt keine identische Zeitlogik her. + +**Nachgestellt:** Im August gestartetes Abo, Kündigung am 08.09.: August-Home zeigt „+1“, verlinkte Detailabfrage 0. Ein identischer Filtername `only_new` verdeckt die verschiedenen Bestandsdefinitionen. + +**Behebung:** Historische Links mit der gleichen Lebenszyklus- und Teamdefinition auswerten. Normale Berater erhalten derzeit nicht dieselbe VIP-Verlinkung; sollte auch ihnen die entsprechende Home-Aufschlüsselung angeboten werden, braucht sie eine eigene berechtigte Route. **Abnahme:** Monatswechsel, spätere Kündigung/Löschung, aktuelle VIP-Bestände und Berechtigungen getrennt testen; VIP-Schutz nicht zur Vereinfachung entfernen. + +### A13 — P2: Ausführungstag ist eine Lücke zwischen Ist und Prognose + +**Stellen:** `app/Services/Abo/PaylineForecastService.php:182,573`; `app/Services/Abo/TeamAboOverviewService.php` (Abfrage offener Ausführungen). + +Offene Abos müssen `next_date > heute 23:59:59` erfüllen. Damit fehlen heute fällige Abos schon ab Mitternacht, selbst wenn der Job noch nicht gelaufen ist oder die Zahlung noch nicht gebucht wurde. + +**Nachgestellt:** 10.09., 00:01 Uhr, aktives Kundenabo mit 50 Punkten und `next_date=10.09.`, keine Buchung. Die persönliche Vorschau bis zum 25. zeigt 0. + +**Einordnung:** Der Code entspricht der vereinfachten Planformel „next_date > heute“. Der Befund ist eine Robustheitslücke dieser Formel im Betriebsablauf, nicht eine heimliche Abweichung vom wörtlichen Datumsfilter. Eine Anzeige „bereits in Ist enthalten“ ist vor der tatsächlichen Buchung unzutreffend. + +**Behebung:** Fällige, tatsächlich noch offene Ausführungen berücksichtigen und Doppelzählung über Ausführungs-/Buchungsreferenzen verhindern. **Abnahme:** Vor und nach Joblauf, verspäteter Job, offene Zahlung, Wiederholung und exakt am Liefertag erfolgte Buchung. + +### A14 — P2: Sortierung funktioniert nur mit deutschen Formaten + +**Stellen:** `resources/views/components/_sortable_table.blade.php:99–114`; `app/Services/Util.php:41–47`; Kundenabo-Tabelle. + +Der Datumsparser akzeptiert ausschließlich `dd.mm.yyyy`, während die Anwendung bei Englisch `Y-m-d` ausgibt. Der Zahlenparser entfernt sämtliche Punkte und interpretiert Kommas als Dezimaltrennzeichen. + +**Mit der unveränderten Originalfunktion ausgeführt:** `2026-09-20` und `2026-08-05` ergeben beide 0. `999.00` wird 99900, `1,000.00` wird 1; damit kann sich die numerische Reihenfolge umkehren. + +**Behebung:** Unformatierte Sortierwerte als Attribute ausgeben; lokalisierte Texte nur anzeigen. **Abnahme:** Datum und Punkte in de/en/es/fr, leere Werte, Tausenderseparatoren, negative Werte und gleiche Werte. Die korrekte Tabellenoptik allein genügt hier nicht. + +### A15 — P1: Shop-Storno überschneidet sich in Eigen- und Kundenpunkten + +**Stellen:** `app/Services/Backoffice/SalesVolumePoints.php:30–34,71–74`; `app/Services/BusinessPlan/SalesPointsVolume.php:105–110,289–306`. + +Status 6 gehört ohne weitere Einschränkung zu Eigenpunkten. Status 6 mit `status_turnover=2` gehört gleichzeitig zu Kundenpunkten. `totalPoints()` addiert beide Kategorien. Der zugrunde liegende ältere Neuberechnungspfad enthält dieselbe Überschneidung; dies ist nicht allein ein neu eingeführter UI-Fehler. Der Stornobuchungspfad übernimmt `status_turnover` aus der Ursprungsbuchung. + +**Nachgestellt:** +100 Shop-Punkte mit Status 2 und −100 Storno mit Status 6/`status_turnover=2`: Buchungsnetto 0, `totalPointsByUser()` −100. Damit können Gesamtpunkte, Firstlines, Recruiter-/Top-3-Volumen und weitere darauf aufbauende Werte zu niedrig sein. Der konkrete Einfluss auf reale Auszahlungen wurde nicht mit Produktionsdaten ermittelt. + +**Behebung:** Ursprungsart der Stornobuchung eindeutig abbilden und disjunkte Kategorien verwenden. Gemeinsam mit der zentralen Punkte-/Provisionslogik korrigieren; Auswirkungen auf vorhandene Daten vor einer Nachberechnung quantifizieren. **Abnahme:** Eigen- und Shop-Bestellung jeweils mit Voll-/Teilstorno, unterschiedliche Monate, `status_turnover` 1/2/null und alte Importfälle; jede Buchung darf zur Gesamtpunktesumme genau einmal beitragen. + +### A16 — P2: Reaktivierungshistorie deckt nicht alle Schreibpfade ab + +**Stellen:** `app/Services/Abo/TeamAboOverviewService.php:168–193`; `app/Repositories/AboRepository.php:139,148`; `app/Services/AboHelper.php:528–556`; `app/Models/UserAboStatusHistory.php`. + +Die Rückrechnung verwirft ein Abo, wenn ein historischer Deaktivierungseintrag bis Monatsende nicht durch eine spätere protokollierte Reaktivierung aufgehoben wurde. Das Repository protokolliert solche Änderungen. Die Recovery-Fortschreibung setzt jedoch direkt `active=true`, Status 2, ohne einen entsprechenden Historieneintrag zu schreiben. Das Modell ergänzt diesen Eintrag nicht automatisch. + +**Zustand nachgestellt:** Start im Juli, protokollierte Deaktivierung Ende Juli, anschließende Reaktivierung ohne Log wie im Recovery-Schreibpfad. September-Livebestand 1, August-Bestand 0 trotz vor August-Ende reaktiviertem Abo. Die Gegenprobe erzeugt diesen Datenzustand; sie löst bewusst keine Recovery-Zahlung aus. + +Darüber hinaus verwendet Home die heutige Teamzuordnung. Selbst eine vollständige Abo-Statushistorie bildet frühere Sponsor-/Kundenzuordnungen nicht automatisch ab. Die pauschale Zusage „ab Juli exakt“ ist deshalb zu weitgehend. + +**Behebung:** Statusänderungen in allen Formular-, Job-, Payment- und Recovery-Pfaden zentral und transaktional protokollieren. Historische Mitgliedschaft entweder persistieren oder die Kennzahl ausdrücklich als Rückrechnung über das heutige Team benennen. **Abnahme:** Mehrfache De-/Reaktivierung einschließlich Ereignissen mit identischem Zeitstempel, Recovery, spätere Kündigung/Löschung, Sponsorwechsel und Altdaten ohne Historie. + +### A17 — P3: Fälligkeitsgruppen verwenden Monatsaddition mit Überlauf + +**Stelle:** `app/Services/Team/AnnualFeeDueService.php:88–95`. + +`addMonth()` bildet an Monatsenden nicht automatisch „gleicher Tag, begrenzt auf letzten Tag des Folgemonats“ ab. **Nachgestellt:** Bezugsdatum 31.01.2026 und Fälligkeit 02.03.2026 werden als „innerhalb eines Monats“ gruppiert, weil der Februar überläuft. + +**Behebung:** Gewünschte Kalendersemantik festlegen und etwa `addMonthNoOverflow`/entsprechende Mehrmonatsvariante verwenden. **Abnahme:** 28./29. Februar, 30./31. Monatsende, Jahreswechsel und exakt fälliger Tag. Die Definition „ein Kalendermonat“ gegenüber „30 Tage“ im Hilfetext eindeutig machen. + +### A18 — P2: CSV-Formelinhalte werden nicht neutralisiert + +**Stellen:** `app/Http/Controllers/User/BackofficeStatisticsController.php:110–121,251`. + +Benutzer-/Kontaktdaten werden unverändert an `fputcsv()` übergeben. CSV-Quoting schützt die Dateistruktur, kennzeichnet einen mit `=` beginnenden Inhalt aber nicht zuverlässig als Text für Tabellenprogramme. + +**Nachgestellt:** Der synthetische Name `=1+1` bleibt unverändert im Exportdatensatz. Eine tatsächliche Ausführung externer Aktionen oder ein Angriff auf eine Tabellenanwendung wurde nicht durchgeführt. Das Risiko entsteht beim späteren Öffnen entsprechender, von anderen Personen kontrollierbarer Textfelder. + +**Behebung:** Textspalten für die unterstützten Tabellenprogramme sicher als Text exportieren; Formelpräfixe einschließlich führender Steuerzeichen berücksichtigen. Fachliche Zahlenspalten bleiben numerisch. **Abnahme:** Harmlose Formelstrings, Namen, internationale Telefonnummern mit `+`, negative Zahlen, Anführungszeichen und mehrzeilige Texte; CSV-Rückimport und vorgesehenen Tabellenclient prüfen. + +### A19 — P2: „Berechnung fehlgeschlagen“ wird zu „0,00 Euro“ + +**Stellen:** `app/Services/BusinessPlan/LiveCommissionService.php:63–88,123–126`; `app/Http/Controllers/HomeController.php:190`. + +Der Snapshot kennzeichnet einen Live-Berechnungsfehler korrekt mit `failed=true`. `commission()` reicht diesen Zustand nicht weiter und berechnet aus den Ersatzwerten einen Nullbetrag. Auch der Home-Payline-Helfer übernimmt nur Punkte und Live-Kennzeichen. Die Oberfläche kann so einen echten Nullstand und eine nicht verfügbare Berechnung nicht unterscheiden. + +**Behebung:** Fehler-/Verfügbarkeitsstatus bis zum Template erhalten, gegebenenfalls letzten erfolgreichen Stand mit Zeitstempel zeigen. Keine Prognose auf einer fehlgeschlagenen Nullbasis als normale Auswertung ausgeben. **Abnahme:** Erzwungene Berechnungsausnahme gegenüber fachlich korrektem Nullmonat; vorhandene Tests für Null-Fallback um die sichtbare Fehlerkennzeichnung ergänzen. + +### A20 — P2: Teaminterne Bilder werden auf öffentlichem Datenträger ausgeliefert + +**Stellen:** `app/Services/Slim.php:35–57`; `app/Http/Controllers/ProfileImageController.php:40–53`; `app/Models/UserAccount.php:376–388`; `config/filesystems.php:62–66`. + +Die Ranglisten-Seite begrenzt ihre Personenauswahl auf das Team. Die Bilddatei wird jedoch auf der öffentlichen Disk gespeichert und mit `/storage/images/profile/...` verlinkt. Dieser Pfad hat in der Anwendung keine Team-/Anmeldeprüfung. Bei der vorgesehenen öffentlichen Storage-Auslieferung reicht die Kenntnis der URL zum Abruf außerhalb der Teamseite. + +**Einordnung:** Belegt ist die fehlende Zugriffskontrolle im Auslieferungsentwurf. Ob die konkrete Produktions-Webserverkonfiguration die URL tatsächlich öffentlich ausliefert, wurde nicht extern getestet. Upload-Eigentümerschaft, JPEG-/PNG-Inhaltsprüfung, erzwungene Dateiendung und Bildlöschung sind positive Schutzmaßnahmen, lösen diese Sichtbarkeitsfrage aber nicht. + +**Behebung:** Für ausdrücklich teaminterne Fotos private Ablage mit berechtigtem Abruf verwenden oder die öffentliche Bildsichtbarkeit ausdrücklich als Produktentscheidung festhalten. Dies ist keine Bewertung der rechtlichen Wirksamkeit des Einwilligungstexts. **Abnahme:** Eigenes Team, fremdes Team, unangemeldeter Abruf, Teamwechsel und gelöschtes Bild. + +### A21 — P3: Neue Texte umgehen teilweise die Sprachdateien + +**Stellen:** `app/Services/Backoffice/BackofficeDashboardService.php:36`; `app/Services/Backoffice/PersonalStatisticsService.php:37`; `app/Http/Controllers/User/BackofficeStatisticsController.php:226`; `resources/views/user/backoffice/statistics/`; `resources/views/web/templates/checkout.blade.php:557`. + +Mehrere Überschriften, Gruppenbezeichnungen, Tooltips und CSV-Spalten werden weiterhin direkt deutsch ausgegeben. Auch das Herkunfts-Freitextfeld enthält deutsche Beschriftungen. Damit wird die in den Plänen genannte Unterstützung de/en/es/fr nicht durchgängig erreicht. Ein Test auf gleiche Sprachschlüssel kann solche hart codierten Texte nicht finden. + +Die ausgeführte angrenzende Testsuite meldet zusätzlich zwei bestehende Schlüsselabweichungen: `payment.php` fehlen in es und fr jeweils 25 deutsche `manual_*`-Schlüssel. Dies ist ein Befund des vorhandenen Arbeitsstands; eine Verursachung durch die Statistikpakete ist damit nicht belegt. + +**Behebung:** Sichtbare neue Texte/Exportüberschriften in Sprachdateien überführen und Ansichten pro Sprache prüfen. **Abnahme:** Nicht nur Key-Parität, sondern gerenderte Beschriftungen sowie Zahlen-/Datumsverhalten aus A14. + +### A22 — P2: Snapshot-Batch gibt bereits berechnete Teambäume nicht frei + +**Stellen:** `app/Providers/AppServiceProvider.php:76`; `app/Services/Backoffice/BackofficeDashboardService.php:81–112,253`; `app/Console/Commands/BackofficeStoreStatisticsSnapshots.php:46–82`. + +Der Dashboard-Service ist ein Singleton und hält `lineBuckets` je Root-ID in einer Instanz-Memoisierung. `storeSnapshot()` leert nur die Memoisierung des aktuell berechneten Roots. Der Batch iteriert über sämtliche VIPs; die zuvor berechneten anderen Roots bleiben referenziert. `gc_collect_cycles()` kann diese regulär erreichbaren Arrays/Modelle nicht freigeben. Zusätzlich werden sämtliche zu bearbeitenden Benutzer vorab geladen. + +**Risiko:** Bei vielen VIPs mit überlappenden großen Teams steigt der Speicherbedarf mit der Summe der gehaltenen Teilbäume, nicht nur mit dem gerade bearbeiteten Team. Ein realer Speicherabbruch wurde nicht provoziert und keine Produktionsgröße geschätzt. + +**Behebung:** Memoisierung am Ende eines Root-Durchlaufs vollständig freigeben oder einen begrenzten Berechnungskontext verwenden; Benutzer per ID chunken. **Abnahme:** Batch mit vielen überlappenden Teams und Memory-Messung nach jedem Root; dauerhaft begrenzter Speicher und gleiche Snapshot-Ergebnisse. + +### A23 — P2: Sechs-Stunden-Caches sind untereinander und mit Live-Daten nicht konsistent + +**Stellen:** `app/Services/BusinessPlan/LiveCommissionService.php:55–65`; `app/Services/Abo/PaylineForecastService.php` (`forecastDays`, `levelAndCommissionForecast`, `paylineContext`); `app/Services/Team/TopResultsService.php:48–65,94–116`; `app/Http/Controllers/HomeController.php:118`. + +Die vereinbarte sechs Stunden alte Anzeige ist an sich kein Fehler. Problematisch ist das Mischen verschiedener Zeitstände: Bereits berechnete Payline-/Provisionspunkte können im Cache liegen, während offene Abos frisch gelesen werden. Nach Zahlung und Fortschreibung von `next_date` ist ein Abo nicht mehr offen, seine neuen Punkte fehlen aber noch in der zwischengespeicherten Basis. Die Prognose kann dadurch vorübergehend absinken. Separate Cache-Erzeugungszeiten verschärfen die Abweichung. + +Bei Top 3 werden Bilder frisch ergänzt, die zwischengespeicherten Namen/Werte aber nicht gegen Löschung oder geänderte Teamzuordnung gefiltert. Ein verschwundener Benutzer kann bis zum Ablauf weiter als Ranglistenzeile stehen bleiben. Für aktuelle Teamzugriffe ist das eine andere Fragestellung als absichtlich aufbewahrte historische Auswertungen. + +**Nachweisgrenze:** Die beteiligten Datenflüsse wurden gelesen; ein vollständiger nebenläufiger Zahlungs-/Jobablauf wurde nicht ausgelöst. Der jüngste Plan nennt die fehlende Invalidierungsstrategie bereits selbst als offen. + +**Behebung:** Gemeinsamen Auswertungszeitpunkt bzw. Datenversionsschlüssel verwenden; nach relevanten Buchungs-/Abo-/Teamänderungen gezielt invalidieren. Aktuelle Sichtbarkeit unabhängig von Zahlen-Caches prüfen. **Abnahme:** Auszahlung/Buchung zwischen zwei Requests, Sponsorwechsel, Löschung, gleichzeitige Requests und keine doppelte Berücksichtigung frisch gebuchter Ausführungen. + +## 4. Weitere Optimierungsmöglichkeiten + +Diese Punkte ergänzen die konkreten Befunde; Laufzeitgewinne wurden nicht behauptet oder ohne Messung beziffert. + +| ID | Ansatz | Betroffene Stellen und sinnvolle Messung | +| --- | --- | --- | +| O1 | Monatswachstum gebündelt berechnen | `TeamAboOverviewService::monthlyGrowth()` fragt Monate/Arten wiederholt ab. Gemeinsame Datenbasis oder Monatsaggregation mit identischer historischer Zustandslogik verwenden; SQL-Anzahl und Laufzeit für zwölf Monate vergleichen. Nicht pauschal auf heutige Aktiv-Abos vereinfachen. | +| O2 | Wiederholte Baumdurchläufe vermeiden | `BackofficeDashboardService::teamIdsPerLeader()` und die rekursive Kettenberechnung in `recruiterRanking()` laufen mehrfach über überlappende Unterbäume. Bottom-up-Zwischenergebnisse und effiziente Queue statt wiederholtem `array_shift`; lange Kette, breites Team und Zyklen testen. | +| O3 | Monatsaggregate je Benutzer/Linie wiederverwenden | `buildLineRow()` und weitere Listen berechnen ähnliche Punktesummen separat. Gebündelte Aggregation mit gemeinsamer Statusdefinition reduziert Abfragen und fachliche Drift. Produktionsnahe `EXPLAIN`-Pläne für Monats-/Benutzerfilter und Abo-Zuordnung prüfen, bevor neue Indizes vorgeschlagen werden. | +| O4 | Snapshot-Schema und Zeitgrenzen ausdrücklich modellieren | JSON-Payloads ohne durchgängige fachliche Schema-/Korrekturversion und manuelle `--force`-Rebuilds erschweren Reproduzierbarkeit. Speichern von Version, Quelle, Stichtag und Finalisierungsstatus; alte Payloads bewusst lesen/migrieren. Tiefenbegrenzungen bei Bäumen bei Erreichen sichtbar diagnostizieren statt unbemerkt abzuschneiden. | + +## 5. Dokumentationslücken und offene Produktentscheidungen + +**D1 — Überzogene Abschlussaussagen.** Formulierungen wie „alle Zahlen korrekt“ und „ab Juli exakt“ müssen nach A01–A23 eingeschränkt werden. Der August-Plan bezeichnet fehlende historische Deaktivierungen teils als Grund für „zu hohe“ Werte; der aktuelle Query kann solche Abos auch ausschließen und damit zu niedrige Werte liefern. Richtung und Ursache einer Näherung sollten konkret beschrieben sein. + +**D2 — Falsche Erklärung zu abgelaufenen Mitgliedschaften.** September-Plan und Kundenbrief sagen, der Provisionsbaum schließe Berater mit abgelaufener Jahresgebühr aus. Die hier geprüften Filter in `BusinessUserItemOptimized::readParentsBusinessUsers()` und `PaylineForecastService::countsInCommissionTree()` prüfen ein gesetztes `payment_account`, nicht dessen Gültigkeit gegenüber heute. Indirekte Änderungen durch Cleanup-Jobs sind möglich, aber keine gleichwertige Datumsprüfung. Vor der Entscheidung zu K11 die tatsächlichen Baumprädikate erklären und mit passenden Fixtures belegen. + +**D3 — Scope-Schalter nur vorbereitet.** `TopResultsService::SCOPE = 'team'` existiert, wird von `scopedUserIds()` aber nicht ausgewertet. Ändern der Konstante schaltet daher nicht auf firmenweit um. Der technische Kommentar sagt zutreffend, dass die Methode später zu erweitern ist; die Darstellung als schon nutzbarer Schalter im August-Plan geht darüber hinaus. Die gegenwärtige teaminterne Auswertung entspricht der dokumentierten Entscheidung. K10 bleibt offen. + +**D4 — Jahresgebühr-CSV fehlt.** N8 nennt im Umsetzungsvorschlag einen CSV-Export analog zu Drilldowns. Die geprüfte Jahresgebühr-Seite und ihre Routen bieten diesen Export nicht. Entweder als noch offenes Teilstück erfassen oder den akzeptierten Lieferumfang ausdrücklich auf die Ansicht begrenzen. Keine automatische Jahresgebühr-Abbuchung daraus ableiten; diese gehört zum offenen K13. + +**D5 — Offene Pakete konsistent zählen.** Der September-Plan benennt K10–K15, also sechs Pakete. Der Kundenbrief spricht wiederholt von vier offenen/großen Themen und stellt sieben Fragen. Gruppierung und Paketzuordnung erläutern, damit eine ausstehende Entscheidung nicht als erledigte Entwicklung erscheint. + +Die offenen Themen bleiben: + +| Paket | Ausstehende Entscheidung/Planung laut Unterlagen | +| --- | --- | +| K10 | Firmenweite Ranglisten und finaler Einwilligungstext | +| K11 | Gemeinsame Definition für Statistik-/Provisionsbaum und weitere Sicht auf abgelaufene Konten | +| K12 | Zeitpunkt und Umkehrbarkeit des Kunden-/Teamübergangs bei Deaktivierung | +| K13 | Automatische Jahresgebühr, Reichweite für neue/bestehende Berater und Zahlungsablauf | +| K14 | Kundenabo in Partnerabo umwandeln, bisherige Lieferungen und Kompensationsprodukte | +| K15 | Zusatzprodukte bei Registrierung und fachliche Punktezuordnung | + +## 6. Bewusst nicht als Fehler bewertet + +- Incentive-Qualifikationen sind eine andere Größe als der aktuelle Abo-Bestand. Der Vergleich der Kundenscreenshots allein beweist keinen Zählfehler. +- Überlappende Führungskräfte-/Recruiter-Zeilen sind bei vollständigen Downlines/Ketten zu erwarten. Eine deduplizierte Kopfsumme muss nicht der Addition dieser überlappenden Zeilen entsprechen. +- Home-Historie und aktueller VIP-Abo-Bestand entsprechen verschiedenen dokumentierten Perspektiven. Fehlerhaft sind Vermischung und unpassende Verlinkung, nicht diese Entscheidung an sich. +- Die allgemeine Terminvorschau enthält gemäß dokumentierter Annahme eigene Abos und eigene Kundenabos. Die separate persönliche Kundenabo-Vorschau hat bewusst Basis 0 und enthält nur noch kommende eigene Kundenabo-Punkte. +- Eine Level-/Provisionsprognose ist keine garantierte spätere Auszahlung; zukünftige Rang-/Bonusänderungen können die tatsächliche Abrechnung verändern. Die geprüften Fehler betreffen auch innerhalb dieser zulässigen Näherung die Datenbasis und Behandlung gültiger Ausgangszustände. +- Die geprüften Statistik-/Abo-Routen verwenden angemeldeten Benutzer, Rollenprüfung und eigene Teamzuordnung. Ein beliebiger fremder Statistik-Root über einen Requestparameter wurde in den geprüften Pfaden nicht festgestellt. A01 ist eine Umgehung der eigenen Abo-Regeln, kein Nachweis einer allgemeinen Fremdkontoübernahme. + +## 7. Prüfung, Grenzen und nächster Umsetzungsschritt + +| Prüfung | Ergebnis | +| --- | --- | +| Vorhandene Tests der Statistik-Kernmodule | **109 bestanden, 678 Assertions** | +| Vorhandene Tests angrenzender Abo-/Profil-/Checkout-/Incentive-/Sprachfunktionen | **105 bestanden, 2 fehlgeschlagen, 553 Assertions**; beide Fehler sind Sprachschlüssel-Parität es/fr | +| Zusätzliche isolierte PHP-Gegenproben | **16 bestanden, 30 Assertions**; sie bestätigen ausdrücklich das beschriebene fehlerhafte Verhalten, nicht dessen Behebung | +| Unveränderte JavaScript-Sortierfunktion | Vier Eingaben ausgeführt; Fehlinterpretationen aus A14 bestätigt | +| PHPUnit-Konfiguration | Warnung wegen veraltetem XML-Schema; nicht verändert | +| Schutz vorhandener Dateien | SHA-256-Vergleich gegen vorab erfasste 10.385 vorhandene Dateien; Abschlussnachweis im Prüfprotokoll | + +Der Audit umfasst die beschriebenen Module einschließlich ihrer fachlichen Datenzuflüsse, Ansichten, Exporte, Berechtigungsanbindung, Persistenz und vorhandenen Tests. Er ist **kein vollständiger Audit der gesamten CRM-/Payment-Anwendung**. Es wurde kein Gesamt-Testlauf sämtlicher Repository-Tests, kein Browser-End-to-End-Test über alle Hosts, kein Lasttest und kein Abgleich mit realen Monatsabrechnungen durchgeführt. Deshalb werden weder tatsächliche Kundenzahlen noch Produktionsverfügbarkeit oder Fehlerfreiheit außerhalb des Prüfbereichs bestätigt. + +Für eine spätere Behebung zuerst A01 und die disjunkte Punkte-/Stornosemantik A15 absichern. Anschließend die oben genannten Regressionen in die regulären Tests übernehmen, historische und aktuelle Berechnungen sauber verbinden und Exporte/Zugriffskanten korrigieren. Vor Nachberechnung bereits abgeschlossener Monate einen nachvollziehbaren Vergleich alter/neuer Ergebnisse erstellen; dieser Audit hat solche Datenänderungen nicht vorgenommen. diff --git a/dev/2026-07-22/monatsabschluss-eingefrorene-statistik.md b/dev/2026-07-22/monatsabschluss-eingefrorene-statistik.md new file mode 100644 index 00000000..8b2a8419 --- /dev/null +++ b/dev/2026-07-22/monatsabschluss-eingefrorene-statistik.md @@ -0,0 +1,90 @@ +# Abgeschlossene Monate sind eingefroren + +Entscheidung der Kundin vom 10.09.2026, umgesetzt am selben Tag. Dieses Dokument hält fest, **warum** +die Statistik so arbeitet, **was genau** eingefroren wird und **was das nicht kann**. + +Es löst die August-Entscheidung F7 („Abo-Bestände tagesaktuell") ab. Der Bezug zu den Audit-Befunden +A04 und A12 steht in [Abschnitt 13 der Bewertung](audit-statistiken-2026-09-09-bewertung.md). + +## 1. Die Regel + +> Am ersten Tag des Folgemonats läuft die Monatsabrechnung: Provisionen werden berechnet, +> Gutschriften ausgestellt, der Monat ist kaufmännisch abgeschlossen. Ab diesem Punkt ändert sich an +> diesem Monat nichts mehr — weder durch Stornos noch durch Änderungen an der Team-Struktur. + +Die Regel gilt damit an zwei Stellen, die vorher unabhängig voneinander gedacht waren: + +| Bereich | Regel | +| --- | --- | +| Buchungen/Stornos | Ein Storno mindert nur den Monat, in dem die Ursprungsbestellung liegt. Ist der Monat abgeschlossen, passiert nichts mehr ([Details](audit-statistiken-2026-09-09-bewertung.md#8-nachtrag-09092026--stornoregel-entschieden-und-umgesetzt)). | +| Statistik/Struktur | Ein abgeschlossener Monat wird nicht mehr aus heutigen Daten gerechnet, sondern aus dem gespeicherten Monatsstand. | + +## 2. Warum die Struktur der eigentliche Grund ist + +Die Team-Struktur ist beweglich: Teampartner kommen dazu, wechseln den Sponsor, fallen weg. Wird ein +vergangener Monat weiter live gerechnet, verschiebt sich seine Auswertung mit jeder solchen Änderung. + +Eine Beraterin sähe dann im Oktober für den August andere Zahlen als in der Abrechnung, die sie für +den August bekommen hat — und die Ursache wäre von außen nicht erkennbar. Genau das war der Auslöser +für die Entscheidung. + +## 3. Was eingefroren wird + +Der Snapshot **ist** der abgeschlossene Monat. Er enthält: + +- Punkte und Umsätze je Linie und in Summe +- Abo-Bestände, Neupartner, „+N neu" +- Führungskräfte-, Recruiter- und Top-10-Listen +- **die Mitglieder je Linie** (`lines[].user_ids`) +- **die Abos je Linie** (`lines[].abo_ids`, getrennt nach Team-/Kundenabos und jeweils „neu") + +Die letzten beiden Punkte sind neu (Payload-Version 2). Aus ihnen baut +`BackofficeDrilldownService` die Detaillisten: Klickt man auf eine Zahl eines abgeschlossenen +Monats, zeigt die Liste genau die Mitglieder und Abos, aus denen diese Zahl entstanden ist — auch +wenn ein Abo inzwischen gekündigt oder gelöscht wurde (`withTrashed()`). + +**Bewusst nicht eingefroren:** Stammdaten wie Name, E-Mail oder Karrierestufe. Sie werden zur +Anzeigezeit gelesen. Eine Umbenennung soll rückwirkend sichtbar sein, eine Strukturänderung nicht. + +## 4. Was das nicht kann + +**Für Monate, die vor dem 10.09.2026 abgeschlossen wurden, gibt es keine echte historische +Struktur.** Frühere Sponsor-Zuordnungen werden nirgends historisiert. Der Rebuild dieser Monate friert +deshalb die Struktur des **Ausführungstags** ein, nicht die des damaligen Monatsendes. + +Das war die bewusste Abwägung: Einheitlichkeit über alle Monate war wichtiger als die Rekonstruktion +einer Struktur, die es nicht mehr gibt. Ab dem Monatsabschluss 01.10.2026 ist der eingefrorene Stand +dann der tatsächliche Monatsstand. + +**Das Home-Dashboard** friert für normale Berater nicht ein — Snapshots gibt es nur für die +VIP-Konten (`admin` 1–3). Wer das auch für alle Berater will, braucht Snapshots für alle Konten; +das ist eine eigene Entscheidung (Menge, Laufzeit, Speicher). + +## 5. Betrieb + +```bash +# Laeuft naechtlich; legt fehlende Snapshots fuer abgeschlossene Monate an +php artisan backoffice:store-statistics-snapshots + +# Nur bei einem Schemawechsel des Payloads: baut vorhandene Snapshots neu. +# ACHTUNG: friert die Struktur des Ausfuehrungstags ein (siehe Abschnitt 4). +php artisan backoffice:store-statistics-snapshots --force +``` + +Beim Wechsel auf Payload-Version 2 wurde `--force` einmalig ausgeführt. Snapshots mit +Payload-Version 1 bleiben lesbar; ihre Detaillisten fallen auf die Live-Auswertung zurück, statt eine +Genauigkeit vorzutäuschen, die sie nicht haben. + +## 6. Wo es im Code steht + +| Datei | Rolle | +| --- | --- | +| `app/Services/Backoffice/BackofficeDashboardService.php` | Klassenkommentar mit der fachlichen Begründung; `PAYLOAD_VERSION`; `buildLineRow()` schreibt `user_ids` und `abo_ids` | +| `app/Services/Backoffice/BackofficeDrilldownService.php` | `frozenLines()`, `frozenUserIds()`, `frozenAboIds()`, `aboScope()` | +| `app/Console/Commands/BackofficeStoreStatisticsSnapshots.php` | Warnung zu `--force` | +| `app/Services/BusinessPlan/SalesPointsVolume.php` | dieselbe Regel auf der Buchungsseite (`isStornoMonthOpen()`) | +| `tests/Feature/BackofficeSnapshotFreezeTest.php` | Regressionen zum Einfrieren | + +Die beiden Tests in `BackofficeStatisticsListsTest.php`, die vorher die alte Regel +(„Abo-Bestände tagesaktuell", August-Entscheidung F7) festhielten, beschreiben jetzt die neue. Sie +wurden umgeschrieben, nicht entfernt — die Szenarien bleiben abgedeckt.