A1-A4 und E1 live seit 23.09., B2, A07, Abo-Warnung und E1-Nachtraege live seit 25.09., Settings auf 0, Montenegro beendet. Offen: erster user:cleanup-Lauf, Seeder fuer den Challenge-Start 09.10. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
861 lines
53 KiB
Markdown
861 lines
53 KiB
Markdown
# 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) |
|
||
| **A07** | Payline-Kontext (Home-Kacheln „Teampartner/Abos in der Payline“, Payline-Prognose) nimmt die Mitglieder aus dem Provisionsbaum wie Team-Statistik und Level-Prognose — umgesetzt 23.09., **live seit 25.09.** An der Live-Kopie betrifft das 102 von 162 Beratern mit Payline (z. B. 527 → 403 Mitglieder). `cache:clear` nach dem Deploy ist erledigt (6-h-Cache `payline_context_*`). | `PaylineForecastService::buildPaylineContext()`, Test in `PaylineForecastServiceTest` |
|
||
|
||
Dazu zwei Regeln, die dabei entschieden wurden:
|
||
|
||
- **Stornoregel** — ein Storno mindert nur den Monat der Ursprungsbestellung; abgeschlossene Monate
|
||
bleiben unberührt. Schalter in den Settings, standardmäßig aus. → [Abschnitt 8](#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 |
|
||
| --- | --- |
|
||
| **A22, A23** | Kein Problem in dieser Größenordnung (7 VIP-Konten) · Cache-Strategie ohnehin offen |
|
||
| **A05, A20, A21** | Kundenentscheidungen: Bedeutung der Spalte „Abos" · Sichtbarkeit der Profilfotos · Spanisch im Backoffice (44 Konten) |
|
||
|
||
## Deployment des gesamten Pakets
|
||
|
||
**Erledigt am 10.09.2026, ohne Fehler** — Einzelheiten und Auswirkung auf die historischen Zahlen in
|
||
[deploy-2026-09-10.md](deploy-2026-09-10.md).
|
||
|
||
```bash
|
||
php artisan migrate
|
||
php artisan backoffice:store-statistics-snapshots --force # einmalig, Payload-Version 2
|
||
php artisan config:clear && php artisan view:clear && php artisan cache:clear
|
||
```
|
||
|
||
---
|
||
|
||
## 1. Gesamturteil
|
||
|
||
**Der Audit ist fachlich solide und ehrlich gearbeitet.** Die zitierten Codestellen stimmen, die
|
||
Zeilennummern stimmen, die Reproduktionen sind nachvollziehbar, und die Grenzen der eigenen Aussage
|
||
werden an jeder Stelle mitgeschrieben ("nicht mit Produktionsdaten ermittelt", "kein
|
||
End-to-End-Test"). Die angegebenen Testergebnisse ließen sich exakt reproduzieren:
|
||
|
||
| Prüfung | Audit | Nachgeprüft |
|
||
| --- | --- | --- |
|
||
| Statistik-Kernmodule | 109 bestanden, 678 Assertions | **109 bestanden, 678 Assertions** ✔ |
|
||
| `LocalizationSyncTest` | 2 fehlgeschlagen (es/fr `payment.php`) | **2 fehlgeschlagen** ✔ |
|
||
|
||
Von 23 Befunden sind **19 als Codefehler bestätigt**. Bei **4 Befunden** stimmt die Diagnose, aber
|
||
die Auswirkung ist in den echten Daten heute null oder deutlich kleiner als dargestellt. Bei
|
||
**einem Befund (A15)** ist die Ursachenanalyse richtig, die daraus abgeleitete Wirkung aber falsch —
|
||
und die tatsächliche, live wirksame Auswirkung ist eine andere als die beschriebene.
|
||
|
||
Die Kernaussage des Audits — "die Aussage ‚alles umgesetzt und Zahlen korrekt' ist nicht belastbar" —
|
||
ist berechtigt. Kein Befund entwertet die gelieferten Pakete K1–K9; es sind Rand- und Sonderfälle
|
||
plus zwei Systematiken (Storno-Zuordnung, Zeitbezug historischer Auswertungen).
|
||
|
||
---
|
||
|
||
## 2. Bewertung je Befund
|
||
|
||
Legende: **bestätigt** = Fehler im Code und in den Daten wirksam · **bestätigt, latent** = Fehler im
|
||
Code, tritt mit den vorhandenen Daten heute nicht auf · **eingeschränkt** = Diagnose richtig,
|
||
Auswirkung anders als beschrieben · **Bewertungssache** = kein Fehler, sondern eine Produkt- oder
|
||
Beschriftungsentscheidung.
|
||
|
||
| ID | Audit | Meine Bewertung | Begründung aus der Gegenprüfung |
|
||
| --- | --- | --- | --- |
|
||
| A01 | P1 | **bestätigt**, aber P2 | Umgehung real; betrifft aber nur den Eigentümer auf seinem eigenen Abo, nicht fremde Konten. |
|
||
| A02 | P2 | **bestätigt** | Kein Statusfilter. In den Daten: 1 Zeile Status 3 mit `user_id`, 1 Zeile Status 0 ohne. |
|
||
| A03 | P2 | **bestätigt** | `HAVING > 0` in allen Metriken; Übersicht und Detail können auseinanderlaufen. |
|
||
| A04 | P2 | **bestätigt** | `overlayCurrentAboStock()` rechnet die komplette Kundenabo-Topliste live neu, auch die abgerechneten Monatspunkte. |
|
||
| A05 | P2 | **Bewertungssache**, P3 | Spalte heißt nur „Abos"; die Seite trägt oben den Hinweis „Abo-Bestände tagesaktuell". Tooltip genügt. |
|
||
| A06 | P2 | **bestätigt**, andere Ursache | Siehe Abschnitt 3: gemeinsame Wurzel mit A15, aber nicht über `status_turnover`. |
|
||
| A07 | P2 | **bestätigt** | `paylineContext()` filtert nicht, `lineByUserId()` schon — zwei Mitgliedermengen auf einer Seite. |
|
||
| A08 | P2 | **bestätigt** | `! is_array($qualLevel) → return null`. Trifft neue Berater und jeden Monatsanfang. |
|
||
| A09 | P2 | **bestätigt** | Kopf 9 Spalten, Summenzeile 8 Werte. Eindeutiger Off-by-one. |
|
||
| A10 | P3 | **bestätigt** | `user_abo_orders` fehlt im Eager Loading; `getCountPaidOrders()` lädt je Zeile nach. |
|
||
| A11 | P3 | **bestätigt**, kosmetisch | Setzt ≥26 exakt gleichauf liegende Kandidaten voraus. |
|
||
| A12 | P2 | **bestätigt** | Kachel rechnet den Monatsendbestand zurück, der verlinkte Drilldown filtert heutige Aktivabos. |
|
||
| A13 | P2 | **bestätigt**, P3 | Entspricht der dokumentierten Formel; Robustheitslücke am Liefertag. |
|
||
| A14 | P2 | **bestätigt**, kleiner Kreis | Betrifft **nur `en`**: es/fr nutzen deutsche Zahl-/Datumsformate. Aktuell 5 Nutzer auf `en`. |
|
||
| A15 | **P1** | **eingeschränkt** | Prädikate überlappen tatsächlich — die beschriebene Doppelzählung gibt es in den Daten aber **nicht**. Siehe Abschnitt 3. |
|
||
| A16 | P2 | **bestätigt, latent** | Kein einziger `reactivated`-Eintrag in den Daten, und alle 10 deaktivierten Abos sind heute noch inaktiv → bisher kein falscher Historienwert. |
|
||
| A17 | P3 | **bestätigt** | Carbon-Überlauf; greift nur, wenn heute der 29.–31. ist. |
|
||
| A18 | P2 | **bestätigt**, P3 | `fputcsv()` ohne Textschutz. Quelle sind eigene Teamdaten, kein fremder Input. |
|
||
| A19 | P2 | **bestätigt** | `commission()` verliert das `failed`-Flag; Oberfläche zeigt 0,00 €. |
|
||
| A20 | P2 | **Bewertungssache** | Öffentliche Disk, Dateiname mit `uniqid()` — nicht aufzählbar, aber ohne Anmeldung abrufbar. Produktentscheidung. |
|
||
| A21 | P3 | **bestätigt** | Gruppenlabels, Metrik-Labels und CSV-Überschriften stehen hart in PHP. |
|
||
| A22 | P2 | **bestätigt, unkritisch** | Der Batch läuft über **7 VIP-Konten** bei 901 Nutzern gesamt. Kein Speicherproblem in dieser Größenordnung. |
|
||
| A23 | P2 | **bestätigt**, bekannt | Deckt sich mit der bereits im Septemberplan offen geführten Cache-Invalidierung. |
|
||
|
||
---
|
||
|
||
## 3. Die wichtigste Korrektur: A15 und A06
|
||
|
||
> **Teilweise überholt (10.09.2026).** Die Analyse der Ursache gilt weiterhin. Die Aussage am Ende
|
||
> des Abschnitts, die Provisionswirkung sei null und nur die Darstellung betroffen, ist durch
|
||
> [Abschnitt 9](#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 | erledigt, live 25.09. |
|
||
| 4 | **A10, A17, A18** | Kleine, unabhängige Korrekturen; als Sammelpaket sinnvoll. | 1 Tag | erledigt |
|
||
| 4 | **A21, A14** | Mehrsprachigkeit und lokalisierte Sortierung. | | **offen** |
|
||
| 5 | **A01, A16** | Vorsorge: Regel serverseitig festziehen, Statushistorie in allen Schreibpfaden protokollieren. Kein aktueller Schaden. | 0,5 Tag | **offen** |
|
||
| — | **A05, A11, A13, A20, A22, A23** | Tooltip, Randfall, Produktentscheidung oder bereits offen geführtes Thema. Nach Absprache. | — | **offen** |
|
||
|
||
~~**Nicht empfohlen:** eine Nachberechnung abgeschlossener Monate.~~ **Überholt:** Die Kundin hat am
|
||
10.09. entschieden, dass abgeschlossene Monate vollständig eingefroren werden; dafür war eine
|
||
einmalige Nachberechnung der Snapshots nötig. Die Punkte der Altmonate wurden dabei nicht verändert —
|
||
siehe [Abschnitt 13](#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**.
|