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 index c8937ea6..6f8fe197 100644 --- a/dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md +++ b/dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md @@ -42,6 +42,7 @@ offen so benannt; genau diese Lücke schließt die folgende Bewertung. | **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. An der Live-Kopie betrifft das 102 von 162 Beratern mit Payline (z. B. 527 → 403 Mitglieder). Nach dem Deploy `cache:clear` (6-h-Cache `payline_context_*`). | `PaylineForecastService::buildPaylineContext()`, Test in `PaylineForecastServiceTest` | Dazu zwei Regeln, die dabei entschieden wurden: @@ -54,7 +55,6 @@ Dazu zwei Regeln, die dabei entschieden wurden: | 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) | diff --git a/dev/2026-07-22/entwicklungsplan-2026-09-07.md b/dev/2026-07-22/entwicklungsplan-2026-09-07.md new file mode 100644 index 00000000..64faeb12 --- /dev/null +++ b/dev/2026-07-22/entwicklungsplan-2026-09-07.md @@ -0,0 +1,294 @@ +# Entwicklungsplan — Kundenfeedback vom 2026-09-07 + +Basis: `dev/2026-07-22/mivita_Statistiken_07_09_2026.pdf` (Kundin, gesammelte Rückmeldung vom 30.08./07.09.). +Ist-Stand-Analyse: 2026-09-07, gegen den Stand nach dem Livegang der 08/2026-Pakete. + +Das Feedback zerfällt in **15 Pakete plus 2 reine Rückfragen**. Vier Punkte sind reine Beschriftungs-/Spaltenänderungen, vier neue oder umgebaute Ansichten, zwei grundsätzliche Entscheidungen und vier Prozess-Automatisierungen außerhalb der Statistik. Zwei Punkte sind bereits umgesetzt und brauchen nur eine Antwort. + +> ## Umsetzungsstand 2026-09-07: K1–K9 umgesetzt +> +> Die Quick-Wins und die drei Ansichts-Pakete sind gebaut und getestet. **K10–K15 sind bewusst offen** — sie hängen an den Rückfragen F3–F8. +> +> | # | Status | Kurz | +> |---|---|---| +> | A1 | ✅ beantwortet | Läuft bereits: 6 Lieferungen / 6 Monate Pause / 3 Tage Sperrfrist. Setting `abo-pause-interval-months` sollte noch explizit auf 6 gesetzt werden (heute Code-Default). | +> | A2 | ✅ bestätigt | Prognose-Punkte stimmen. | +> | K1 | ✅ | Drei Spalten in „Top 10 Kundenabos (Umsatz)": Abgerechnet · davon reine Abo-Punkte · einmalig dazu. Neuer `customerAboAddonPoints()`; `points_recurring` wird als **Rest** aus dem Gesamtwert gebildet, damit beide Spalten immer die angezeigte Summe ergeben. | +> | K2 | ✅ | „Teamabos (direkt)" und „Team-Kundenabos (direkt)" aus der Führungskräfte-Tabelle entfernt. Kopf-Summe bleibt (F1, Variante a) und ist jetzt per Tooltip und Fußnote als überschneidungsfreie Zählung erklärt. | +> | K3 | ✅ | Unterzeile „Basis: Paylinepunkte + persönliches Volumen" unter der Payline-Prognose. | +> | K4 | ✅ | Summe der Payline-Abos ergänzt: „149 Teamabos + 391 Kundenabos = 540 Abos gesamt". | +> | K5 | ✅ | Abo-Bestellungen in der Punkteübersicht türkis mit Zusatz „(Abo)", plus Legende über der Tabelle. | +> | K6 | ✅ | Telefonspalte (mit Mobil-Fallback) im Neupartner-Drilldown inkl. CSV; Spalten umbenannt in „Punkte seiner Kunden" und „Gesamt (inkl. Eigenbestellung)" mit erklärenden Tooltips. | +> | K7 | ✅ | Summenzeile bei „Recruiter des Monats" — **dedupliziert**, nicht als Spaltensumme (sonst zählt das Kettenvolumen doppelt). Eigener Payload-Schlüssel `recruiter_totals`, ältere Snapshots bleiben lesbar. | +> | K8 | ✅ | Eigene Kundenabos als sortierbare Liste mit Suche (Kunde · Liefertag · Punkte · nächste Ausführung · Startdatum · Ausführungen · Status). Abos mit offener Erstzahlung bleiben als Karte darüber, damit deren Aktionsbuttons erhalten bleiben. | +> | K9 | ✅ | Liefertag-Vorschau 5./10./20./25. nur aus den eigenen Kundenabos, über der neuen Liste. | +> +> **Nennenswerte Umsetzungsentscheidungen** +> +> - **K1 an Produktivdaten geprüft:** Für 200 von 200 Abo-Bestellungen aus 08/2026 stimmt `SUM(shopping_order_items.points * qty)` exakt mit `user_sales_volumes.points` überein; die Positionspunkte sind Stückpunkte zum Bestellzeitpunkt. Es gibt produktiv 726 Positionen mit `is_abo_addon = 1`. Die Aufteilung ist damit belastbar — die Einschränkung „erst ab 06/2026 erfasst" steht als Tooltip an der Spalte. +> - **Sortierlogik entdoppelt:** Die 80 Zeilen Sortier-/Such-JavaScript aus der Statistik-Detailseite liegen jetzt in `resources/views/components/_sortable_table.blade.php` und werden von beiden Seiten eingebunden, statt für K8 kopiert zu werden. +> - **Termin-Logik entdoppelt:** `forecastDays()` und `personalCustomerAboForecast()` teilen sich `cumulateByDeliveryDay()` — es gibt weiterhin nur eine Stelle, die die vier Liefertermine kumuliert. +> - **Yajra vs. clientseitig:** K8 nutzt bewusst das vorhandene clientseitige Sortier-Muster statt Yajra. Grund: „Anzahl Ausführungen" (`getCountPaidOrders()`) und „Abo-Punkte" (`getTotalPoints()`) sind in PHP berechnet und in SQL nicht sortierbar — genau zwei der drei Sortierungen, die die Kundin nennt. Die Datenmenge (eigene Kundenabos eines Beraters) rechtfertigt kein Serverpaging. +> +> **Tests:** 10 neue Tests (Aufteilung der Abo-Punkte inkl. Altdatenfall, deduplizierte Recruiter-Summe, Telefon mit Mobil-Fallback, persönliche Liefertag-Prognose, Rendering der Kundenabo-Liste mit und ohne Prognose). Gesamtsuite: 987 grün gegen unveränderte Baseline von 92 vorbestehenden Fehlern (Payone-Race, DATEV, `payment.php`-Sprachkeys aus dem Stripe-Commit). +> +> **Beim Deploy:** `php artisan cache:clear`. K1, K2, K6 und K7 ändern den Snapshot-Payload — abgeschlossene Monate zeigen die neuen Spalten erst nach einer Neuberechnung der Snapshots; bis dahin greifen die Fallbacks („–" bzw. keine Summenzeile). + +| # | Paket | Bereich | Einstufung | Aufwand | +|---|---|---|---|---| +| **A1** | Abo pausieren/stornieren nach der 6. Lieferung | Abo | **läuft bereits** — nur Antwort | — | +| **A2** | Payline-Prognose-Punkte stimmen | Home | **bestätigt** — nur Antwort | — | +| K1 | Kundenabos (Umsatz): Punkte in 3 Spalten aufteilen | VIP-Statistik | Erweiterung | klein–mittel | +| K2 | Führungskräfte: „direkt"-Spalten entfernen | VIP-Statistik | Rückbau | klein | +| K3 | Payline-Prognose: Beschriftung der Basis | Home | Mini-Änderung | sehr klein | +| K4 | Team-Abos Payline: Summe zusätzlich ausweisen | Team-Abos | Mini-Änderung | sehr klein | +| K5 | Punkteübersicht: Abo- vs. Shop-Bestellung markieren | Team-Punkte | kleine Änderung | klein | +| K6 | Neupartner-Statistik: Telefon + Spalten verständlich machen | VIP-Statistik | Erweiterung | klein | +| K7 | Recruiter des Monats: Summenzeile | VIP-Statistik | Erweiterung | klein | +| K8 | Eigene Kundenabos als sortierbare Liste | Portal/Abo | Umbau der Ansicht | mittel | +| K9 | Persönliche Liefertag-Prognose 5./10./20./25. | Portal/Abo | neue Kennzahl | mittel | +| K10 | „Die besten Ergebnisse" firmenweit statt teamintern | Home | **Grundsatzentscheidung** | mittel | +| K11 | Statistik und Struktur zur Deckung bringen | VIP-Statistik | **Grundsatzentscheidung** | groß | +| K12 | Deaktivierte Berater nach 2 Wochen zu Kunden des Sponsors | Cron/Prozess | Verhaltensänderung | groß | +| K13 | Jahresgebühr automatisch verlängern und abbuchen | Payment/Prozess | neues Feature | groß | +| K14 | Kundenabo → Partnerabo per Button im Account | Portal/Abo | neues Feature | groß | +| K15 | Neupartner-Registrierung: rabattierte Zusatzprodukte | Registrierung | neues Feature | groß | + +**Empfohlene Reihenfolge:** A1/A2 beantworten → **K3 · K4 · K2 · K7 · K6 · K5** (Quick-Wins, ein Release) → **K1 · K8 · K9** → **K10** (nach Anwaltsfreigabe) → **K11** (eigene Runde, viel Abstimmung) → **K14 · K15 · K12 · K13** (Prozess-Pakete, je einzeln planen). + +--- + +## A1 — Abo pausieren/stornieren: läuft bereits automatisiert + +**Ihre Frage:** *„Wie funktioniert aktuell das Pausieren, stornieren bei den Abos? Ausgemacht war: Nach der 6. Lieferung kann man das Abo 1x in 6 Monaten pausieren / monatlich stornieren (das sollte alles automatisiert laufen, läuft es aber meines Wissens noch nicht)."* + +**Befund: Es läuft — und zwar genau nach diesen Regeln.** Kein Entwicklungsbedarf. + +- [AboHelper::canCancelAbo()](../../app/Services/AboHelper.php#L125-L134): Kündigung erst ab `getCountPaidOrders() >= abo-min-duration`. **Setting steht produktiv auf 6** — also ab der 6. bezahlten Lieferung, danach jederzeit. +- [AboHelper::canPauseAbo()](../../app/Services/AboHelper.php#L163-L179): Pause verlangt zusätzlich, dass die letzte Pause mindestens `abo-pause-interval-months` zurückliegt (rollierend ab `last_pause_date`). **Effektiv 6 Monate.** +- Zusätzlich greift eine Sperrfrist von **3 Tagen** vor der Ausführung (`abo-lock-days-pause-cancel`), damit eine Pause nicht in eine schon angestoßene Lieferung fällt. + +**Eine Kleinigkeit zum Aufräumen (5 Minuten):** Das Setting `abo-pause-interval-months` ist in der Datenbank **nicht gepflegt**; der Wert 6 kommt aus der Code-Konstante `DEFAULT_PAUSE_INTERVAL_MONTHS`. Funktional identisch, aber im Admin nicht sichtbar — sollte explizit auf 6 gesetzt werden, damit die Regel nachlesbar ist. + +## A2 — Payline-Prognose: Zahlen bestätigt + +**Ihre Rückmeldung:** *„Die Prognose-Punkte stimmen."* — Deckt sich mit unserer Gegenprüfung. Übernehmen wir so, keine Änderung. Die gewünschte Beschriftung ist K3. + +--- + +## K1 — Kundenabos (Umsatz): abgerechnete Punkte in 3 Spalten + +**Briefing:** Statt einer Spalte „Abgerechnet (Punkte)" drei Spalten: **Abgerechnet** · **davon reine Abo-Punkte** · **einmalige Punkte zum Abo dazu**. Beispiel: *Tanja Longerich 1231 abgerechnet – davon 1000 reine Abo-Punkte – 231 einmalige Bestellungen dazu.* Nur bereits abgerechnete Abos. + +**Ist-Stand:** [statistics/index.blade.php:627](../../resources/views/user/backoffice/statistics/index.blade.php#L627) zeigt eine Spalte „Abgerechnet (Punkte)" aus [`customerAboActualPoints()`](../../app/Services/Backoffice/BackofficeDashboardService.php#L799-L811). Die Summe kommt aus `user_sales_volumes` gefiltert auf `shopping_orders.is_abo = 1` ([SalesVolumePoints::customerAboPoints()](../../app/Services/Backoffice/SalesVolumePoints.php)) — **auf Bestellebene**, also Abo-Positionen und Einmal-Zusätze in einem Topf. + +**Gute Nachricht:** Der Marker existiert bereits. `shopping_order_items.is_abo_addon` (Migration `2026_06_05_120100`) unterscheidet Abo-Positionen von Einmal-Zusätzen und wird an allen drei Schreibpfaden gesetzt — [CheckoutRepository](../../app/Repositories/CheckoutRepository.php#L154), [PaymentHelper](../../app/Services/PaymentHelper.php#L133) und dem Abo-Cron [UserMakeOrder](../../app/Cron/UserMakeOrder.php#L315). `shopping_order_items.points` gibt es ebenfalls. + +**Umsetzung:** Zusätzliches Aggregat über `shopping_order_items`, join auf die Abo-Bestellungen des Monats, gruppiert nach `is_abo_addon`. Zwei neue Werte je Berater (`abo_points_recurring`, `abo_points_addon`); die bestehende Spalte bleibt als Summe stehen und muss weiterhin aus `user_sales_volumes` kommen, damit sie mit den übrigen Punkte-Kennzahlen konsistent bleibt. + +> **Wichtige Einschränkung, die in die Kundenantwort gehört:** `is_abo_addon` gibt es erst **seit dem 05.06.2026**. Für Bestellungen davor ist der Wert `false` — die Aufteilung zeigt dort alles als „reine Abo-Punkte". Für Mai und früher ist die Spalte also nicht aussagekräftig. Entweder so kennzeichnen oder die Aufteilung erst ab 06/2026 anzeigen (Empfehlung: kennzeichnen, nicht ausblenden). +> +> **Zweite Prüfung vor der Umsetzung:** Ob die Summe der Positionspunkte je Bestellung exakt den `user_sales_volumes.points` derselben Bestellung entspricht, muss an echten Daten gegengerechnet werden (Rundung, Kompensationspositionen `comp`, Gutschriften). Weicht sie ab, zeigen wir die Aufteilung als *Anteile* der bestehenden Summe statt als eigenständige Zahlen. + +## K2 — Führungskräfte: „direkt"-Spalten entfernen + +**Briefing:** *„Die Spalte (Teamabos direkt) und (Team-Kundenabos direkt) braucht es nicht!"* + +Damit ist die **offene Frage 3 aus dem August-Brief beantwortet** — sie wollte damals beide Sichten sehen, jetzt reicht „gesamt". + +**Ist-Stand:** [statistics/index.blade.php:386-390](../../resources/views/user/backoffice/statistics/index.blade.php#L386-L390) zeigt 4 Team-Spalten (`team_abo_count`, `team_customer_abo_count`, `..._total`), [`leaderAboOverview()`](../../app/Services/Backoffice/BackofficeDashboardService.php#L591-L640) berechnet beide Sichten über zwei Durchläufe von `teamIdsPerLeader()`. + +**Achtung — ein Detail, das leicht übersehen wird:** Die **Kopf-Summe** über der Tabelle (`total_team` / `total_team_customer`) ist bewusst aus den **direkt**-Werten gebildet, weil nur die überschneidungsfrei sind. Fallen die Spalten weg, muss entschieden werden, was die Kopfzahl künftig zeigt: +- **(a)** Kopf-Summe bleibt intern auf der direkt-Rechnung (korrekt, aber die Zahl lässt sich dann nicht mehr aus der Tabelle nachrechnen), oder +- **(b)** Kopf-Summe entfällt ebenfalls. + +Empfehlung: **(a)** mit einer Fußnote „Summe überschneidungsfrei gerechnet". → **Rückfrage F1.** + +**Umsetzung:** Spalten und Fußnotentext aus der View entfernen, `teamIdsByLeader`-Zweig in `leaderAboOverview()` nur noch für die Kopf-Summe rechnen (spart einen Durchlauf). Der Drilldown auf die entfernten Badges muss mit raus. + +## K3 — Payline-Prognose: Basis beschriften + +**Briefing:** *„Aber ich würde dazuschreiben: Paylinepunkte + persönliches Volumen, dann kennt man sich aus."* + +**Ist-Stand:** Die Basis der vier Kacheln ist in [HomeController](../../app/Http/Controllers/HomeController.php#L117-L121) genau das: `customerPoints + paylinePoints`, wobei `customerPoints = getPointsKPSum()` (persönliches Volumen inkl. Shop). Die Beschriftung fehlt nur. + +**Umsetzung:** Sprachkey `home.payline_forecast_title` um den Zusatz ergänzen bzw. eine Unterzeile „Basis: Paylinepunkte + persönliches Volumen" — 4 Sprachen. **Reine Textänderung.** + +## K4 — Team-Abos Payline: Summe ausweisen + +**Briefing:** *„Wenn da steht 149 Teamabos – 391 Kundenabos in der Payline, wäre es schön, wenn die Summe der Abos in der Payline auch noch da steht."* + +**Ist-Stand:** [abos.blade.php:70](../../resources/views/user/team/abos.blade.php#L70) rendert `abo.payline_abo_counts` mit `team_abo_count` und `team_customer_abo_count` aus [`PaylineForecastService::aboTurnover()`](../../app/Services/Abo/PaylineForecastService.php). Beide Werte liegen vor, die Summe wird nur nicht gebildet. + +**Umsetzung:** Sprachkey um `:total` erweitern, in der View summieren. **Reine Textänderung**, 4 Sprachen. + +## K5 — Punkteübersicht: Abo- gegen Shop-Bestellung absetzen + +**Briefing:** *„Hier wäre es noch hilfreich zu sehen, welcher dieser Bestellungen eine ABO-Bestellung ist (statt Shop-Bestellung – Abo-Bestellung und in einer anderen Farbe)."* + +**Ist-Stand:** [TeamController::datatablePoints()](../../app/Http/Controllers/User/TeamController.php#L1084-L1127) rendert die Spalte „Bestellung" als Badge — heute unterscheidet die Farbe nur *eigene* (`btn-primary`) von *Kunden*-Bestellungen (`btn-secondary`). `shopping_orders.is_abo` ist über die geladene Relation direkt verfügbar. + +**Umsetzung:** Badge-Farbe und Beschriftung um die Abo-Unterscheidung erweitern (z. B. Abo = `btn-info` mit Zusatz „Abo"), Legende über der Tabelle. Klein — aber die Yajra-Sortierung auf der Spalte muss weiterhin funktionieren. + +## K6 — Neupartner-Statistik: Telefon + Spalten verständlich machen + +**Briefing:** Zwei Punkte: +1. *„Hier würde ich auch die Telefonnummer des Neupartners hinzufügen."* +2. *„Was bedeutet die Spalte Kundenpunkte vs Punkte gesamt? Das ist für mich nicht schlüssig."* + +**Ist-Stand:** [`newPartnerRows()`](../../app/Services/Backoffice/BackofficeDrilldownService.php#L133-L164) liefert Name, E-Mail, Karriere-Level, Sponsor, `customer_points`, `total_points`, aktiv seit, Konto bis. **Telefon fehlt** — steht aber in `user_accounts.phone` / `.mobil` und wird über `with('account')` bereits mitgeladen. + +Zu 2.: Die Spalten sind korrekt, aber schlecht benannt. **Kundenpunkte** = Punkte aus Bestellungen *seiner Kunden* (Shop + Abo). **Punkte gesamt** = Kundenpunkte **plus seine Eigenbestellungen**, und da steckt vor allem das **Starterpaket** drin. Genau diese Spalte hatten wir im August auf ihren Hinweis hin ergänzt, damit das Starterpaket sichtbar wird — der Zusammenhang ist aus der Überschrift nicht erkennbar. + +**Umsetzung:** +1. Telefon-Spalte ergänzen (mit `phone`-Fallback auf `mobil`, wie auf der Firstlines-Seite), inklusive CSV-Export. +2. Spalten umbenennen: **„Kundenpunkte"** → **„Punkte seiner Kunden"**, **„Punkte gesamt"** → **„Gesamt (inkl. Eigenbestellung/Starterpaket)"**, plus eine Erklärzeile unter der Tabelle. Textänderung, kein Rechenweg-Eingriff. + +## K7 — Recruiter des Monats: Summenzeile + +**Briefing:** *„Hier kann man am Ende noch eine Summe bilden – das Volumen aller Neupartner zusammen, die in diesem Monat eingestiegen sind."* + +**Ist-Stand:** [`recruiterRanking()`](../../app/Services/Backoffice/BackofficeDashboardService.php#L834-L936) liefert Zeilen mit `new_partner_count` und `volume_points`. Der Kopf zeigt bereits die Gesamtzahl der Neupartner; eine Volumen-Summe fehlt. + +**Wichtig — nicht einfach die Spalte aufaddieren:** `volume_points` enthält das **Kettenvolumen** (Punkte der Neupartner, die diese Neupartner selbst geworben haben). Wer im selben Monat von A geworben wurde und selbst B wirbt, taucht in A's Kettenvolumen **und** in seiner eigenen Zeile auf — die Spaltensumme zählt B doppelt. + +**Umsetzung:** Die Summe muss **einmal je Neupartner** über die deduplizierte Neupartner-Menge gerechnet werden (`SalesVolumePoints::totalPointsByUser()` über alle `newPartnerIds`), nicht als Spaltensumme. Als Fußzeile „Volumen aller N Neupartner des Monats: X Punkte" mit Tooltip, warum das weniger sein kann als die Spalte darüber. + +## K8 — Eigene Kundenabos als sortierbare Liste + +**Briefing:** *„Eigene Kundenabos sehr unübersichtlich – in Listenform. Kann man das ähnlich anzeigen lassen wie die Teamabos und Teamkundenabos in Listenform? Evtl. mit der Möglichkeit es auch zu sortieren – nach Startdatum, nach Anzahl der Ausführungen, nach Liefertag."* + +Gewünschte Spalten laut ihrer Skizze: +`Name des Kunden | Abo-Liefertag | Abo-Punkte | Nächste Ausführung | Start-Datum | Abo-Ausführungen` + +**Ist-Stand:** [user/abo/index.blade.php](../../resources/views/user/abo/index.blade.php) rendert **eine Karte pro Abo** (267 Zeilen Karten-Markup) — bei vielen Kundenabos entsteht genau die beschriebene Unübersichtlichkeit. Die Daten liegen alle vor: `shopping_user` (Kundenname), `abo_interval` (Liefertag), `getTotalPoints()`, `next_date`, `start_date`, `getCountPaidOrders()`. + +**Umsetzung:** Tabellenansicht für `view = 'ot'` als Standard, Karten als Detail hinter dem Klick (Detailseite existiert: `user_abos_detail`). Sortierung projektkonform über **Yajra Datatables** (CLAUDE.md: Listenansichten ausschließlich Yajra) — dafür braucht es eine Datatable-Route analog zu `user_team_datatable_points`. Der bestehende Monatsfilter und die Chart-Karte bleiben darüber stehen. + +> **Achtung:** Die Karten enthalten heute die Sonderfälle „Zahlungslink offen", „Erstzahlung ausstehend/fehlgeschlagen" mit Aktionsbuttons. Die dürfen nicht verloren gehen — entweder als Statusspalte mit Badge in der Liste oder als Kartenblock **über** der Tabelle (Empfehlung: Letzteres, da es Ausnahmefälle sind). + +## K9 — Persönliche Liefertag-Prognose 5./10./20./25. + +**Briefing:** *„Hier wäre es auch noch interessant zu sehen, wieviel Volumenpunkte habe ich am 5. / 10. / 20. / 25. Nur mit meinen Kundenabos."* + +**Ist-Stand:** [`PaylineForecastService::forecastDays()`](../../app/Services/Abo/PaylineForecastService.php) rechnet genau diese vier Termine — aber für die **Payline** (Team) und mit dem persönlichen Umsatz als Basis. Eine Variante „nur meine eigenen Kundenabos" gibt es nicht. + +**Umsetzung:** Zweite, schlanke Methode auf demselben Baustein: gleiche Termin-Logik (`UserAbo::$aboDeliveryDays`, kumuliert, `next_date` im laufenden Monat), aber Mitgliedermenge = nur der Berater selbst, `is_for = 'ot'`. Anzeige auf der neuen Kundenabo-Liste aus K8 als vier kleine Kacheln — das passt inhaltlich zusammen und sollte **mit K8 in einem Zug** gebaut werden. + +> **Klärung:** Sie schreibt „nur mit meinen Kundenabos" — also **ohne** das eigene Abo. Das ist die umgekehrte Entscheidung zur Payline-Prognose, wo eigene Abos bewusst mitzählen (Frage 2 aus dem August-Brief, von ihr noch nicht beantwortet). Wir bauen es wie beschrieben (nur Kundenabos), sollten den Unterschied aber benennen. → **Rückfrage F2.** + +## K10 — „Die besten Ergebnisse" firmenweit statt teamintern + +**Briefing:** *„Das ist sehr toll umgesetzt, doch es wäre sinnvoller dies nicht für das eigene Team zu machen, sondern für die ganze Firma – das müsste ja jetzt auch mit dem Anwalt geklärt sein?"* + +**Ist-Stand — dafür vorgesehen:** [`TopResultsService::SCOPE`](../../app/Services/Team/TopResultsService.php#L30) ist genau als dieser Schalter angelegt (`'team'` / `'company'`), und [`scopedUserIds()`](../../app/Services/Team/TopResultsService.php#L78-L84) ist die einzige Stelle, die sich ändern muss. Die Ranglisten-Logik bleibt unangetastet. + +**Was trotzdem zu tun ist — der Schalter allein reicht nicht:** +1. **Performance/Cache.** Teamintern wird je Betrachter gecacht (`top_results_{user}_{month}_{year}`). Firmenweit gibt es **ein** Ranking für alle — der Cache-Key muss den User verlieren, sonst rechnen wir dasselbe Ergebnis 900-mal. Gleichzeitig läuft die Rangliste dann über alle Berater statt über eine Downline; das ist als eine gebatchte Query unkritisch, aber die Kandidaten-Hydrierung (`TOP_LIMIT + 22`) muss geprüft werden. +2. **„ich"-Markierung** bleibt, muss aber nach dem Cache gesetzt werden (wie heute schon die Profilfotos), sonst sieht jeder das „ich" des Ersten, der die Seite geladen hat. **Das ist der Fehler, den man hier am leichtesten baut.** +3. **Datenschutz.** Firmenweit sieht jeder Berater Namen, Fotos und Umsatzhöhe fremder Berater ohne Vertriebsbeziehung. Das ist eine andere Qualität als teamintern und braucht die Freigabe, die sie selbst anspricht — inklusive des Einwilligungstexts fürs Profilfoto, der **weiterhin ein Platzhalter** ist (offener Punkt aus dem August-Brief). + +→ **Rückfrage F3: schriftliche Bestätigung der Anwaltsfreigabe + finaler Einwilligungstext.** Ohne beides würde ich das nicht scharf schalten. + +## K11 — Statistik und Struktur zur Deckung bringen + +**Briefing:** *„Kann man Statistik und Struktur so anzeigen, damit man die gleiche Summe bekommt – ich würde die Zahlen der Struktur nehmen, denn diese sind für die Provisionen relevant!"* + +**Befund: Sie hat recht, und wir kennen die Ursache genau.** Die beiden Seiten laufen über zwei verschiedene Baum-Definitionen: + +| | Statistik (`user/backoffice/statistics`) | Struktur (`user/team/structure`) | +|---|---|---| +| Baum | [`BackofficeDashboardService::lineBuckets()`](../../app/Services/Backoffice/BackofficeDashboardService.php#L247-L277) | `TreeCalcBotOptimized` → [`readParentsBusinessUsers()`](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php#L1080-L1092) | +| Nimmt auf | **jeden** nicht gelöschten Downline-User | nur `m_level` **und** `payment_account` gesetzt, `admin < 4`, nicht `id = 1`, `active_date <= Monatsende` | +| Bei ausgefiltertem Knoten | Unterbaum wird **weiter** traversiert | Unterbaum wird **komplett gekappt** | +| Max. Tiefe | 100 | 20 | + +Gemessen an Claudias Account (08/2026): **578 Personen in der Downline, davon 134 nicht im Provisionsbaum.** Die Punkte-Differenz war an diesem Stichtag zufällig 0, weil die ausgefilterten Konten Blätter ohne Umsatz waren — strukturell ist die Abweichung aber jederzeit möglich. + +**Vorarbeit ist schon geleistet:** Für die Level-/Provisions-Prognose wurde diese Angleichung am 01.09. bereits gebaut — [`PaylineForecastService::lineByUserId()`](../../app/Services/Abo/PaylineForecastService.php) filtert und kappt jetzt nach denselben Regeln, inklusive Regressionstest. **Dasselbe Prädikat lässt sich wiederverwenden**; es gehört dann als gemeinsame Definition in den `BackofficeDashboardService`. + +**Warum das trotzdem groß ist:** `lineBuckets()` ist die Grundlage von **allem** in der VIP-Statistik und auf dem Home-Dashboard — Berater-Zahlen, Neupartner, Abo-Bestände, Punkte, Payline-Mitglieder, Top-3, Führungskräfte, Recruiter. Eine Umstellung ändert praktisch jede Zahl auf beiden Seiten, und zwar auch rückwirkend in der Anzeige. + +**Umsetzungsvorschlag in drei Schritten:** +1. **Erst messen, dann umstellen.** Ein Auswertungs-Command, der für alle VIPs beide Baum-Definitionen gegenüberstellt (Anzahl Berater, Neupartner, Abos, Punkte je Ebene). Damit weiß man vor der Umstellung, wo und wie stark sich Zahlen bewegen — und kann es der Kundin vorher sagen, statt sie es entdecken zu lassen. +2. **Gemeinsames Prädikat** aus `PaylineForecastService` in den `BackofficeDashboardService` heben, `lineBuckets()` bekommt die Provisionsbaum-Variante als zweite Methode. +3. **Umstellen mit Stichtag**, gespeicherte Snapshots neu berechnen, und in der Kundenmitteilung offenlegen, welche Zahlen sich warum geändert haben — analog zur Neuberechnung im August. + +> **Ein Punkt, den sie kennen sollte:** Die Statistik zählt heute bewusst auch Berater mit **abgelaufener** Jahresgebühr als „Berater" (Nachfass-Perspektive: sie sollen auffindbar bleiben). Der Provisionsbaum tut das nicht. Nach der Umstellung verschwinden diese Personen aus der Statistik — genau die, für die wir im August die Jahresgebühr-Liste gebaut haben. → **Rückfrage F4:** Provisionssicht überall, oder Provisionssicht als Standard mit umschaltbarer „inkl. abgelaufene"-Ansicht? Ich empfehle Letzteres. + +## K12 — Deaktivierte Berater nach 2 Wochen zu Kunden des Sponsors + +**Briefing (sie kennzeichnet ihn selbst als „Vorschlag, den wir noch besprechen müssen"):** Bei Deaktivierung **innerhalb von 2 Wochen, nicht 2 Monaten**: Berater wird als Kunde geführt, seine Kunden **und er selbst** gehen als Kunden an den Sponsor, seine Teampartner werden neue Firstlines des Sponsors. Sponsor bekommt eine Mail mit beiden Listen (Vorname, Nachname, Telefon, E-Mail). 2 Wochen vorher eine Mail an den Berater mit der Konsequenz. + +**Ist-Stand — die Logik existiert komplett, nur zum falschen Zeitpunkt.** [`UserCleanUp`](../../app/Console/Commands/UserCleanUp.php) läuft täglich um 03:30 in zwei Stufen: + +| Stufe | Zeitpunkt | Was passiert | +|---|---|---| +| `cleanUpInActiveUser()` | payment_account **< −2 Wochen** | Teampartner → nächster aktiver Sponsor, User deaktiviert (`pre_sponsor` gemerkt). **Kunden bleiben beim deaktivierten User.** | +| `deleteInactiveUsers()` | payment_account **< −2 Monate** | zusätzlich `setShoppingUserToNewMember()` + `setUserToClient()`, dann Soft-Delete | + +Ihr Wunsch = die beiden `UserUtil`-Aufrufe aus der 2-Monats-Stufe in die 2-Wochen-Stufe vorziehen. **Die Bausteine sind fertig** ([UserUtil](../../app/Services/UserUtil.php)), es fehlen nur die Mails. + +**Das eigentliche Problem ist nicht technisch, sondern fachlich — Reversibilität:** Die 2-Wochen-Stufe ist heute bewusst **umkehrbar**. `UserUtil::reactiveUserResetChilds()` holt Teampartner über `pre_sponsor` zurück, wenn jemand verspätet zahlt. Zieht man den Kundentransfer und die Kundenumwandlung vor, ist das nach 2 Wochen **endgültig** — wer am 15. Tag zahlt, bekommt seine Kunden nicht zurück. + +Sie schreibt selbst: *„Wenn ein Berater rausgefallen ist, muss er sich wieder mit Starterpaket registrieren… seine Teampartner und Kunden sind dann aber schon seinem Sponsor übergegangen."* — das klingt nach bewusster Akzeptanz. Bei 2 Wochen Frist und einer Jahresgebühr halte ich das trotzdem für hart und rechtlich nicht ohne. → **Rückfrage F5.** + +**Umsetzung (wenn bestätigt):** +1. `cleanUpInActiveUser()` um `setShoppingUserToNewMember()` + `setUserToClient()` erweitern; Reaktivierungspfad entsprechend anpassen oder bewusst entfernen. +2. **Neue Mail an den Sponsor** mit beiden Listen — Datenquelle sind exakt die im selben Transaktionsblock umgezogenen IDs, nicht ein nachträglicher Query. +3. **Vorwarn-Mail an den Berater 2 Wochen vor Ablauf.** Achtung: [`CheckPaymentsAccount`](../../app/Console/Commands/CheckPaymentsAccount.php) verschickt bereits Erinnerungen — die neue Mail muss sich dort einreihen statt eine zweite Mailstrecke aufzumachen. +4. **Alles idempotent und transaktional**, mit Trockenlauf-Modus. Ein fehlerhafter Lauf verschiebt Kundenbeziehungen unwiederbringlich. + +## K13 — Jahresgebühr automatisch verlängern und abbuchen + +**Briefing:** *„Jahresgebühr: diese wird automatisch verlängert und abgebucht. Falls man sie nicht verlängern möchte, muss man eingreifen und nicht, wenn man verlängern möchte!"* + +**Ist-Stand:** Heute ausdrücklich **manuell** — der Sprachkey sagt es dem Berater wörtlich: *„Sobald Deine Zahlung bei uns eingegangen ist, verlängert sie sich um ein weiteres Jahr. **Eine automatische Verlängerung gibt es nicht.**"* ([membership.php:36](../../resources/lang/de/membership.php#L36)). + +**Interessanter Fund:** Es existieren bereits fertige Texte für genau dieses Feature — [payment.php:15-17](../../resources/lang/de/payment.php#L15-L17) beschreibt SEPA-Mandat, Speicherung und Abbuchung **14 Tage vor Ablauf** inklusive jederzeitigem Widerruf. Diese Keys werden **nirgends** verwendet, und es gibt weder ein Feld noch einen Cron dafür. Das Feature war offenbar konzipiert und nicht gebaut. + +**Umfang (bewusst grob, das braucht eine eigene Planungsrunde):** Opt-out-Kennzeichen am Account, gespeichertes Zahlungsmittel/SEPA-Mandat (PAYONE bzw. Stripe — beide Wege existieren im System), Cron „14 Tage vor Ablauf abbuchen", Fehler-/Retry-Behandlung bei Rücklastschrift, Rechnungserzeugung, Kündigungsweg im Account, sowie AGB/Widerruf. **Rechtlich ist die Umkehrung von Opt-in auf Opt-out der kritische Teil** — für Bestandsberater lässt sich ein Lastschriftmandat nicht rückwirkend annehmen. → **Rückfrage F6:** gilt das nur für Neuregistrierungen, oder sollen Bestandsberater aktiv zustimmen? + +## K14 — Kundenabo → Partnerabo per Button im Account + +**Briefing:** Wenn ein Kunde mit aktivem Abo Partner wird, musste bisher eine Mail an mivita gehen, damit das Abo gestoppt und in ein Partnerabo umgewandelt wird. Gewünscht: Button im Account — *„jetzt Kundenabo in Partnerabo ändern, damit du auch dein ABO 30 % günstiger bekommst"*, dann ein Vorschlag aus dem Kundenabo plus die Möglichkeit, Kompensationsprodukte hinzuzufügen, mit einem Feld für die Begründung. + +**Ist-Stand:** Kein Umwandlungspfad im Code — der Vorgang läuft heute tatsächlich manuell. Vorhanden und wiederverwendbar sind: die Abo-Anlage über `AboOrderCart`, die Kompensationslogik (`comp`-Flag auf `user_abo_items`, wird in allen Punkte-Abfragen bereits berücksichtigt) und das Einmalprodukt-Muster [`AboOneTimeService`](../../app/Services/AboOneTimeService.php) als Vorlage für „Vorschlag + Positionen ergänzen". + +**Fachliche Knackpunkte, die vor der Umsetzung geklärt sein müssen:** +- Läuft das alte Kundenabo aus oder wird es storniert — und was passiert mit den bereits gezählten **Ausführungen** (die 6-Lieferungen-Regel aus A1 beginnt sonst von vorn)? +- Der Kunde hing als `shopping_user` an einem Berater. Wird er selbst Partner, wandert der Umsatz von dessen Kundenpunkten in seine Eigenpunkte — das verschiebt Provisionen und Statistik. +- Kompensationsprodukte mit Begründungsfeld heißt: es braucht eine Freigabe (mivita bzw. Admin), sonst kann sich jeder Partner beliebig Kompensation eintragen. + +→ **Rückfrage F7.** Ohne diese drei Antworten würde ich nicht anfangen. + +## K15 — Neupartner-Registrierung: rabattierte Zusatzprodukte + +**Briefing:** *„Die Möglichkeit gleich bei der Registrierung noch weitere Produkte rabattiert dazuzubestellen (bei Starterpaket -30 %; ohne Starterpaket -20 %), wenn jemand z. B. das Basic Health Paket auswählt und noch eine Aer Sun dazubestellen möchte."* + +**Ist-Stand:** In der Registrierungsstrecke gibt es keinen Weg, dem Startpaket weitere Produkte hinzuzufügen. Die Rabattstaffel 30 %/20 % ist die reguläre Berater-Kondition; die Preislogik existiert also, nur nicht an dieser Stelle. + +**Zu klären:** Punktewirkung der Zusatzprodukte (zählen sie wie das Starterpaket zu den Eigenpunkten des Neupartners und damit ins „neue Volumen" des Recruiters?), Versand (eine Sendung oder zwei), Storno/Widerruf bei gescheiterter Registrierung, und ob der Rabatt schon vor der Freischaltung des Accounts greifen darf. → **Rückfrage F8.** + +--- + +## Rückfragen an die Kundin + +| # | Frage | Blockiert | +|---|---|---| +| **F1** | Führungskräfte: Wenn die „direkt"-Spalten entfallen — soll die Kopf-Summe bleiben (überschneidungsfrei gerechnet, aber nicht mehr aus der Tabelle nachrechenbar) oder ebenfalls weg? | K2 | +| **F2** | Persönliche Liefertag-Prognose „nur mit meinen Kundenabos" — **ohne** das eigene Abo, richtig? (In der Payline-Prognose zählen die eigenen Abos bewusst mit.) | K9 | +| **F3** | Firmenweite Ranglisten: Liegt die Anwaltsfreigabe schriftlich vor? Und bitte den **finalen Einwilligungstext fürs Profilfoto** — der steht seit August auf Platzhalter. | K10 | +| **F4** | Statistik = Struktur: Sollen Berater mit **abgelaufener** Jahresgebühr dann überall verschwinden, oder Provisionssicht als Standard mit umschaltbarer „inkl. abgelaufene"-Ansicht? (Empfehlung: umschaltbar — sonst fehlen genau die Leute, für die die Jahresgebühr-Liste gebaut wurde.) | K11 | +| **F5** | Deaktivierung nach 2 Wochen: Soll der Kundentransfer **endgültig** sein? Heute ist die 2-Wochen-Stufe umkehrbar — wer verspätet zahlt, bekommt sein Team zurück. | K12 | +| **F6** | Automatische Jahresgebühr: nur für **Neuregistrierungen**, oder sollen Bestandsberater aktiv ein Lastschriftmandat erteilen? | K13 | +| **F7** | Kundenabo → Partnerabo: (a) zählen die bisherigen Ausführungen für die 6-Lieferungen-Regel weiter? (b) Wer gibt Kompensationsprodukte frei? | K14 | +| **F8** | Registrierungs-Zusatzprodukte: Zählen deren Punkte wie das Starterpaket zu den Eigenpunkten des Neupartners (und damit ins „neue Volumen" des Recruiters)? | K15 | + +## Technische Querschnittsthemen + +- **Sprachkeys:** Jede Textänderung in **de/en/es/fr** — `LocalizationSyncTest` erzwingt Parität. Betrifft K2–K7. +- **Tests:** Für jedes Paket ein Test, der die Regel festhält, nicht nur das Rendering. Bei K1 und K7 sind es die Rechenwege (Aufteilung bzw. Doppelzählung), bei K11 die Baum-Definition. +- **Snapshots:** K1, K2, K6, K7 und K11 ändern den `BackofficeStatisticsSnapshot`-Payload. Abgeschlossene Monate zeigen sonst die alte Struktur — wie im August müssen die Snapshots nachgerechnet und die Kundin über abweichende Altzahlen informiert werden. Die Views brauchen weiterhin den „älterer Snapshot ohne diese Liste"-Fallback. +- **Caches:** Nach jedem Livegang `php artisan cache:clear` — die 6-h-Caches (`payline_context_*`, `live_business_v3_*`, `top_results_*`, `level_forecast_*`) haben keine Invalidierung. Bei K10 ändert sich zusätzlich der Cache-Key-Zuschnitt. +- **Offen aus dem letzten Code-Review** (unabhängig von diesem Feedback, sollte mitlaufen): Cache-Invalidierungsstrategie statt fester 6 h; Widerspruch Home-Kacheln ↔ VIP-Statistik bei Abo-Beständen vergangener Monate (Kundenentscheid F7 aus dem Juli — passt thematisch zu K11 und sollte in derselben Runde mitentschieden werden); Performance von `monthlyGrowth()`. diff --git a/dev/2026-07-22/feedback-an-kundin-2026-09-07.md b/dev/2026-07-22/feedback-an-kundin-2026-09-07.md new file mode 100644 index 00000000..5c8a333c --- /dev/null +++ b/dev/2026-07-22/feedback-an-kundin-2026-09-07.md @@ -0,0 +1,184 @@ +# Rückmeldung zu deiner Sammlung vom 07.09. + +Hallo Claudia, + +danke für die zusammengefasste Liste — die war sehr gut zu bearbeiten. Wir haben sie in 15 Punkte zerlegt: + +- **9 sind umgesetzt und live** (Teil 1) +- **2 liefen bereits so, wie ihr es ausgemacht habt** — dazu unten die Erklärung +- **4 größere Themen** möchten wir vorher mit dir klären (Teil 2) + +Bei den vier offenen geht es nicht um Aufwand, sondern um Entscheidungen, die dir gehören: Sie ändern Provisionen, Kundenzuordnungen oder Zahlungsabläufe. Wenn wir da in die falsche Richtung bauen, ist das nur schwer rückgängig zu machen. Deshalb die sieben Fragen am Ende — kurz gehalten, mit unserer Empfehlung dazu. + +--- + +# Teil 1 — Was jetzt neu ist + +## 1. Kundenabos (Umsatz): abgerechnete Punkte in drei Spalten + +Genau wie in deinem Beispiel. In der Liste „Top 10 Kundenabos (Umsatz)" steht jetzt: + +**Abgerechnet** · **davon reine Abo-Punkte** · **einmalig dazu** · Bestand/Lieferung + +Aus deinen 1231 wird also **1231 | 1000 | 231**. + +Wir haben die Aufteilung gegen die echten Daten geprüft: Bei 200 geprüften Abo-Bestellungen aus dem August ergeben die Einzelpositionen auf den Punkt genau dieselbe Summe wie die Umsatzbuchung. Die drei Spalten passen also immer zusammen. + +> **Eine Einschränkung:** Das System unterscheidet Abo-Positionen und einmalig dazubestellte Artikel erst **seit Anfang Juni 2026**. Für ältere Bestellungen steht alles unter „reine Abo-Punkte" — nicht weil es so war, sondern weil die Information damals nicht mitgeschrieben wurde. Der Hinweis steht auch als Tooltip an der Spalte. + +## 2. Führungskräfte: die „direkt"-Spalten sind raus + +Es bleiben **Eigene Abos · Kundenabos · Teamabos (gesamt) · Team-Kundenabos (gesamt) · Gesamt**. + +Damit dich die Zahl oben rechts nicht irritiert: Die **Summe im Kopf** ist kleiner als die Spalten darunter — und das ist richtig. In den Spalten steht die komplette Downline jeder Führungskraft; sitzt eine Führungskraft unter einer anderen, zählen dieselben Abos bei beiden. Die Kopf-Summe zählt jedes Abo **genau einmal**. Beides ist jetzt an der Karte erklärt. + +## 3. Payline-Prognose: Basis beschriftet + +Unter der Überschrift steht jetzt **„Basis: Paylinepunkte + persönliches Volumen"**. + +## 4. Team-Abos: Summe dabei + +**„149 Teamabos + 391 Kundenabos in der Payline = 540 Abos gesamt"** + +## 5. Punkteübersicht: Abo-Bestellungen abgesetzt + +Abo-Lieferungen sind **türkis** mit dem Zusatz **(Abo)**. Legende über der Tabelle: blau = eigene Bestellung, grau = Kundenbestellung, türkis = Abo-Lieferung. + +## 6. Neupartner-Statistik: Telefon und klarere Spalten + +Die **Telefonnummer** ist in Liste und CSV-Export dabei (Festnetz, ersatzweise Mobil). + +Zu deiner Frage nach den beiden Punkte-Spalten — die waren richtig gerechnet, aber schlecht benannt: + +| alt | neu | was drinsteckt | +|---|---|---| +| Kundenpunkte | **Punkte seiner Kunden** | nur Bestellungen seiner Kunden (Shop und Abo) | +| Punkte gesamt | **Gesamt (inkl. Eigenbestellung)** | dazu seine eigenen Bestellungen — vor allem das **Starterpaket** | + +Das ist die Spalte, die wir im August auf deinen Hinweis hin ergänzt haben, damit die Starterpaket-Punkte sichtbar werden. Aus der alten Überschrift war das nicht zu erkennen. + +## 7. Recruiter des Monats: Summenzeile + +Unter der Tabelle steht jetzt das Volumen aller Neupartner des Monats. + +> **Damit du dich nicht wunderst:** Diese Summe ist meist **kleiner** als die Spalte darüber — und das muss so sein. In „neues Volumen" steckt bei jedem Sponsor auch das Volumen der Neupartner, die *seine* Neupartner geworben haben (deine Kettenlogik). Ein Neupartner taucht dadurch zweimal auf. Die Summe zählt jeden **genau einmal**. + +## 8. Eigene Kundenabos: sortierbare Liste statt Karten + +Du hattest recht, bei vielen Abos war das unübersichtlich. Jetzt eine Tabelle mit deinen Spalten: + +**Kunde · Abo-Liefertag · Abo-Punkte · Nächste Ausführung · Startdatum · Ausführungen · Status** + +**Jede Spalte ist sortierbar** — auch nach Startdatum, Anzahl der Ausführungen und Liefertag, wie gewünscht. Dazu ein Suchfeld. Abos mit noch offener Erstzahlung stehen weiterhin als Karte darüber, dort hängen die Aktionsknöpfe dran. + +## 9. Volumenpunkte am 5. / 10. / 20. / 25. + +Vier Kacheln direkt über der neuen Liste: wie viele Punkte bis zum jeweiligen Liefertag aus deinen eigenen Kundenabos noch hereinkommen, kumuliert. + +--- + +# Zwei Punkte liefen schon + +**Pausieren und Stornieren.** Du schreibst: *„das sollte alles automatisiert laufen, läuft es aber meines Wissens noch nicht."* — Doch, und zwar genau nach den Regeln von damals: + +- **Kündigen** ab der **6. bezahlten Lieferung**, danach jederzeit +- **Pausieren** ab derselben Grenze, danach einmal alle **6 Monate** +- Zusätzlich **3 Tage Sperrfrist** vor der Ausführung, damit eine Pause nicht in eine schon angestoßene Lieferung fällt + +Der Berater erledigt das selbst im Account, ohne Mail an euch. Falls euch Fälle begegnen, in denen jemand nicht weiterkommt: schick uns die Abo-Nummer, dann sehen wir konkret nach. + +**Payline-Prognose.** Du schreibst, die Prognose-Punkte stimmen — das deckt sich mit unserer Gegenprüfung. Bleibt unverändert. + +--- + +# Teil 2 — Sieben Fragen, dann bauen wir weiter + +## Frage 1 — Firmenweite Ranglisten: liegt die Anwaltsfreigabe vor? + +**Worum es geht:** Du möchtest „Die besten Ergebnisse des Monats" für die ganze Firma statt teamintern. + +**Warum wir fragen:** Technisch ist das ein kleiner Schritt — wir haben die Karte im August schon umschaltbar gebaut. Inhaltlich ist es aber etwas anderes: Firmenweit sieht jeder Berater Namen, Foto und Umsatzhöhe von Beratern, zu denen er **keine Vertriebsbeziehung** hat. Deine Frage „das müsste ja jetzt auch mit dem Anwalt geklärt sein?" können wir nicht beantworten. + +**Was wir brauchen:** Eine kurze schriftliche Bestätigung, dass das freigegeben ist — plus den **Einwilligungstext fürs Profilfoto**. Der steht seit August auf unserem Platzhalter. Spätestens firmenweit sollte dort euer Text stehen. + +--- + +## Frage 2 — Statistik = Struktur: sollen abgelaufene Berater verschwinden? + +**Worum es geht:** Du hast einen echten Unterschied gefunden. Die beiden Seiten bauen ihren Team-Baum nach verschiedenen Regeln: Die **Struktur** nimmt nur Berater auf, die für die Provision zählen, und lässt alles unterhalb eines ausgefallenen Beraters weg. Die **Statistik** nimmt jeden Teampartner mit. + +An deinem Account: **578 Personen in der Downline, davon 134 nicht im Provisionsbaum.** Am Prüftag war die Punkte-Differenz zufällig null — sichtbar werden kann der Unterschied aber jederzeit. + +**Warum wir fragen:** Dein Vorschlag, die Struktur-Zahlen zu nehmen, ist aus Provisionssicht richtig. Nur: Die Statistik zählt heute bewusst auch Berater mit **abgelaufener Jahresgebühr** mit — genau die Leute, für die wir im August die Jahresgebühr-Nachfassliste gebaut haben. Im Provisionsbaum sind sie nicht drin. Stellen wir um, sind sie aus der Statistik weg. + +**Unsere Empfehlung:** Provisionssicht als Standard, plus einen **Schalter „inklusive abgelaufene"**. Dann stimmen die Zahlen mit der Struktur überein, und die Nachfassliste verliert ihre Grundlage nicht. + +**Und ein Vorschlag zum Vorgehen:** Wir würden **erst messen und dir zeigen**, bevor wir umstellen. Der Baum steckt in praktisch jeder Zahl der Statistik — Berater, Neupartner, Abos, Punkte. Wir wollen dir vorher sagen können, welche Zahl sich um wie viel bewegt, statt dass du es entdeckst. + +--- + +## Frage 3 — Deaktivierung nach 2 Wochen: ist der Kundenübergang endgültig? + +**Worum es geht:** Bei Deaktivierung sollen Kunden und Teampartner an den Sponsor gehen — nach **2 Wochen**, nicht nach 2 Monaten. + +**Warum wir fragen:** Das ist fast fertig im System vorhanden, es passiert heute nur nach 2 Monaten. Vorziehen ist wenig Arbeit. Aber: Die 2-Wochen-Stufe ist heute bewusst **umkehrbar** — wer verspätet zahlt, bekommt seine Teampartner zurück. Ziehen wir den Kundenübergang vor, ist das nach 14 Tagen **endgültig**, auch für jemanden, der am 15. Tag zahlt. + +Dein Text liest sich so, als sei genau das gewollt („seine Teampartner und Kunden sind dann aber schon seinem Sponsor übergegangen"). Bei einer **Jahres**gebühr und nur 14 Tagen Frist möchten wir das trotzdem ausdrücklich bestätigt bekommen — rückgängig machen lässt es sich nicht. + +**Was noch dazugehört:** Die zwei Mails (Vorwarnung an den Berater, Liste an den Sponsor) bauen wir mit. Die Vorwarnung reiht sich in die bestehenden Zahlungserinnerungen ein, damit nicht zwei Mailstrecken nebeneinander laufen. + +--- + +## Frage 4 — Automatische Jahresgebühr: nur neue Berater oder auch Bestand? + +**Worum es geht:** Die Jahresgebühr soll automatisch verlängert und abgebucht werden — eingreifen muss, wer **nicht** verlängern will. + +**Warum wir fragen:** Heute ist das ausdrücklich manuell; im Account steht sogar wörtlich „Eine automatische Verlängerung gibt es nicht." + +Interessant: In eurem System liegen bereits **fertige Texte** für genau dieses Feature — SEPA-Mandat, Abbuchung 14 Tage vor Ablauf, jederzeit widerrufbar. Es war also schon einmal geplant und nicht gebaut. Wir würden darauf aufsetzen. + +**Die Entscheidung:** Für **Bestandsberater** können wir kein Lastschriftmandat rückwirkend annehmen — die müssten aktiv zustimmen. Gilt die automatische Verlängerung also nur für **neue** Registrierungen, oder sollen wir Bestandsberater aktiv anschreiben und umstellen? + +--- + +## Frage 5 — Kundenabo → Partnerabo: drei Detailfragen + +**Worum es geht:** Wird ein Kunde mit laufendem Abo Partner, soll er im Account einen Knopf sehen: „jetzt Kundenabo in Partnerabo ändern" — mit Vorschlag aus dem alten Abo, Kompensationsprodukten und Begründungsfeld. + +**Warum wir fragen:** Der Weg ist klar, drei Dinge müssen aber vorher entschieden sein: + +**(a) Lieferungen:** Zählen die bisherigen Lieferungen für die 6-Lieferungen-Regel weiter, oder fängt die Frist mit dem neuen Partnerabo von vorn an? + +**(b) Umsatz:** Der Kunde hing bisher als Kunde an einem Berater. Wird er selbst Partner, wandert sein Umsatz aus dessen **Kundenpunkten** in seine **Eigenpunkte** — das verschiebt Provisionen und Statistik. Ist das so gewollt? + +**(c) Kompensationsprodukte:** Darf der Partner die selbst eintragen (mit Begründung), oder muss mivita das freigeben? Ohne Freigabe kann sich jeder beliebig Kompensation eintragen. + +--- + +## Frage 6 — Zusatzprodukte bei der Registrierung: zählen die Punkte zum Neupartner-Volumen? + +**Worum es geht:** Bei der Registrierung sollen weitere Produkte rabattiert dazubestellt werden können (mit Starterpaket −30 %, ohne −20 %). + +**Warum wir fragen:** Die Rabattstaffel ist die reguläre Berater-Kondition, die Preislogik gibt es also schon. Offen ist die **Punktewirkung**: Zählen die Punkte dieser Zusatzprodukte wie das Starterpaket zu den **Eigenpunkten des Neupartners** — und damit ins „neue Volumen" seines Sponsors im Recruiter-Ranking? + +Nebenbei zu klären: eine Sendung oder zwei, und was bei Storno/Widerruf passiert, wenn die Registrierung scheitert. + +--- + +## Frage 7 — Liefertag-Vorschau: ohne dein eigenes Abo, richtig? + +**Worum es geht:** Die neuen vier Kacheln über deinen Kundenabos. + +**Warum wir fragen:** Wir haben es so gebaut, wie du geschrieben hast — **nur Kundenabos, ohne dein eigenes Abo**. In der Payline-Prognose auf der Startseite zählt dein eigenes Abo dagegen mit. Das ist bewusst unterschiedlich, aber wenn du es lieber einheitlich hättest, ändern wir es. + +**Unsere Empfehlung:** so lassen. An dieser Stelle geht es um deine Kundenabos, das eigene Abo würde die Zahl nur verwässern. + +--- + +## Zum Schluss + +Schau dir die neun umgesetzten Punkte in Ruhe an. Am meisten hilft uns dein Blick auf die **Aufteilung der Abo-Punkte** (dein 1231-Beispiel) und die **Summenzeile beim Recruiter** — bei beiden weißt du am besten, ob die Größenordnung stimmt. + +Sobald die sieben Fragen beantwortet sind, planen wir die vier großen Themen konkret durch und melden uns mit Aufwand und Reihenfolge. + +Viele Grüße diff --git a/dev/2026-07-22/mivita_Statistiken_07_09_2026.pdf b/dev/2026-07-22/mivita_Statistiken_07_09_2026.pdf new file mode 100644 index 00000000..b4eeefd8 Binary files /dev/null and b/dev/2026-07-22/mivita_Statistiken_07_09_2026.pdf differ diff --git a/dev/2026-08-12-intensiv-tickets/august-anerkennungen.md b/dev/2026-08-12-intensiv-tickets/august-anerkennungen.md new file mode 100644 index 00000000..b6336fdb --- /dev/null +++ b/dev/2026-08-12-intensiv-tickets/august-anerkennungen.md @@ -0,0 +1,55 @@ +bitte prüfe das mal alles hier und korrigiere notfalls namen aber bitte nicht ämdern wie es steht. einfach nur namen und in klammer punkte oder zahl + +liebe grüße - dany + + + + + +Herzlichen Glückwunsch zu euren großartigen Erfolgen! 🥳💙 + + +🏆 Top-Recruiter – August + +Wir gratulieren ganz herzlich unseren Top-Recruitern des Monats August zu dieser großartigen Leistung! + +🥇 Gabi Ramstötter (4) +🥈 Christina Schuder, Sandra Läufle (3) +🥉 Jasmin Grund, Elke Barrabas, Dietmar Kuntz, Nuria Ventimilla Sala, Gabriele Maier (2) + +Herzlichen Glückwunsch zu dieser starken Leistung! 👏🎉 + +🌟 Die meisten neuen Kunden-Abos im August + +Diese Partner haben im August die meisten neuen Kunden-Abos gewonnen. Herzlichen Glückwunsch zu diesem hervorragenden Erfolg! + +🥇 Platz 1: Tanja Longerich (6) +🥈 Platz 2: Claudia Lattner, Marlene Mathis, Rosa Maria Assenbaum, Annette Harsch, Carina Harsch (4) +🥉 Platz 3: Nicola Maier (3) + +💙 Höchster Umsatz mit Kunden-Abos + +Besonders gratulieren wir unseren Partnern mit dem höchsten Umsatz durch Kunden-Abos im Monat August. + +🥇 Platz 1: Tanja Longerich – 1.275 Punkte +🥈 Platz 2: Annette Harsch – 1.129 Punkte +🥉 Platz 3: Claudia Lattner – 1.029 Punkte + +⭐ Die meisten Kunden-Abos insgesamt + +Auch in dieser Kategorie möchten wir unseren erfolgreichsten Partnern ganz herzlich gratulieren. + +🥇 Platz 1: Annette Harsch (21) +🥈 Platz 2: Rosa Maria Assenbaum, Tanja Longerich (16) +🥉 Platz 3: Nina Fleschhut (15) + +🚀 Top 3 durch Kundenumsatz & Recruiting – 1.000-Punkte-Shop + +Diese Partner haben im August durch Kundenumsatz, Eigenumsatz und Recruiting die höchste Punktezahl erreicht – eine großartige Leistung! + +🥇 Platz 1: Daniel Biallowons (5.002 P) +🥈 Platz 2: Claudia Lattner (3.545 P) +🥉 Platz 3: Marlene Mathis (2.887 P) +Platz 4: Christine Königsfeld (2.827 P) + +Wir freuen uns riesig über eure Erfolge und sind sehr stolz auf euch! 💙 diff --git a/dev/2026-08-26-logs/ANALYSE.md b/dev/2026-08-26-logs/ANALYSE.md new file mode 100644 index 00000000..465453e3 --- /dev/null +++ b/dev/2026-08-26-logs/ANALYSE.md @@ -0,0 +1,476 @@ +# Log-Analyse 26.08.2026 + +Auswertung von 94 Logdateien (`abo_order`, `cleanup`, `cron`, `dhl`, `laravel`, `order_controller`, `payment`, `stripe`) aus `dev/2026-08-26-logs/`. +Zeitraum: 05.04.2026 – 26.08.2026 (Schwerpunkt 27.07.–26.08.2026, `laravel.log` reicht ungedreht bis April zurück). + +## Umsetzungsstand (26.08.2026) + +Alle Code-Fixes sind umgesetzt. Der Testlauf zeigt unveraendert 89 Vorbestandsfehler +(BusinessPlan, InvoiceService, ConcurrentPayment – alle bereits vor diesen Aenderungen rot), +bestandene Tests stiegen von 893 auf 914: die 21 neuen Tests laufen, keine Regression. + +| # | Punkt | Status | +| --- | --- | --- | +| P0-1 | `contains()` auf Array | erledigt | +| P0-2 | `V53PAK` → `V53WPAK` | erledigt | +| P0-3 | Zolldaten Schweiz | CH/GB/US/NO gesperrt, ExportDocument offen (eigenes Ticket) | +| P1-1 | Log-Channel `userabo` | erledigt | +| P1-2 | Portal-Alias `user_order_detail` | erledigt | +| P1-3 | `delete-`-E-Mail-Kollision | erledigt | +| P1-4 | Nicht-ASCII-E-Mails | erledigt | +| P1-5 | DHL-Felder normalisieren | erledigt | +| P2-1 | Mailversand in die Queue | erledigt | +| P2-2 | PAYONE-Fehlerbilder | keine Codeaenderung – Kartenablehnungen sind Geschaeftsvorfaelle, `923`/`950` treten nicht mehr auf | +| P2-3 | DHL-Tracking Retry | erledigt | +| P2-4 | Stripe-Webhook-Rauschen | erledigt | +| P2-5 | `KasController` Fatal | **offen: Serverseitig** – Datei ist im Branch bereits geloescht, Deploy-Stand pruefen | +| P2-6 | ImageMagick PDF-Policy | **offen: Serverseitig** – `policy.xml` auf dem Server anpassen | +| P2-7 | Passport Grant Type | Logging entschaerft; ob der `password`-Grant aktiviert werden soll, ist noch zu klaeren | +| P2-8 | Logrotation / Cron-Rauschen | erledigt (Rotation loest sich mit P1-1, Erinnerungs-Logs auf `debug` + Zusammenfassung) | + +## Nach dem Deploy noch zu tun + +1. `php artisan view:clear` – die kompilierte Blade-View des Abo-Checks liegt im Cache. +2. Benutzer 1212 einmalig manuell aufraeumen, damit `user:cleanup` sauber durchlaeuft. +3. Bestehende Umlaut-Adressen pruefen: + `SELECT id, email FROM users WHERE email <> CONVERT(email USING ASCII);` + und dasselbe fuer `shopping_users.billing_email`. +4. ENV auf dem Server: `DHL_ACCOUNT_NUMBER_V53PAK` → `DHL_ACCOUNT_NUMBER_V53WPAK` + (der alte Name wird als Fallback weiterhin gelesen, muss also nicht sofort umgestellt werden). +5. Die 16 im Log genannten Auslandsbestellungen brauchen neue Labels. + +--- + +# Fehlerverteilung (aggregiert) + +| Vorkommen | Meldung | Quelle | +| --- | --- | --- | +| 109 | `Unable to create configured logger` (Log `[userabo]` is not defined) | laravel | +| 89 | `UserMakeAboOrder::makeOrder / makePayment Error response` | laravel | +| 66 | DHL HTTP 400 – davon **33× „The product entered is unknown"** | dhl | +| 53 | `Call to a member function contains() on array` | laravel | +| 24 | `[DHL Service] Shipment creation failed (sync)` | laravel | +| 21 | `[DHL Tracking Service] Unified API returned unexpected status` (HTTP 500) | laravel | +| 18 | `AboRetryPaymentService::retry / makePayment Error` | laravel | +| 17 | `Stripe-Webhook ohne zuordenbare Zahlung` | stripe | +| 9 | `SOAP-ERROR: Parsing WSDL ... kasapi.kasserver.com` | laravel | +| 9 | `Route [user_order_detail] not defined.` | laravel | +| 7 | `deleteInactiveUsers failed ... Duplicate entry 'delete-…'` | cleanup | +| 5 | `The authorization grant type is not supported` (Passport) | laravel | +| 3 | `[DHL Controller] Failed to dispatch: shipping phone darf maximal 20 Zeichen haben` | laravel | +| 2 | Non-ASCII-Zeichen im local-part der E-Mail-Adresse | payment / abo_order | + +--- + +# P0 – Muss sofort behoben werden + +## P0-1 · Abo-Anlage im Portal wirft 500 (`contains()` on array) + +**Belege:** 53×, davon 43× am 24.08. und 10× am 25.08. – der mit Abstand häufigste harte Fehler. Betroffen sind echte Kunden (`userId` 1039, 1174, …). + +``` +production.ERROR: Call to a member function contains() on array + at resources/views/portal/abo/_create_check.blade.php:128 +``` + +**Ursache:** [_create_check.blade.php:128](resources/views/portal/abo/_create_check.blade.php#L128) ruft `->contains(...)` auf dem Rückgabewert von `getContentByOrder()` auf. [Yard.php:695-709](app/Services/Yard.php#L695-L709) liefert aber ein **PHP-Array**, keine Collection: + +```php +public function getContentByOrder() +{ + $ret = []; + // … + $ret = array_merge($ret, $comp); + return $ret; // ← Array, nicht Collection +} +``` + +Eingeschleppt mit Commit `50d55f4` („Abo: Einmalprodukte bereits bei der Abo-Anlage", 21.07.2026) – seitdem bricht die Prüf-/Bestätigungsseite der Abo-Anlage ab, sobald sie gerendert wird. **Der Kunde kann das Abo nicht abschließen.** + +**Lösungsvorschlag (minimal-invasiv, keine Signaturänderung):** + +```blade +@php($hasOneTimeRows = collect(Yard::instance('subscription')->getContentByOrder()) + ->contains(fn ($item) => (bool) ($item->options->abo_addon ?? false))) +``` + +Alternative (sauberer, aber breitere Wirkung): `getContentByOrder()` auf `Illuminate\Support\Collection` umstellen und Rückgabetyp deklarieren. Dann müssen die weiteren Aufrufer geprüft werden – [CheckoutRepository.php:134](app/Repositories/CheckoutRepository.php#L134), [AboController.php:784](app/Http/Controllers/Portal/AboController.php#L784), [OrderPaymentService.php:203](app/Services/OrderPaymentService.php#L203), [PaymentHelper.php:114](app/Services/PaymentHelper.php#L114), [UserMakeOrder.php:303](app/Cron/UserMakeOrder.php#L303) sowie `AboHelper::aboHasBaseProduct()`. + +**Empfehlung:** Erst den Blade-Fix ausliefern (Hotfix), die Typvereinheitlichung separat mit Test. +**Test:** Feature-Test, der die Abo-Anlage bis zum Schritt „check" durchspielt. + +--- + +## P0-2 · Kein einziges Auslandslabel wird erzeugt – falscher DHL-Produktcode + +**Belege:** **33 von 33** Anfragen mit `"product":"V53PAK"` scheitern mit + +``` +"validationMessages":[{"property":"product","validationMessage":"The product entered is unknown."}] +``` + +Durchgehend seit mindestens 27.07.2026, betroffene Bestellungen im Log-Zeitraum: +59704, 59893, 59909, 60011, 60246, 60335, 60342, 60403, 60431, 60534, 60574, 60599, 60628, 60630, 60734, 60945. +Zielländer: 27× AUT, 4× CHE, 2× ESP. + +**Ursache:** Der Produktcode `V53PAK` existiert in der DHL Parcel DE Shipping API v2 nicht. Korrekt ist **`V53WPAK`**. Das steht wörtlich in der im Repo abgelegten API-Spezifikation, [parcel-de-shipping-v2_2.yaml:1231](dev/2026-05-13-dhl-modul/parcel-de-shipping-v2_2.yaml#L1231): + +``` +* V01PAK: DHL PAKET; +* V53WPAK: DHL PAKET International; +enum: [V01PAK, V53WPAK, V54EPAK, V62WP, V62KP, V66WPI] +``` + +Der Rest der Anwendung verwendet bereits korrekt `V53WPAK` – nur der Versand-Pfad nicht. Das ist die eigentliche Inkonsistenz: + +| Verwendet `V53WPAK` (korrekt) | Verwendet `V53PAK` (falsch) | +| --- | --- | +| [ModalController.php:288](app/Http/Controllers/ModalController.php#L288) | [DhlProductResolver.php:15](app/Services/DhlProductResolver.php#L15) `INTERNATIONAL_PRODUCT_CODE` | +| [admin/dhl/create.blade.php:172](resources/views/admin/dhl/create.blade.php#L172), :383 | [config/dhl.php:99](config/dhl.php#L99), :107 | +| `resources/lang/{de,en,es,fr}/dhl.php:27` | [SettingController.php:143](app/Http/Controllers/SettingController.php#L143), :152 | +| | [DhlShipmentWeightCalculator.php:16](app/Services/DhlShipmentWeightCalculator.php#L16) | +| | [DhlModalService.php:319-320](app/Services/DhlModalService.php#L319-L320), :335 | + +**Nebenwirkung:** Weil die Admin-Maske `V53WPAK` sendet, der Resolver aber `V53PAK` erwartet, wirft [DhlProductResolver::resolveProductCode()](app/Services/DhlProductResolver.php#L74) für manuell angelegte Auslandssendungen zusätzlich +`"Produkt V53WPAK ist fuer DHL-Versand in das Zielland … nicht erlaubt."` + +**Lösungsvorschlag:** + +1. `DhlProductResolver::INTERNATIONAL_PRODUCT_CODE = 'V53WPAK'`. +2. Konfig-Keys in [config/dhl.php](config/dhl.php) (`account_numbers`, `dimensions`) auf `V53WPAK` umbenennen, ENV-Namen `DHL_ACCOUNT_NUMBER_V53WPAK` mit Fallback auf `DHL_ACCOUNT_NUMBER_V53PAK`. +3. In `normalizeProductCode()` einen Alias ergänzen, analog zum bestehenden `V62WP → V62KP` in [SettingController::normalizeDhlProductCode()](app/Http/Controllers/SettingController.php#L184-L187): + ```php + $productCode = match ($productCode) { + 'V53PAK' => 'V53WPAK', + 'V62WP' => 'V62KP', + default => $productCode, + }; + ``` + Damit bleiben Alt-Settings (`dhl_account_v53pak` in der `settings`-Tabelle) und gespeicherte Sendungen lauffähig. +4. `DhlShipmentWeightCalculator`, `DhlModalService` und `SettingController` nachziehen. +5. Tests anpassen: `tests/Unit/Dhl/DhlProductResolverTest.php`, `ShippingServiceProductCodeTest.php`, `DhlSanitizeLoggingTest.php`. + +**Die Abrechnungsnummer `63144073555301` ist korrekt** (EKP `6314407355` + Produkt `53` + Teilnahme `01`) und muss nicht geändert werden. + +--- + +## P0-3 · Auslandssendungen in die Schweiz brauchen zusätzlich Zolldaten + +**Beleg:** 4 der gescheiterten Sendungen gehen nach `CHE`. Die Schweiz liegt außerhalb der EU-Zollunion. + +**Ursache:** Für `V53WPAK` in Drittländer verlangt die DHL-API einen `customs`-Block (ExportDocument/CN23) – siehe [parcel-de-shipping-v2_2.yaml:1059](dev/2026-05-13-dhl-modul/parcel-de-shipping-v2_2.yaml#L1059). Im gesamten Code existiert **kein** `customs`/`exportDocument`-Aufbau (`grep customs app/ packages/` → nur VIES-VAT). + +**Lösungsvorschlag:** Nach dem Fix aus P0-2 wird ein Teil der Sendungen weiterhin scheitern. Entweder +(a) `ExportDocument` in [ShippingService::buildPayload()](packages/acme-laravel-dhl/src/Services/ShippingService.php#L535-L555) ergänzen (Positionen aus den Bestellzeilen: Beschreibung, Zolltarifnummer, Menge, Wert, Gewicht, Ursprungsland), oder +(b) `CH` vorerst aus `DEFAULT_INTERNATIONAL_COUNTRIES` / dem Setting `dhl_international_countries` nehmen und dort manuell versenden. + +**Empfehlung:** (b) als Sofortmaßnahme, (a) als eigenes Ticket – nicht mit dem Hotfix vermischen. + +--- + +# P1 – Wichtig, sollte zeitnah behoben werden + +## P1-1 · Log-Channel `userabo` existiert nicht → 109 EMERGENCY-Einträge + +**Beleg:** +``` +laravel.EMERGENCY: Unable to create configured logger. Using emergency logger. + InvalidArgumentException: Log [userabo] is not defined. +``` + +**Ursache:** `MyLog::writeLog('userabo', …)` wird an drei Stellen aufgerufen, aber [config/logging.php](config/logging.php) kennt den Channel nicht (definiert sind: `order_controller`, `abo_order`, `domain`, `payone`, `stripe`, `cleanup`, `payment`, `cron`, `dhl`). + +- [UserMakeAboOrder.php:293](app/Console/Commands/UserMakeAboOrder.php#L293) +- [AboRetryPaymentService.php:202](app/Services/AboRetryPaymentService.php#L202) +- [RetryFailedPaypalAbos.php:209](app/Console/Commands/RetryFailedPaypalAbos.php#L209) + +**Folge:** Jeder Zahlungsfehler erzeugt zwei Einträge statt einem, landet im Fallback-`laravel.log` statt in einer eigenen Datei und verschleiert die eigentliche Meldung. + +**Lösungsvorschlag:** An allen drei Stellen wird direkt darüber bereits sauber geloggt: + +```php +\Log::channel('abo_order')->error('UserMakeAboOrder: Zahlungsfehler', [...]); // existiert +MyLog::writeLog('userabo', 'error', '…', $response); // Channel fehlt +``` + +Der `MyLog`-Aufruf dient hier nur noch dem Mailversand. Sauberste Variante: **`'userabo'` durch `'abo_order'` ersetzen** – dann fällt die doppelte Datei weg und der Mailversand bleibt erhalten. Wer die Trennung will, ergänzt stattdessen einen `userabo`-Daily-Channel in `config/logging.php`. + +--- + +## P1-2 · `Route [user_order_detail] not defined` im Portal + +**Beleg:** 9× am 14.08.2026, `userId` 930. +Stack: `resources/views/portal/abo/my_abo.blade.php:136` → `resources/views/admin/abo/_executions.blade.php:68`. + +**Ursache:** Multi-Tenant-Routing. `user_order_detail` ist nur in [routes/domains/crm.php:196](routes/domains/crm.php#L196) definiert. Die geteilte View `admin.abo._executions` wird aber auch auf der **Portal-Domain** gerendert, wo diese Route nicht geladen ist. In [routes/domains/portal.php](routes/domains/portal.php#L68-L72) steht sogar der passende Kommentar: + +```php +// Routen-Aliase für shared Views (admin.abo._detail, admin.abo._executions) +Route::get('portal/my-data/edit/{id?}', …)->name('user_customer_edit'); +Route::get('portal/my-orders/detail/{id}', …)->name('user_shop_order_detail'); +// ← 'user_order_detail' fehlt +``` + +Der Alias für `is_for === 'ot'` wurde angelegt, der für `is_for === 'me'` vergessen. + +**Lösungsvorschlag:** Alias in `routes/domains/portal.php` ergänzen: + +```php +Route::get('portal/my-orders/own-detail/{id}', [OrderController::class, 'myOrderShow'])->name('user_order_detail'); +``` + +Zusätzlich in `_executions.blade.php` defensiv absichern (`@if (Route::has('user_order_detail'))`), da die View domainübergreifend genutzt wird. + +--- + +## P1-3 · `user:cleanup` scheitert seit 7 Tagen an derselben E-Mail-Kollision + +**Beleg:** Täglich seit 20.08.2026, immer `user_id: 1212`: + +``` +SQLSTATE[23000]: Duplicate entry 'delete-kundin@beispiel.de' +for key 'users.users_email_unique' +``` + +**Ursache:** [UserUtil::deleteUser()](app/Services/UserUtil.php#L180) präfixt die E-Mail fest mit `delete-`: + +```php +$user->email = 'delete-'.$user->email; +``` + +War dieselbe Adresse bereits einmal soft-deleted, kollidiert der zweite Lauf mit dem Unique-Index. Der Cleanup bricht für diesen User ab und wiederholt den Fehler jede Nacht – der Datensatz bleibt dauerhaft ungelöscht. + +**Lösungsvorschlag:** Präfix eindeutig machen und das Zurücksetzen anpassen. `str_replace('delete-', '', …)` reicht dann nicht mehr: + +```php +// UserUtil::deleteUser() +$user->email = 'delete-'.$user->id.'-'.$user->email; + +// neuer Helper +public static function stripDeletePrefix(string $email): string +{ + return preg_replace('/^delete-(\d+-)?/', '', $email); +} +``` + +Anzupassende Stellen: [UserUtil.php:211](app/Services/UserUtil.php#L211) (`checkEmailExists`), [UserUtil.php:229](app/Services/UserUtil.php#L229) (`restoreUser`), [AdminUserCleanupController.php:138](app/Http/Controllers/AdminUserCleanupController.php#L138), [AdminUserController.php:302](app/Http/Controllers/AdminUserController.php#L302). + +Zusätzlich: Länge der `users.email`-Spalte prüfen, das Präfix wächst um bis zu 13 Zeichen. +Für User 1212 einmalig manuell aufräumen, damit der Cronlauf sauber durchläuft. +**Test:** Unit-Test „zweimaliges Löschen derselben Adresse". + +--- + +## P1-4 · E-Mail-Adressen mit Umlauten im local-part brechen Mailversand und Abo-Erinnerung + +**Belege:** + +``` +payment-2026-08-05.log: + ERROR: Invalid billing email at shopping_order 59690 {"billing_email":"rüdiger.beispiel@web.de"} + +abo_order-2026-08-02.log: + ERROR: AboSendExecutionReminder: Versand fehlgeschlagen + {"abo_id":519,"error":"Invalid addresses: non-ASCII characters not supported in local-part of email."} +``` + +**Ursache:** Solche Adressen kommen ins System, weil bei der Registrierung/Bestellung nicht gegen ASCII validiert wird. `filter_var(FILTER_VALIDATE_EMAIL)` und Symfony Mailer lehnen sie später ab. + +- [Payment.php:349-352](app/Services/Payment.php#L349-L352) fängt den Fall ab und schickt an `config('app.checkout_mail')` – **der Kunde erhält seine Zahlungsbestätigung nie.** +- `AboSendExecutionReminder` fängt ihn **nicht** ab, die Ausführungserinnerung fällt komplett aus. + +**Lösungsvorschlag:** + +1. Bei der Eingabe validieren – eigene Rule `AsciiEmail`, angewandt auf Registrierung, Checkout und Profil-Bearbeitung. Verhindert neue Fälle. +2. `AboSendExecutionReminder` genauso absichern wie `Payment::paymentStatusSendMail()` (try/catch + Log), damit ein Datensatz nicht den ganzen Lauf abbricht. +3. Bestand einmalig prüfen: + `SELECT id, email FROM users WHERE email <> CONVERT(email USING ASCII);` + und die Betroffenen anschreiben bzw. korrigieren. + +--- + +## P1-5 · Versand scheitert an zu langen Feldern statt sie zu bereinigen + +**Belege:** + +``` +3× [DHL Controller] Failed to dispatch shipment creation + {"error":"shipping phone darf maximal 20 Zeichen haben.","order_id":"59996"/"60027"/"60837"} +1× [DHL Service] Shipment creation failed (sync) + {"order_id":60496,"error":"consignee.name2 darf maximal 50 Zeichen haben."} +3× DHL: "Parameter refNo (Shipment reference number) must be between 8 and 35 characters long." + → gesendet wurde refNo "NL" (2 Zeichen) +2× DHL: "Please enter a valid phone number." + → gesendet wurde z. B. "03301717813888" +``` + +**Ursache:** Die Telefonnummer ist für DHL ein **optionales** Feld. Trotzdem bricht [DhlShipmentController.php:301](app/Http/Controllers/DhlShipmentController.php#L301) (`'shipping_phone' => 'nullable|string|max:20'`) die komplette Sendungserstellung ab, wenn der Kunde die Nummer mit Leerzeichen/Bindestrichen/Vorwahl-Klammern eingegeben hat. Bei `refNo` fehlt umgekehrt die Mindestlänge – ein manuell eingetragenes „NL" geht ungeprüft an die API. + +**Lösungsvorschlag:** In [DhlAddressValidator](app/Services/DhlAddressValidator.php#L84) bzw. `DhlModalService::prepareAddressForApi()` normalisieren statt validieren: + +- Telefon: Sonderzeichen entfernen (`preg_replace('/[^\d+]/', '', $phone)`), auf 20 Zeichen kürzen; ist das Ergebnis unplausibel (< 6 Ziffern), Feld **weglassen** statt die Sendung scheitern zu lassen. +- `name2`/`name1`: auf die API-Grenzen kürzen (`Str::limit(…, 50, '')`). +- `refNo`: Fallback `Order-{id}` verwenden, sobald die eingegebene Referenz kürzer als 8 Zeichen ist; `'reference' => 'nullable|string|min:8|max:35'` als Validierung ergänzen ([DhlShipmentController.php:289](app/Http/Controllers/DhlShipmentController.php#L289)). + +Grundsatz: Ein schlecht formatiertes optionales Feld darf keinen Versand blockieren. + +--- + +# P2 – Optimierungen / Log-Rauschen + +## P2-1 · `MyLog::writeLog()` verschickt Mails synchron + +[MyLog.php:31-33](app/Services/MyLog.php#L31-L33): + +```php +if ($mail) { + Mail::to(config('app.exception_mail'))->send(new MailLog(...)); // blockierend +} +``` + +`MailLog` nutzt bereits das `Queueable`-Trait, wird aber mit `send()` statt `queue()` verschickt. Bei jedem Zahlungsfehler wartet der Cron auf den SMTP-Roundtrip. + +**Sichtbare Folge:** `UserMakeAboOrder` läuft an Abrechnungstagen **250 s (20.08.)** bzw. **234 s (25.08.)**, an allen anderen Tagen ~50 ms. +Und am 25.08. um 04:00 kippte genau dort der SMTP-Server weg: +`Connection to "w017f6e4.kasserver.com:587" has been closed unexpectedly.` + +**Lösungsvorschlag:** `Mail::to(...)->queue(new MailLog(...))` – Horizon läuft ohnehin. Zusätzlich den Aufruf in try/catch kapseln, damit ein Mailfehler nie den Zahllauf abbricht. + +--- + +## P2-2 · PAYONE-Fehlerbilder aus den Zahlläufen + +89 Fehlerantworten in `UserMakeAboOrder`, 18 in `AboRetryPaymentService`. Nach Ursache getrennt: + +| Code | Anzahl | Bewertung | +| --- | --- | --- | +| `923` Payment type not available for this currency or card type | 39 | **Alle am 05.04.2026**, danach nie wieder → einmaliges Konfigurationsproblem, erledigt | +| `950` Desired status change not possible for this payment process | 20 | Letzter Fall **22.07.2026** → durch das PAYONE-Refactoring offenbar behoben; nach Deploy des aktuellen Branch beobachten | +| `5` / `4` / `43` / `56` / `132` Authorization declined, Card stolen … | 22 | Normale Kartenablehnungen, kein Bug | +| `cardexpiredate faulty` | 3 | Abgelaufene Karte – hier wäre eine **proaktive Erinnerung** vor Ablaufdatum sinnvoll | +| Stripe `card_declined` / `insufficient funds` | 4 | Normale Ablehnung | + +**Optimierung:** Kartenablehnungen sind Geschäftsvorfälle, keine Anwendungsfehler. Sie sollten als `warning` in `abo_order` laufen und nicht per Mail an `exception_mail` gehen – sonst geht der Blick für echte Fehler verloren. Nur „technische" Codes (`923`, `950`, HTTP-/Timeout-Fehler) sollten alarmieren. + +Zusätzlich: Ein Job, der Abos mit demnächst ablaufender Karte erkennt und den Kunden vorab zur Aktualisierung auffordert, würde die 3 `cardexpiredate`-Fälle und einen Teil der Wiederholungsläufe vermeiden. + +--- + +## P2-3 · DHL-Tracking: 21 HTTP-500 der DHL-Unified-API + +``` +WARNING: [DHL Tracking Service] Unified API returned unexpected status +{"tracking_number":"003404350651331…","status_code":500, + "detail":"The server encountered an unexpected condition…"} +``` + +Fremdsystemfehler, kein eigener Bug. Aktuell wird pro Versuch geloggt, ohne Backoff. + +**Lösungsvorschlag:** Retry mit exponentiellem Backoff (`Http::retry(3, 200)`), erst nach dem letzten Fehlversuch als `warning` loggen. Sendungen, die dauerhaft 500 liefern, in ein Feld `tracking_last_error` schreiben statt jeden Lauf neu zu protokollieren. + +--- + +## P2-4 · Stripe-Webhooks ohne zuordenbare Zahlung + +17× `checkout.session.expired`, ausnahmslos ohne `metadata.shopping_payment_id`. +[StripeWebhookHandler.php:181-188](app/Services/Payment/Stripe/StripeWebhookHandler.php#L181-L188) loggt das als `warning` und gibt korrekt `true` zurück (Event gilt als verarbeitet). + +Abgelaufene Checkout-Sessions ohne zugehörige Zahlung sind der Normalfall bei abgebrochenen Bestellungen – kein Fehler. + +**Lösungsvorschlag:** Für `checkout.session.expired` (und `checkout.session.async_payment_failed`) auf `info` herabstufen, `warning` nur für Eventtypen, bei denen eine Zahlung existieren *muss*. + +--- + +## P2-5 · `KasController` läuft auf dem Server in einen Fatal Error + +**Beleg:** 9× am 24./25.08.2026: + +``` +FatalError: SOAP-ERROR: Parsing WSDL: Couldn't load from +'https://kasapi.kasserver.com/soap/wsdl/KasAuth.wsdl' +at app/Http/Controllers/Api/KasController.php:75 +``` + +**Zwei Punkte:** + +1. Die Datei existiert im aktuellen Branch **nicht mehr** – sie wurde mit Commit `d4e9d10` entfernt und liegt nur noch unter `dev/app-bak/`. Auf dem Server ist sie also noch deployt. **Deploy-Stand prüfen.** +2. Der Code behandelt SOAP-Fehler mit `trigger_error(..., E_USER_ERROR)` ([dev/app-bak/.../KasController.php:60](dev/app-bak/Http/Controllers/Api/KasController.php#L60)) – das erzeugt einen nicht abfangbaren Fatal Error statt einer 5xx-Antwort. Falls der Controller wieder gebraucht wird: `SoapFault` per try/catch fangen und eine JSON-Fehlerantwort liefern. + +**Kontext:** Alle Vorfälle liegen im Fenster 24.08. 23:35 – 25.08. 00:25, zeitgleich mit +`Connection could not be established with host "w017f6e4.kasserver.com:587"` und +`[payment:check-uptime] Outage-Incident #4 für payone` (24.08. 23:50). +→ Eine Netzwerkstörung beim Hoster, kein Anwendungsfehler. Nur die fehlende Fehlerbehandlung ist unser Problem. + +--- + +## P2-6 · ImageMagick verbietet PDF-Verarbeitung + +**Beleg:** 13.08.2026, 10:31: + +``` +ERROR: PDF Verarbeitung fehlgeschlagen: attempt to perform an operation + not allowed by the security policy `PDF' @ error/constitute.c/IsCoderAuthorized/426 +ERROR: Thumbnail creation failed: … +``` + +**Ursache:** Serverkonfiguration, nicht Code. Die ImageMagick-Policy (`/etc/ImageMagick-6/policy.xml`) sperrt den PDF-Coder – Standard seit CVE-2018-16509. + +**Lösungsvorschlag:** Auf dem Server das PDF-`rights="none"` in der Policy auf `read|write` setzen und Ghostscript aktuell halten. Im Code zusätzlich prüfen, ob die PDF-Verarbeitung verfügbar ist, und bei fehlender Berechtigung ein Platzhalter-Thumbnail liefern statt zu scheitern. + +--- + +## P2-7 · Passport: nicht unterstützter Grant Type + +5 Vorfälle (17.08., 22.08. 04:03 und 05:53, …): +`Laravel\Passport\Exceptions\OAuthServerException: The authorization grant type is not supported`. + +Kein Stacktrace bis in Anwendungscode. Vermutlich ein API-Client, der einen nicht aktivierten Grant (z. B. `password`) anfordert, oder Scan-Traffic auf `/oauth/token`. + +**Lösungsvorschlag:** In [AuthServiceProvider](app/Providers/AuthServiceProvider.php) prüfen, welche Grants freigeschaltet sind, und mit den API-Clients abgleichen. Falls es Fremdtraffic ist: `/oauth/token` als 4xx behandeln und nicht als `ERROR` loggen (Rate-Limit ergänzen). + +--- + +## P2-8 · Logrotation und Log-Rauschen + +**`laravel.log`** ist 572 KB groß und enthält Einträge von **05.04.2026 bis 25.08.2026** – die tägliche Rotation greift hier nicht, weil `MyLog` über nicht existierende Channels in den Fallback schreibt (siehe P1-1). Nach dem Fix von P1-1 löst sich das mit auf; zusätzlich `LOG_CHANNEL=daily` und `LOG_DAILY_DAYS` prüfen. + +**`laravel-2026-08-24.log`** ist 972 KB bei nur **50 Fehlern** – 5.868 von 6.054 Zeilen sind Stacktraces. Die 43 identischen `contains()`-Traces machen den Großteil aus; mit P0-1 verschwindet das. + +**Cron-Rauschen:** Die Zahlungserinnerungen loggen jeden geprüften Benutzer auf `INFO`: + +| Anzahl | Meldung | +| --- | --- | +| 488 | `Prüfe Zahlungserinnerungen für Benutzer` | +| 429 | `Erinnerung bereits gesendet für Benutzer` | +| 189 | `Zweite Erinnerung für Benutzer` | +| 146 | `Inaktives Konto für Benutzer` | + +Über 1.200 Zeilen pro Woche für ~59 tatsächlich versandte Mails. +**Lösungsvorschlag:** „geprüft"/„bereits gesendet"/„inaktiv" auf `debug` setzen, am Ende des Laufs eine Zusammenfassung auf `INFO` schreiben (`geprüft: X, versendet: Y, übersprungen: Z`). Tatsächlich versandte Erinnerungen bleiben auf `INFO`. + +--- + +# Hinweise ohne Handlungsbedarf + +- **`BusinessStoreOptimized: NOT EXECUTED - wrong day (expected: 1, today: N)`** – korrektes Verhalten, die Provisionsberechnung läuft nur am 1. des Monats. Der Eintrag könnte auf `debug`. +- **`User #… attempted to create customer abo but one is already initiated`** (16× in `order_controller`) – die Schutzlogik greift wie vorgesehen. Auffällig ist nur, dass mehrere User es zweimal versuchen; ggf. ist die Rückmeldung in der Oberfläche zu unauffällig. +- **`Shipping country not found for user #1056, country: ` (leer)** – 2×, Stammdaten ohne Land. Einzelfall, Datensatz korrigieren. +- **`SponsorBuyingPointsRepair: VP-Buchung liegt bei anderem Berater — manuell pruefen (Umhaengung?)`** (26.08., `shopping_order_id: 58797`, `buyer_id: 1426`, `current_sponsor_id: 1241`, `booked_sponsor_id: 6`) – bewusst als Warnung ausgelegt, verlangt fachliche Prüfung, keinen Code-Fix. +- **`payments:check-accounts`** läuft täglich in 0–2 s durch, ohne Fehler. + +--- + +# Empfohlene Reihenfolge + +| # | Maßnahme | Aufwand | Wirkung | +| --- | --- | --- | --- | +| 1 | P0-1 `collect()` im Blade | Minuten | Abo-Anlage im Portal funktioniert wieder | +| 2 | P0-2 `V53PAK` → `V53WPAK` | ~1 h inkl. Tests | Auslandsversand funktioniert wieder | +| 3 | P1-3 `delete-`-Kollision | ~1 h | Nächtlicher Cleanup läuft wieder durch | +| 4 | P1-1 Log-Channel `userabo` | Minuten | 109 EMERGENCY-Einträge weg, saubere Logs | +| 5 | P1-2 Portal-Alias `user_order_detail` | Minuten | Kein 500 mehr auf „Meine Abos" | +| 6 | P1-5 DHL-Felder normalisieren | ~2 h | Keine blockierten Sendungen durch Telefonnummern | +| 7 | P1-4 ASCII-Validierung E-Mail | ~2 h | Kunden erhalten ihre Bestätigungen | +| 8 | P2-1 Mailversand in die Queue | ~30 min | Zahllauf von 250 s auf Sekunden | +| 9 | P0-3 Zolldaten CH | eigenes Ticket | Schweiz-Versand | +| 10 | P2-3/4/8 Log-Level und Retries | ~2 h | Auswertbare Logs | diff --git a/dev/2026-09-10-business-plan/README.md b/dev/2026-09-10-business-plan/README.md new file mode 100644 index 00000000..d3a6dc0b --- /dev/null +++ b/dev/2026-09-10-business-plan/README.md @@ -0,0 +1,18 @@ +# Business-Plan: Berechnung und Auswertung optimieren + +Entwicklungsordner für die Umstellung der Business-Plan-Berechnung (Provision, Team-Struktur, +Level, Punkte) von „bei jedem Aufruf rekursiv rechnen" auf ein fortlaufend gepflegtes, +persistentes Zahlenwerk, das parallel zum bestehenden Modell läuft, bis beide gleich sind. + +| Dokument | Inhalt | +| --- | --- | +| [entwicklungsplan-business-ledger.md](entwicklungsplan-business-ledger.md) | Entwicklungsplan Version 2 (nach Analyse): Ist-Analyse, Zielbild, Datenmodell, Ereignisse, Nachweis, Phasen, Entscheidungen, Regelkatalog, Testmatrix | +| [analyse-entwicklungsplan-business-ledger.md](analyse-entwicklungsplan-business-ledger.md) | Analyse von Codex zu Version 1 (Befunde B01 – B14), am Code bestätigt und in Version 2 eingearbeitet | +| `known-differences.md` (entsteht in Phase 0) | Abweichungen zwischen Legacy und Ledger mit Einordnung und Entscheidung | +| `compare-YYYY-MM.md` (entsteht ab Phase 1) | Zusammenfassung der Vergleichsläufe je Monat | + +Bezug zu bestehender Doku: + +- `app/Services/BusinessPlan/SYSTEM-OVERVIEW.md` – Beschreibung des heutigen Modells +- `dev/2026-07-22/monatsabschluss-eingefrorene-statistik.md` – Entscheidung „abgeschlossene Monate sind eingefroren" (gilt auch für den Ledger) +- `dev/2026-07-22/audit-statistiken-2026-09-09-bewertung.md` – offene Befunde A07/K11, die der Folgeschritt (Statistik auf Ledger) löst diff --git a/dev/2026-09-10-business-plan/analyse-entwicklungsplan-business-ledger.md b/dev/2026-09-10-business-plan/analyse-entwicklungsplan-business-ledger.md new file mode 100644 index 00000000..8902150c --- /dev/null +++ b/dev/2026-09-10-business-plan/analyse-entwicklungsplan-business-ledger.md @@ -0,0 +1,311 @@ +# Analyse des Entwicklungsplans Business-Ledger + +Stand: 10.09.2026 · Prüfung: Codex · Branch: `payment/phase-r-payone-refactoring` · HEAD: `2f21bd4` + +Geprüft wurde der [Entwicklungsplan](entwicklungsplan-business-ledger.md) gegen den **aktuellen Arbeitsstand einschließlich vorhandener uncommitteter Änderungen**. Die Bewertung betrifft Architektur, tatsächliche Berechnungsregeln, Schreibpfade, historische Rekonstruktion, Monatsabschluss und Tests. Produktivdaten, das Mengengerüst der Entwicklungsdatenbank und Produktionslaufzeiten wurden nicht überprüft. Zahlenbeispiele unten sind synthetisch und enthalten keine Kundendaten. + +## 1. Einschätzung + +**Die Richtung ist richtig; als unmittelbar umsetzbare Spezifikation ist der Plan noch nicht ausreichend abgesichert.** Persistente Monatsergebnisse, getrennte Regelklassen, ein zunächst rein beobachtender Parallelbetrieb und feldweise Vergleiche sind sehr gute Grundlagen. Ich würde das Vorhaben weiterverfolgen, aber vor dem Kernbau eine verbindliche Phase zur Regel- und Datenvalidierung einschieben. + +Die größten Risiken liegen nicht im Schreiben einer rekursiven SQL-Abfrage. Sie liegen darin, **welcher Eingangsstand berechnet wird, welche tatsächlichen Legacy-Regeln nachgebildet werden und wann ein Ergebnis vollständig und endgültig ist**. Hier verspricht der Plan stellenweise mehr Parität und Ausfallsicherheit, als die vorgeschlagene Verarbeitung gewährleistet. + +| Priorität | Befund | Konsequenz | +| --- | --- | --- | +| P1 | Negative Team-Punkte werden im Legacy gefiltert, in der geplanten CTE summiert | Unterschiedliche Qualifikation und Provision | +| P1 | Growth-Rundungsgruppen sind unvollständig beschrieben | Cent-Abweichungen auch bei sehr kleinen Bäumen | +| P1 | Observer, Entprellung und `dirty` sichern keinen vollständigen Eingangsstand | Veraltete Ergebnisse trotz erfolgreich verarbeitetem Job | +| P1 | Gleichzeitige Ketten teilen Vorfahren, aber keinen gemeinsamen Schutz | Ein älterer Lauf kann ein neueres Ergebnis überschreiben | +| P1 | Historische Strukturen und Regelstände sind nicht vollständig gesichert | Zehn Monate Backfill sind kein automatisch belastbarer Regressionstest | +| P1 | Monatsgrenze und Abschlussstatus sind widersprüchlich definiert | Vergleich verschiedener Stände oder Einfrieren unvollständiger Ergebnisse | +| P2 | CTE pro Vorfahr traversiert weiterhin dessen Downline | Das Laufzeitversprechen hängt nicht nur von der Kettenlänge ab | +| P2 | SQLite-/Queue-Annahmen passen nicht vollständig zur Anwendung | Umsetzung und Tests scheitern ohne zusätzliche Konfiguration | +| P2 | Schnittstellen- und Datenmodellvertrag ist für den Cutover unvollständig | Zusätzliche Arbeit bei Ansichten und Abschluss-Export | + +P1 bedeutet hier: vor produktiver Nutzung zwingend lösen, bei Regelabweichungen schon vor Erstellung verbindlicher Golden Files. P2 bedeutet: vor der jeweiligen Implementierungsphase konkretisieren. + +## 2. Konkrete Befunde im Code + +### B01 – Die CTE bildet negative Team-Punkte nicht wie Legacy ab (P1) + +**Plan:** `SUM(n.tp_sum)` aller Nachkommen. + +**Code:** Sowohl `TreeCalcBotOptimized::calculateUserPointsOptimized()` als auch `collectChildrenPointsForUser()` addieren nur Werte mit `$points > 0`. `GrowthBonusCalculator::getVolumeByProtectionLevel()` übernimmt eigenes Volumen ebenfalls nur bei `> 0`. + +Belege: [TreeCalcBotOptimized.php](../../app/Services/BusinessPlan/TreeCalcBotOptimized.php), Zeilen 279–301 und 845–861; [GrowthBonusCalculator.php](../../app/Services/BusinessPlan/GrowthBonusCalculator.php), Zeilen 277–285. + +Das ist angesichts der Stornoregel relevant: `cancelSalesPointsVolume()` kann negative Punkte im laufenden Monat erzeugen, insbesondere bei aktivierter Umbuchung von Stornos älterer Monate. Entscheidend ist dabei der **kumulierte TP-Saldo je Person**, nicht das Vorhandensein einer einzelnen negativen Buchung. + +Reproduziert mit zwei Kindern, deren Monatssalden `100` und `-40` betragen: + +| Berechnung | Team-Punkte | +| --- | ---: | +| Bestehende Aggregationsmethode | 100 | +| Vorgeschlagene vorzeichenbehaftete SQL-Summe | 60 | + +**Empfehlung:** Für eine ausdrückliche Paritätsversion positive Monatssalden mit `CASE WHEN tp_sum > 0 THEN tp_sum ELSE 0 END` aggregieren. Eigene KP/TP bleiben davon getrennt. Ob negative Salden künftig die Upline mindern sollen, ist eine fachliche Änderung und muss als solche entschieden werden. Entsprechende Fixtures gehören in Phase 0. + +### B02 – Growth benötigt mehr als eine Gruppierung nach Schutzprozentsatz (P1) + +Anhang B nennt korrekt die Rundung je direktem Kind und Schutzgruppe. Abschnitt 4 beschreibt dagegen nur eine Verteilung `[schutz_pct => volumen]` und `GROUP BY protection`. Dabei geht die Firstline-Zuordnung verloren. + +Zusätzlich enthält das Legacy eine Besonderheit, die der Plan gar nicht erfasst: Eigenvolumen wird unter dem PHP-Array-Schlüssel `'0.0'` gespeichert; ungeschütztes Nachkommenvolumen landet über `(string) $effectiveProtection` unter `'0'`. Das sind unterschiedliche Schlüssel und damit unterschiedliche Rundungsgruppen. + +Belege: [GrowthBonusCalculator.php](../../app/Services/BusinessPlan/GrowthBonusCalculator.php), Zeilen 45–60, 282–284 und 305–318. + +Reproduziert mit der bestehenden Klasse, jeweils 3,5 % Growth: + +| Fall | Legacy | Zusammengefasste SQL-Gruppe | +| --- | ---: | ---: | +| Zwei direkte Kinder mit je 0,10 Punkten | 0,00 € | 0,01 € | +| Ein direktes Kind mit 0,10 Eigenpunkten und 0,10 ungeschützten Nachkommenpunkten | 0,00 € | 0,01 € | + +Die zweite Abweichung besteht auch dann noch, wenn SQL bereits nach Firstline und numerischem Schutz gruppiert. + +Außerdem rundet `getMatrixDetails()` je Person; der auszahlungsrelevante Growth-Betrag rundet je Verteilungsgruppe. Reproduzierter Fall: Ein direktes Kind mit 0 Punkten und zwei Kindern mit je 0,10 Punkten ergibt **0,01 € Growth, aber 0,00 € Matrixsumme**. Der Kommentar im Code, die Matrix passe exakt zur Gesamtsumme, ist dafür kein Nachweis. + +Belege: dieselbe Datei, Zeilen 138–177 und 197–239. + +**Empfehlung:** Den Rundungsvertrag ausdrücklich festhalten: Firstline, Schutzgruppe, Legacy-Sondergruppe, Rundungsmodus und Zeitpunkt. Betragsvergleich und Matrixvergleich getrennt durchführen. Eine Vereinheitlichung der Null-Prozent-Gruppen oder Matrixrundung als bewusste Korrektur behandeln; sie darf nicht unbemerkt in einer vermeintlichen 1:1-Neuimplementierung passieren. + +### B03 – Ein `saved`-Observer sieht Zwischenstände der Punkteberechnung (P1) + +`addSalesPointsVolume()`, `editSalesPointsVolume()` und `cancelSalesPointsVolume()` speichern zuerst die Buchung und rufen **danach** die Neuberechnung auf. Diese speichert jede Monatszeile einzeln. Die vorgeschlagenen Observer würden somit sowohl den unfertigen Anfangszustand als auch zahlreiche Zwischenstände sehen. + +Beleg: [SalesPointsVolume.php](../../app/Services/BusinessPlan/SalesPointsVolume.php), Zeilen 77–150, 251–253, 277–293 und 388–412. + +Ein fünf Sekunden verzögerter Job ist keine garantierte Abschlussgrenze. Bei langen Transaktionen, parallelen Buchungen oder im vorgeschlagenen Sync-Modus kann er zu früh rechnen. `afterCommit` allein genügt ebenfalls nicht, wenn Buchung und Laufstandberechnung gar nicht dieselbe Transaktion bilden. Ein konkretes Beispiel für getrennte Grenzen ist [RepairAboOrderUserAssignment.php](../../app/Console/Commands/RepairAboOrderUserAssignment.php), Zeilen 77–118: Umbuchung in der Transaktion, Laufstandkorrektur erst danach. + +Der letzte kumulierte Datensatz ist außerdem nur dann eine belastbare Quelle, wenn seine Berechnung selbst korrekt abgeschlossen ist. Ein direkter Rohdaten-Edit ohne anschließende Laufstandkorrektur wird nicht dadurch geheilt, dass der Ledger denselben veralteten Laufstand nochmals liest. + +**Empfehlung:** Einen fachlichen Abschluss des Schreibvorgangs definieren: Buchungsänderung, Laufstandberechnung und dauerhaft gespeicherte Änderungsrevision gehören zusammen. Erst danach verarbeitet der Ledger diese Revision. Observer können zusätzliche Änderungen erkennen; sie ersetzen diesen Vertrag nicht. Der Audit sollte zusätzlich Rohbuchungen gegen Monatslaufstände prüfen können, ohne abgeschlossene Monate automatisch umzuschreiben. + +### B04 – Umbuchungen, Neuanlage und leere Quellmonate brauchen eigene Behandlung (P1) + +`changeSalesPointsVolumeUser()` berechnet zwar beide Benutzer neu. Wird aber die **letzte** Monatszeile von A nach B verschoben, läuft die Schleife für A leer: Dort entsteht kein weiteres `saved`-Ereignis. Ein Observer, der nur die neue `user_id` verarbeitet, lässt A dauerhaft falsch stehen. + +`setToUserAndReCalculate()` kann gleichzeitig Benutzer, Monat, Jahr und Status ändern. Deshalb müssen sowohl der alte als auch der neue Schlüssel `(user_id, year, month)` erfasst werden. Ein Wechsel zu `user_id = NULL` darf die Bereinigung des alten Benutzers ebenfalls nicht unterdrücken. + +Belege: [SalesPointsVolume.php](../../app/Services/BusinessPlan/SalesPointsVolume.php), Zeilen 24–58, 80–89 und 193–209. + +Der vorgeschlagene User-Observer nennt `updated`, `deleted`, `restored`, aber kein `created`. Das reicht insbesondere nicht für die versprochene vollständige Struktur aller Benutzer. Erstellungspfade existieren unter anderem in [Auth/RegisterController.php](../../app/Http/Controllers/Auth/RegisterController.php), Zeile 72, und [Api/AuthController.php](../../app/Http/Controllers/Api/AuthController.php), Zeile 97. Auch `UserLevel`-Neuanlage und -Löschung müssen Teil der Regeländerungserkennung sein. + +**Empfehlung:** Eine Schreibpfadmatrix mit alten/neuen Schlüsseln erstellen. Leere Monate ausdrücklich auf null setzen und ihre Vorfahren neu berechnen. Bei Änderungen von `payment_account`, `admin`, `active_date` oder Löschstatus auch den betroffenen Benutzer selbst aktualisieren, nicht ausschließlich alte/neue Sponsoren. Massenupdates, SQL-Reparaturen und unterdrückte Model-Events separat behandeln; Eloquent-Massenupdates lösen keine einzelnen Model-Events aus. Siehe [Laravel: Model Events](https://laravel.com/docs/11.x/eloquent#events). + +### B05 – `ShouldBeUnique` und ein boolesches `dirty` verhindern keine verlorenen Aktualisierungen (P1) + +Der Unique-Schlüssel gilt für den auslösenden Benutzer. Zwei Geschwister erzeugen unterschiedliche Jobs, die denselben Sponsor neu berechnen. Eine Transaktion je Knoten macht dessen Schreiben atomar, verhindert aber ohne zusätzlichen Vertrag nicht die Veröffentlichung eines Ergebnisses aus veralteten Eingaben. + +Auch derselbe Job kann eine neue Änderung verlieren: + +1. Job liest Eingangsstand R1. +2. Neue Buchung erzeugt R2 und setzt `dirty = 1`; ein weiterer Unique-Job wird unterdrückt. +3. Der erste Job speichert R1 und setzt `dirty = 0`. +4. Der Fallback findet keinen offenen Knoten mehr. + +Das ist ein mögliches Rennen des vorgeschlagenen Designs, kein hier nachgewiesener Produktionsvorfall. Laravel beschreibt Unique Jobs als Sperre gegen erneutes Dispatching desselben Schlüssels; daraus folgt keine Serialisierung verschiedener, sich überschneidender Ketten. Siehe [Laravel: Unique Jobs](https://laravel.com/docs/11.x/queues#unique-jobs). + +**Empfehlung:** `input_revision` und `calculated_revision` statt ausschließlich `dirty`. Ein Lauf darf den Bearbeitungsbedarf nur quittieren, wenn die verarbeitete Revision noch aktuell ist. Ein Ereignis muss vor der Verarbeitung dauerhaft vorliegen; ein erst nach erfolgreichem Job geschriebenes Journal kann einen vorher verlorenen Job nicht nachweisen. Die Sperrstrategie muss gemeinsame Vorfahren, Strukturwechsel und Rebuilds einschließen. + +Für den Einstieg würde ich pro Monat eine serialisierte Verarbeitung und zusammengefasste Änderungsmengen bevorzugen. Mehrere Worker erst zulassen, wenn Revisionen, Sperrreihenfolge und Wiederholungen mit echten Konkurrenztests abgesichert sind. Sync-Ausführung umgeht keine dieser fachlichen Anforderungen. + +### B06 – Der historische Backfill hat keine garantiert vollständige Quelle (P1) + +Der Plan erkennt selbst, dass Parentless-Benutzer im Gesamtbaum ohne geladenen Teilbaum bleiben. Genau dieser Gesamtbaum wird jedoch als `structure`/`parentless` gespeichert. Einzelne `user_businesses` werden anschließend mit ihrem jeweils separat aufgebauten Live-Teilbaum berechnet. Damit können Struktur-JSON und Einzelabrechnung unterschiedliche Informationsmengen enthalten. + +Belege: [TreeCalcBotOptimized.php](../../app/Services/BusinessPlan/TreeCalcBotOptimized.php), Zeilen 557–565 und 604–621; [BusinessUsersStoreOptimized.php](../../app/Cron/BusinessUsersStoreOptimized.php), Zeilen 44–48, 100–116 und 226–255. + +Eine flache Parentless-Liste enthält zwar Benutzer-IDs, aber nicht automatisch deren damalige Beziehungen. Der geplante Fallback `user_businesses.m_sponsor_id` ist ebenfalls nicht gesichert: `initializeFromUserModel()` füllt dieses Feld nicht. `checkSponsor()` schreibt das JSON-Feld **`sponsor`**. Das Model führt `sponsor`, aber nicht `m_sponsor_id` in `$fillable`. + +Belege: [BusinessUserItemOptimized.php](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php), Zeilen 183–225 und 1015–1058; [UserBusiness.php](../../app/Models/UserBusiness.php), ab Zeile 178. + +Hinzu kommt: `user_items` speichert zwar eine individuelle Struktur, verwendet für deren Punkte aber `sales_volume_points_sum`, während die aktive Berechnung KP-/TP-spezifische Felder nutzt. Dieses JSON ist ohne gesonderte Prüfung keine verlässliche historische Punktequelle. Siehe [BusinessUsersStoreOptimized.php](../../app/Cron/BusinessUsersStoreOptimized.php), Zeilen 181–186. + +**Empfehlung:** Vor Backfill einen Herkunftsreport je Monat erstellen: Strukturabdeckung, Sponsor-JSON, individuelle `user_items`, fehlende/mehrdeutige Beziehungen und Widersprüche zwischen Quellen. Nicht rekonstruierbare Fälle als solche ausweisen. Aktuelle `users.m_sponsor` dürfen historische Lücken nicht stillschweigend schließen. Zehn Monate Vergleich bleiben ein Ziel, ihre vollständige Rekonstruierbarkeit ist erst zu beweisen. + +### B07 – `calc_version` ersetzt keinen historischen Regelstand (P1) + +`user_businesses` enthält Level-IDs und einige Level-Objekte, aber keinen garantierten vollständigen Snapshot der damaligen `user_levels`-Tabelle. Für Kandidatenauswahl, Karriere-Deckelung, Shop-Marge und nächste Stufen benötigt der neue Rechner mehr als das gespeicherte erreichte Level. + +Ein Observer auf `UserLevel` korrigiert den offenen Monat. Er macht spätere Rebuilds alter Monate mit inzwischen geänderten Schwellen, Positionen oder Margen jedoch nicht reproduzierbar. Ab 11/2025 dieselbe Growth-Verzweigung zu verwenden beweist auch nicht, dass sämtliche übrigen Regeln und Berechnungsfehler seitdem unverändert waren. + +Belege: [BusinessUserItemOptimized.php](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php), Zeilen 167–217, 748–759, 833–835, 897–899 und 924–925. + +**Empfehlung:** Unveränderliche Regelstände mit allen relevanten Level-Feldern speichern und per `ruleset_id` referenzieren. Algorithmusversion, Regelstand und Eingangsstand getrennt führen. Für alte Monate die Verfügbarkeit der benötigten Regeln im Backfill-Bericht benennen. Gespeicherte eigene Monatspunkte aus `user_businesses` und heutige Journal-Laufstände zunächst separat vergleichen, bevor man sie als identische Quelle behandelt. + +### B08 – Monatsgrenze und Freeze passen noch nicht zum tatsächlichen Abschluss (P1) + +Der Plan lässt den neuen Monat um 00:10 starten, erklärt anderswo den Vormonat um 00:10 für strukturell eingefroren und beschreibt zugleich einen Registry-Status `frozen` erst nach erfolgreichem Vergleich. Diese drei Vorgänge brauchen getrennte, eindeutige Bedeutungen. + +Der bestehende Scheduler startet `business:store-optimized` täglich um 03:00; **der Command selbst** prüft das Setting `day-exectute-business-structur`. Tag 1 ist nur der Standard. Ein starrer Vergleich am Ersten um 06:00 kann daher vor dem tatsächlich konfigurierten Abschluss liegen. + +Belege: [Kernel.php](../../app/Console/Kernel.php), Zeilen 46–52; [BusinessStoreOptimized.php](../../app/Console/Commands/BusinessStoreOptimized.php), Zeilen 68–86 und 127–171. + +Weitere relevante Abläufe: + +- Der Scheduler repariert Sponsor-VP um 02:30 vor dem Abschluss; Cleanup ist um 03:30 vorgesehen. Zwischenlagen und Reparaturen gehören in den gemeinsamen Stichtagsvertrag. +- Strukturaufbau und die nachfolgenden Einzelberechnungen haben keinen gemeinsamen unveränderlichen Eingangs-Snapshot. +- Im Command folgen auf die Ergebnisberechnung Gutschriften, Karriere-Upgrades und KP-Bonusbuchungen für den neuen Monat. +- Ein späterer Live-Vergleich des Vormonats kann bereits die neuen Karriere-Level verwenden. +- `SalesPointsVolume::isStornoMonthOpen()` prüft den **Kalendermonat**, nicht `user_business_structures.completed` oder eine Ledger-Registry. + +Damit ist ein Sponsorwechsel zwischen 00:10 und 03:00 keineswegs der einzige mögliche Paritätsbruch. Belege zusätzlich: [SalesPointsVolume.php](../../app/Services/BusinessPlan/SalesPointsVolume.php), Zeilen 328–333; [BusinessStoreOptimized.php](../../app/Console/Commands/BusinessStoreOptimized.php), Zeilen 393–430. + +**Empfehlung:** Einen Abschlusslauf mit `run_id`, fachlicher Zeitzone, Eingangsrevision und definiertem Stichtag modellieren. Im Shadow-Betrieb den damaligen Eingangsstand vor Karriere-Upgrades erfassen. Ein Vergleich startet abhängig vom erfolgreichen Abschlusslauf, nicht nur nach Uhrzeit. Kalenderwechsel, Annahmestopp, fachliche Abrechnung und geprüfte Veröffentlichung getrennt beschreiben. Neue Monatsanlage muss idempotent sein und bereits vorhandene Buchungen sowie lazy angelegte Datensätze erhalten. + +### B09 – `completed = 1` ist kein hinreichender Erfolgsnachweis (P1) + +`storeBusinessUsersDetail()` markiert einen Benutzer auch bei einer Exception oder fehlendem User als erledigt. `storeBusinessCompleted()` prüft danach nur, ob noch Nullen in dieser Bearbeitungsliste stehen. Ein fehlender Ergebnisdatensatz kann deshalb mit einer abgeschlossenen Struktur zusammenfallen. + +Beleg: [BusinessUsersStoreOptimized.php](../../app/Cron/BusinessUsersStoreOptimized.php), Zeilen 73–93 und 146–167. Zusätzlich fängt `calcQualPP()` Fehler intern ab, Growth kann bei Fehlern null Euro liefern: [BusinessUserItemOptimized.php](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php), Zeilen 676–709 und 757–772. + +**Empfehlung:** Der Vergleich muss die erwartete Benutzer-ID-Menge, fehlende und doppelte Ergebnisse sowie Fehlerzustände prüfen. Gleiche Anzahlen genügen nicht. Ein Monatsabschluss darf erst freigegeben werden, wenn alle erwarteten Ergebnisse erfolgreich berechnet und alle erforderlichen Revisionen verarbeitet wurden. Beim späteren `--engine=both` beide Rechnungen nebenwirkungsfrei erstellen und vergleichen; anschließend genau einmal Ergebnisse veröffentlichen, Gutschriften anlegen und Karriere-Upgrades ausführen. + +`UserPaymentCredits` prüft vorhandene Gutschriften anhand der `user_business_id` und legt nur positive Komponenten an. Ein Löschen/Neuanlegen von Monatszeilen ist deshalb kein neutraler Rollback. IDs und Gutschriftzuordnung müssen erhalten bleiben. Beleg: [UserPaymentCredits.php](../../app/Cron/UserPaymentCredits.php), Zeilen 40–75 und 102–104. + +### B10 – Die Kettenlänge beschreibt nicht die Rechenkosten (P2) + +Die vorgeschlagene CTE liest für **jeden** betroffenen Vorfahren erneut dessen Teilbaum. Bei einer Änderung tief im Team wird der große Teilbaum des obersten Sponsors weiterhin durchlaufen, für Ebenen und Growth jeweils erneut. Die Anzahl neu geschriebener Kontozeilen sinkt; die Zahl gelesener Nachkommen sinkt nicht automatisch auf 5–30. + +Das kann gegenüber N+1-Abfragen trotzdem deutlich schneller sein. Die Aussagen „im Request tragbar“, p95 unter 500 ms und Abschluss in Sekunden sind aber erst nach Messung belastbar. Der aktuelle Growth-Fallback fragt außerdem selbst bei leeren Live-Blättern die Datenbank ab, nicht ausschließlich bei gespeicherten Strukturen; die pauschale Anzahl von zwei Rekursionen beschreibt die tatsächlichen Kosten nur grob. + +Beleg: [GrowthBonusCalculator.php](../../app/Services/BusinessPlan/GrowthBonusCalculator.php), Zeilen 293–303 und 332–378. + +**Empfehlung:** Zunächst einen vollständigen Monatsrechner bauen, der Benutzer, Kanten, letzte Monatssalden und Regeln gesammelt lädt und in topologischer Reihenfolge berechnet. Bei wenigen hundert bis einigen tausend Benutzern ist das eine gut überprüfbare Ausgangsbasis. Für inkrementelle Verarbeitung können Ebenensummen aus direkten Kindern und deren gespeicherten Ebenen vollständig neu aggregiert werden. Growth-Verteilungen brauchen dafür zusätzliche, ausdrücklich definierte Zusammenfassungen. Das bleibt idempotente Neuberechnung und erfordert keine fehleranfällige Delta-Buchhaltung. + +Die Wahl SQL-CTE versus vorberechnete Teilbaumsummen würde ich anhand derselben Fixtures und Lastprofile treffen. Zur Messung gehören Queue-Wartezeit, Datenbankabfragen, gelesene Zeilen, CPU, Speicher und Ende-zu-Ende-Aktualität. Ein Rebuild muss zunächst **alle** eigenen Salden laden und erst danach bottom-up aggregieren; sonst hängt das Ergebnis von der Initialisierungsreihenfolge ab. + +### B11 – Datenbank- und Queue-Annahmen müssen korrigiert werden (P2) + +**SQLite:** `GREATEST(1, 2)` ist auch mit nur zwei Argumenten keine SQLite-Standardfunktion. Der Vorschlag in Risiko 10 löst das Problem nicht. Lokal mit PHP/PDO und SQLite 3.45.1 reproduziert: `no such function: greatest`. Portabel ist hier beispielsweise ein `CASE`-Ausdruck mit ausdrücklich behandeltem `NULL`. Siehe [SQLite: skalare Funktionen](https://www.sqlite.org/lang_corefunc.html). + +SQLite-Tests allein beweisen zudem weder MySQL-Dezimalverhalten noch Sperren oder das Typverhalten einer rekursiven CTE. Prozentsatz und Pfadspalten sollten im Startteil ausreichend typisiert sein; mindestens die Aggregations- und Konkurrenztests müssen zusätzlich auf der tatsächlichen Produktions-DB-Engine laufen. + +**Queue:** [config/horizon.php](../../config/horizon.php) lässt die vorhandenen Supervisoren nur `default` verarbeiten. `business-ledger` muss ausdrücklich ergänzt werden. [config/queue.php](../../config/queue.php), Zeile 16, liest `QUEUE_DRIVER` und fällt auf `database` zurück. `onQueue('business-ledger')` allein garantiert deshalb keine Redis-Verbindung für Horizon. `after_commit` ist dort nicht konfiguriert. + +Auch die Tests setzen in `phpunit.xml` `QUEUE_CONNECTION=sync`, während diese Anwendung `QUEUE_DRIVER` liest. Neue Tests sollten die tatsächliche Laravel-Konfiguration ausdrücklich setzen. Queue-Verbindung, Queue-Name, Worker, Wiederholungen, Timeout und Sperrdauer gehören als konkrete Änderungen in Phase 2. + +### B12 – Baumprädikat, Tiefengrenze und Strukturwechsel brauchen präzisere Verträge (P2) + +Eine Kante beschreibt die Zugehörigkeit eines **Kindes** zu einem Elternteil. Bei der Upline-Neuberechnung darf nicht bereits vor Berechnung des Elternteils abgebrochen werden, nur weil dieser selbst nicht als Kind in einen höheren Baum aufgenommen würde. Ein Parentless-Benutzer kann seinen eigenen Teilbaum berechnen lassen. Erst die weitere Weitergabe nach oben hängt von dessen eigener eingehender Kante ab. + +Die Aussage „Nachkommen müssen bei Strukturwechsel nie neu gerechnet werden“ gilt für reines Umhängen mit unverändertem Teilbaum. Wiederaufnahme, Löschung, erstmalige Aufnahme bislang nicht angelegter Knoten und Änderungen der Berechnungsberechtigung brauchen zusätzliche Behandlung. Bei zwei Ketten sind gemeinsame Vorfahren erst nach beiden geänderten Zweigen abschließend zu berechnen. + +Das Legacy-Limit ist außerdem nicht einfach „20 Ebenen“: `readParentsBusinessUsers()` startet mit Tiefe 0 und bricht erst bei `depth > 20` ab. Auf Tiefe 20 werden noch Kinder angelegt. Growth startet wiederum beim direkten Kind mit Tiefe 0 und kann fehlende Kinder aus der DB nachladen. Der Plan sollte das tatsächliche Verhalten an den Grenzen testen, bevor er pauschal 20 durch 30 ersetzt. + +Belege: [BusinessUserItemOptimized.php](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php), Zeilen 1068–1112; [GrowthBonusCalculator.php](../../app/Services/BusinessPlan/GrowthBonusCalculator.php), Zeilen 260–266 und 293–303. + +**Empfehlung:** Topologische Reihenfolge für alle Komponenten, klare Behandlung von Zyklen und fehlenden Sponsoren sowie Fixtures für Tiefe 20/21/22/30/31. Einen bereits im Quellsystem vorhandenen Zyklus im Shadow-Ledger sichtbar als Fehler behandeln. Ein Ledger-Observer darf eine bisher erfolgreiche Registrierung oder Zahlung nicht beiläufig durch eine neue Exception abbrechen; Quellsystemvalidierung wäre eine gesonderte Änderung. + +### B13 – Datenmodell und Cutover-Vertrag sind noch unvollständig (P2) + +Die Spezifikation zählt wiederholt vier Tabellen, beschreibt aber **fünf**, einschließlich `business_ledger_events`. Ein versionierter Regelstand kommt voraussichtlich noch hinzu. + +Vor den Migrationen sollten mindestens folgende Punkte feststehen: + +| Bereich | Erforderliche Ergänzung | +| --- | --- | +| Zahlen | Vorzeichenbehaftete Punkt-/Umsatzfelder; definierte Dezimalstellen, ausreichende Summenbreite und Rundungsmodus; Prozentwerte nicht unbeabsichtigt als Integer | +| Revisionen | Eingangs-, Struktur- und Regelrevision, Bearbeitungsfehler, letzter erfolgreicher Lauf; `calculated_at` allein beweist keine Aktualität | +| Historie | Regel-Snapshot und Herkunft des Eingangsstands; `changed_at` und überschriebene Kante allein erlauben keinen Verlauf innerhalb des Monats | +| Events | Status, Versuche, letzter Fehler, alte/neue Periodenschlüssel; Ereignisse vor Verarbeitung dauerhaft speichern | +| Ausgabe | Level-Namen, Schwellen, Margen und `_calculated_*` für jede der verschiedenen nächsten Stufen; `active_account`, Sortierung nach `active_date, id`, Sponsor- und Growth-Details | +| Vollständigkeit | Knoten für alle zur Aggregation erforderlichen Personen, auch ohne Buchung; keine stillschweigend durch `INNER JOIN` verschwindenden Mitglieder | +| Vergleich | Benutzer-ID-Mengen, alle vorhandenen Ebenen, `members`, Karrierestand, Umsätze, Gesamtprovision, einzelne Ebenenbeträge und vollständige nächste-Level-Daten | +| Betrieb | Atomare Veröffentlichung eines Rebuilds; parallel eintreffende Änderungen erhalten; Reports und lange Historie getrennt vom auf 180 Tage begrenzten Event-Journal | + +Ein `in_tree = false` bedeutet nicht automatisch „Jahresgebühr abgelaufen“. Der Kindfilter verlangt lediglich ein gesetztes `payment_account`; weitere Ausschlussgründe sind Level, Adminstatus, Aktivierungsdatum und Löschung. Der spätere Schalter „inklusive abgelaufene“ benötigt daher eine eigenständige fachliche Definition. + +Für den Cutover muss außerdem ein fehlender oder veralteter Ledger-Datensatz definiert behandelt werden. Der bestehende `LiveCommissionService` kennt bereits ein `failed`-Kennzeichen. Fehlende Daten sollten nicht als echte Nullprovision erscheinen. Siehe [LiveCommissionService.php](../../app/Services/BusinessPlan/LiveCommissionService.php), Zeilen 70–93. + +Das Rückschalten der Lesequelle ist einfach; ein schon ausgeführtes Karriere-Upgrade oder eine erzeugte Gutschrift wird dadurch nicht rückgängig. Lese-Cutover und Abrechnungs-Cutover brauchen getrennte Abnahmepunkte. + +### B14 – Vergleichbarkeit und Tests müssen zuerst hergestellt werden (P1/P2) + +Die Aussage aus Abschnitt 6.3, der Ledger werde zur Referenz, sobald zwei Legacy-Läufe voneinander abweichen, würde ich streichen. **Aus zwei widersprüchlichen Ergebnissen folgt nicht, dass ein drittes richtig ist.** Nötig sind eingefrorene gemeinsame Eingaben, fachlich nachvollziehbare Beispielrechnungen und dokumentierte Entscheidungen zu Abweichungen. + +Ein zusätzlicher Fallstrick: `User::getUserSalesVolumeBy()` cached den geladenen Monatsdatensatz auf dem User-Objekt **ohne Monat/Jahr als Schlüssel**. Wer beim Backfill dasselbe User-Objekt für mehrere Monate wiederverwendet, kann den ersten Monat weiterverwenden. Beleg: [User.php](../../app/User.php), Zeilen 697–705. Für Legacy-Vergleiche deshalb frische User-Instanzen und getrennte Cache-Kontexte verwenden. + +Die Aussage „Lesepfad verändert Daten“ sollte präzisiert werden: `enrichStoredDataWithUserModel()` und `updateSalesVolumeFields()` verändern das geladene Ergebnisobjekt. In diesen Methoden ist kein `save()` enthalten. Eine Abweichung zwischen Anzeige und gespeicherten Werten ist damit plausibel; ein persistentes Umschreiben der Monatszeile ist durch diese Stellen allein nicht belegt. + +**Ausgeführte Prüfungen:** + +```bash +php artisan test --compact --filter='TreeCalcBotIntegrationTest|SelfSponsoredUserTreeCalcTest|StornoClosedMonthTest|StornoPointsCategoryTest' +``` + +Ergebnis: **19 bestanden, 3 fehlgeschlagen, 74 Assertions**. Die drei Fehler betreffen vorhandene Tests in `TreeCalcBotIntegrationTest.php`, Zeilen 21, 68 und 131: `Call to undefined method App\User::factory()`. Zusätzlich meldet PHPUnit ein veraltetes XML-Schema. Die fehlerhaften Tests erreichen ihre eigentlichen Berechnungsprüfungen nicht. + +Die Zahlenbeispiele aus B01/B02 wurden zusätzlich mit den tatsächlichen Klassen in datenbankfreien PHP-Aufrufen reproduziert. Dafür wurden synthetische Item-Zustände per Reflection gesetzt; Aggregations-/Growth-Methoden blieben unverändert. Dies belegt die lokalen Rechenregeln, nicht den vollständigen Produktivdatenfluss. Die fehlende SQLite-Funktion wurde separat mit einer frischen PDO-In-Memory-Datenbank geprüft. + +Es wurde keine vollständige Testsuite und kein Produktivvergleich ausgeführt. Anwendungscode und Originalplan wurden für diese Analyse nicht geändert. + +## 3. So würde ich den Plan umstellen + +### Schritt 1 – Verbindlicher Regel- und Datenvertrag + +Zuerst die aktuell fehlschlagenden Basistests lauffähig machen und die oben genannten synthetischen Rechenfälle als Regressionstests festhalten. Golden Files um händisch geprüfte Sollrechnungen ergänzen. Für jeden bekannten Unterschied festlegen, ob Parität oder eine genehmigte Korrektur gewünscht ist. `known-differences.md` beginnt damit in Phase 0, nicht erst in Phase 3. + +Parallel dazu historisches Quelleninventar, tatsächliche Tiefenverteilung, DB-Version/Indizes und Queue-Konfiguration prüfen. Erst danach bestätigen, welche Monate als belastbare Backfill-Abnahme dienen können. + +### Schritt 2 – Deterministischer vollständiger Monatsrechner + +Einen expliziten Eingangsdatensatz erzeugen: Benutzerzustände, Kanten, eigene Monatswerte und unveränderliche Regeln. Daraus ohne versteckte DB-Nachladevorgänge Ergebnisse und Erklärungsdaten berechnen. Fehlende Eingaben oder Zyklen als Fehler ausgeben; keine Nullwerte als stillen Ersatz für fehlgeschlagene Berechnungen. + +Zunächst einen vollständig vorbereiteten Monat rechnen und mit Legacy aus demselben Stand vergleichen. SQL-CTEs sind dafür möglich; ein gesammelt geladener Baum mit PHP-Aggregation ist ebenfalls eine gute Option. Das ist die wichtigste Referenz für den späteren inkrementellen Betrieb. + +### Schritt 3 – Robuste Aktualisierung + +Alle relevanten Schreibvorgänge an einen abgeschlossenen Punkte-/Strukturstand anbinden. Änderungen dauerhaft erfassen, alte und neue Schlüssel sichern und Revisionen fortschreiben. Je Monat Änderungen bündeln und geordnet verarbeiten. Erst dann Unique Jobs, Verzögerung und Fallback als Betriebsoptimierung einsetzen. + +Der Fallback verarbeitet Revisionen und Fehler, nicht nur ein boolesches Flag. Der nächtliche Rebuild arbeitet auf einer isolierten Generation; seine Veröffentlichung darf seit Beginn eingegangene Änderungen nicht überschreiben. + +### Schritt 4 – Abschluss und beobachtender Betrieb + +Abschlussstichtag, Regeln, erwartete Benutzer und Eingangsrevision einfrieren. Den Vergleich an den Abschlusslauf koppeln. Monatsabschluss und Wiederanlauf einschließlich Fehlerfällen testen. Nachvollziehbare Differenzberichte müssen neben Zahlen auch Quellenstand und Regelversion enthalten. + +Zwei Monatsabschlüsse und 30 Tage Stichproben sind als Beobachtungszeit sinnvoll. Sie ersetzen keine Tests für seltene Umbuchungs-, Konkurrenz- und Wiederanlauffälle. 14 Tage ohne Dry-Run-Differenz zeigen die Güte des beobachteten Betriebs, nicht automatisch die Vollständigkeit aller Observer. + +### Schritt 5 – Zwei getrennte Umstellungen + +Zuerst die Lesepfade über einen gemeinsamen Provider/Adapter umstellen und die komplette Ausgabe einschließlich leerer/fehlerhafter Zustände vergleichen. Anschließend den Abschluss-Export separat freigeben. Bei `--engine=both` genau eine Veröffentlichung und genau einen Satz finanzieller Folgeaktionen ausführen. Historische Monate außerhalb der freigegebenen Regelstände bleiben über die bisherige Quelle erreichbar. + +## 4. Ergänzungen zur Test- und Abnahmematrix + +| Testfall | Zu beweisende Eigenschaft | +| --- | --- | +| Negativer eigener TP-Monatssaldo und positiver Geschwistersaldo | Bewusst festgelegte Behandlung in Paylines, Qualifikation und Growth | +| Rundung über mehrere Firstlines und `'0.0'`/`'0'` | Centgenaue, ausdrücklich definierte Parität | +| Growth-Summe versus Matrixsumme | Rundungsdifferenzen sichtbar und fachlich behandelt | +| Letzte Buchung von A nach B, oder Löschen letzter Buchung | A wird null, beide Uplines werden aktuell | +| Änderung von Benutzer und Periodenschlüssel gleichzeitig | Alte und neue Perioden korrekt behandelt | +| Kein erreichtes Level, höchstes Level, Level-Abstieg | Alle vormals gesetzten Qualifikations-/Provisionsfelder werden vollständig ersetzt | +| Neue Person, Eintritt/Austritt aus dem Baum, Parentless mit Kindern | Eigener Zustand und alle betroffenen Vorfahren stimmen | +| Gleichzeitige Geschwisterbuchungen, Änderung während laufendem Job | Kein verlorener Aktualisierungsbedarf; ältere Revision überschreibt keine neuere | +| Transaktionsrollback, Worker-Abbruch, Redis-Ausfall | Keine falsche Veröffentlichung; offene Arbeit bleibt auffindbar | +| Monatsanlage nach ersten Buchungen, zweimalige Monatsanlage | Bereits vorhandene Werte bleiben erhalten; Monat wird vollständig initialisiert | +| Abschluss mit fehlgeschlagenem Benutzer und Wiederanlauf | Keine voreilige Freigabe und keine doppelten Gutschriften | +| Regeländerung nach Freeze | Historischer Rebuild bleibt reproduzierbar | +| Rebuild während Buchung/Sponsorwechsel | Konsistente Generation, neue Änderungen bleiben offen oder eingearbeitet | +| Tiefe 20/21/22/30/31, Selbstsponsor, mehrgliedriger Zyklus | Keine stillschweigende Abweichung oder Endlosschleife | +| MySQL-/Produktionsengine und SQLite | Identische fachliche Resultate; Sperrverhalten auf realer Engine geprüft | +| Alle Leseadapter und Abschluss-Mapping | Gleiche fachliche Ausgabe, erhaltene Reihenfolge, definierter Fehlerzustand | + +## 5. Aufwand und Entscheidungsempfehlung + +Die vorgesehenen **10–13 Entwicklungstage** sind für den vollständigen beschriebenen Umfang optimistisch. Nicht ausreichend sichtbar eingeplant sind die Aufarbeitung historischer Quellen, Regelversionierung, Konkurrenzbehandlung, Abschlusskoordination, Reparatur der Testbasis, Statusoberfläche und die vollständigen Kompatibilitätsdaten für die bisherigen Ansichten. + +Als vorläufige eigene Planungsschätzung würde ich ansetzen: + +| Arbeitspaket | Größenordnung | +| --- | ---: | +| Regelkatalog, reproduzierbare Fixtures, historische Machbarkeit | 2–4 Tage | +| Eingangs-/Regelstände, Monatsrechner, Vergleich und Rebuild | 4–6 Tage | +| Schreibpfade, Revisionen, Queue, Wiederanlauf und Abschlussintegration | 4–6 Tage | +| Monitoring und Auswertung des Shadow-Betriebs | 2–3 Tage aktive Arbeit | +| Leseadapter und separat abgesicherter Abschluss-Cutover | 4–6 Tage | +| **Gesamt** | **16–25 Tage plus Beobachtungszeit** | + +Das ist keine gemessene Aufwandsschätzung. Sie sollte nach der Regel- und Datenvalidierung aktualisiert werden; größere historische Datenprobleme oder zusätzliche fachliche Korrekturen können den Umfang erhöhen. + +**Meine Empfehlung:** Den Plan als Architekturentwurf beibehalten, die Befunde B01–B09 und B14 vor verbindlicher Kernimplementierung einarbeiten und zunächst den deterministischen Monatsrechner mit Vergleich freigeben. Damit entsteht früh ein überprüfbarer Nutzen. Die ereignisgetriebene Optimierung und die endgültige Abrechnung bauen anschließend auf einer nachweislich korrekten Basis auf. diff --git a/dev/2026-09-10-business-plan/entwicklungsplan-business-ledger.md b/dev/2026-09-10-business-plan/entwicklungsplan-business-ledger.md new file mode 100644 index 00000000..360b3893 --- /dev/null +++ b/dev/2026-09-10-business-plan/entwicklungsplan-business-ledger.md @@ -0,0 +1,651 @@ +# Entwicklungsplan: Business-Ledger (fortlaufende Berechnung der Business-Pläne) + +Version 2 · Stand: 10.09.2026 · Branch-Kontext: `payment/phase-r-payone-refactoring` · Autor: Claude + +**Änderungen gegenüber Version 1** (nach der [Analyse von Codex](analyse-entwicklungsplan-business-ledger.md), Befunde B01 – B14, alle am Code nachgeprüft und bestätigt): + +| Bereich | Version 1 | Version 2 | +| --- | --- | --- | +| Aggregation | rekursive CTE je Vorfahr | Aggregation aus den **gespeicherten Aggregaten der direkten Kinder** (Ebenen und Growth-Verteilung), keine CTE im Betrieb | +| Vorzeichen | `SUM(tp_sum)` | nur positive Monatssalden zählen (Legacy-Parität, B01), Entscheidung D4 | +| Growth-Rundung | eine Gruppe je Schutzprozent | Rundung je Firstline und je Bucket, inklusive der Legacy-Doppelgruppe `"0.0"`/`"0"` (B02), Entscheidung D3 | +| Aktualität | `dirty`-Flag, Unique-Job, 5 s Verzögerung | persistentes Ereignisjournal **vor** Verarbeitung, Eingangs-/Berechnungsrevision je Knoten, ein serialisierter Verarbeiter je Monat (B03 – B05) | +| Backfill | Kanten aus Struktur-JSON, `m_sponsor_id` | Kanten aus `user_items` je User (vollständig) + Struktur-JSON, Herkunftsreport je Monat; `m_sponsor_id` existiert nicht (B06) | +| Regelstand | `calc_version` | Snapshot der `user_levels`-Tabelle je Monat in der Registry (B07) | +| Abschluss | Vergleich um 06:00 am Ersten | Vergleich gekoppelt an den erfolgreichen Abschlusslauf; Stichtags-Vertrag; `completed = 1` gilt nicht als Nachweis (B08, B09) | +| Umgebung | SQLite `GREATEST`, Queue `business-ledger` | keine DB-Funktionen außerhalb des SQL-Standards; Horizon-Supervisor und `QUEUE_DRIVER` als konkrete Änderungen (B11) | +| Cutover | ein Flag | Lese-Adapter mit `failed`-Zustand, Lese-Cutover und Abrechnungs-Cutover getrennt (B13) | +| Aufwand | 10 – 13 Tage | 16 – 25 Tage plus Beobachtungszeit | +| Reihenfolge | Kern → Ereignisse → Shadow | **Regel- und Datenvertrag → deterministischer Monatsrechner mit Vergleich → Ereignisse → Shadow** | + +--- + +## 0. Auftrag in einem Absatz + +Heute werden Provision, Team-Struktur, Level und Punkte eines Beraters **bei jedem Aufruf** aus der +Sponsor-Kette neu gerechnet (rekursiv über die gesamte Downline). Am Monatsersten friert ein Batch +den Vormonat in `user_businesses` ein. Mit wachsenden Teams wird der Live-Pfad teuer und wird deshalb +mit 6-Stunden-Caches gedämpft, was die Zahlen veraltet. Ziel ist ein **zweites, fortlaufend +gepflegtes Zahlenwerk** („Business-Ledger"), das bei jedem Punkte-Ein-/Ausgang und jeder +Strukturänderung nur die betroffene Sponsor-Kette nachrechnet, alle Ergebnisse persistent hält und +**parallel** zum bestehenden Modell läuft, bis beide nachweislich dieselben Zahlen liefern. Das +bestehende Modell bleibt bis dahin unverändert die Quelle für Gutschriften, Level-Updates und alle +Ansichten. + +Nicht Teil dieses Plans (aber vorbereitet): die Vereinheitlichung der Backoffice-/Team-Statistiken +auf dasselbe Zahlenwerk (Audit-Befund A07 / Konzept K11 „Statistik = Struktur"). Das ist der +angekündigte nächste Schritt. + +--- + +## 1. Ist-Analyse + +### 1.1 Datenfluss heute + +``` +Bestellung / Gutschrift / Storno / Registrierung / manuelle Punkte + │ + ▼ +SalesPointsVolume::User() | addSalesPointsVolume() | editSalesPointsVolume() +| changeSalesPointsVolumeUser() | cancelSalesPointsVolume() | setToUserAndReCalculate() + │ (12 Aufrufstellen in Controllern, Services, Commands) + ▼ +SalesPointsVolume::reCalculateSalesPointsVolume(user, month, year) + │ speichert jede Buchungszeile des Monats einzeln mit kumuliertem Laufstand + ▼ +user_sales_volumes (month_KP_points, month_TP_points, month_shop_points, month_*_total_net) + │ + │ ── Live-Pfad (laufender Monat, bei jedem Aufruf) ────────────────────────── + ▼ +TreeCalcBotOptimized + BusinessUserItemOptimized + readParentsBusinessUsers() 1 SQL je Knoten, rekursiv (lädt Kinder bis Tiefe 20 → 21 Ebenen) + calculateUserPointsOptimized() Punkte je Ebene, nur Werte > 0 + calculateQualificationsForStructure() Qualifikation bottom-up für alle Knoten + calcQualPP() → calcuQualLevel() / setNextUserLevel() / calculateCommissions() + GrowthBonusCalculator zweite Rekursion; lädt bei leeren Kindlisten aus der DB nach + │ + ├─► LiveCommissionService (Dashboard, 6 h Cache) + ├─► PaylineForecastService (Payline-Kontext, 6 h Cache) + ├─► TeamController (Struktur, DataTable, Level-Reports – ohne Cache) + ├─► ModalController (Berater-Detail) + └─► BusinessControllerOptimized (Admin-Struktur gesamte Firma) + │ + │ ── Monatsabschluss (täglich 03:00, läuft nur am konfigurierten Tag) ────── + ▼ +business:store-optimized (Setting `day-exectute-business-structur`, Standard 1) + BusinessUsersStoreOptimized::storeUserBusinessStructure() Gesamtbaum → user_business_structures + BusinessUsersStoreOptimized::storeBusinessUsersDetail() je User initBusinesslUserDetail(forceLive) → user_businesses + UserPaymentCredits Gutschriften aus commission_* (idempotent über user_business_id) + UserLevelUpdate Karriere-Level aus next_qual_user_level (schreibt users.m_level) + assignMonthlyQualKpBonusPoints Bonusbuchung in den NEUEN Monat +``` + +### 1.2 Beteiligte Dateien + +| Datei | Rolle | Zeilen | +| --- | --- | --- | +| `app/Services/BusinessPlan/TreeCalcBotOptimized.php` | Baumaufbau, Punkte je Ebene, Orchestrierung | 1.020 | +| `app/Services/BusinessPlan/BusinessUserItemOptimized.php` | Knoten: Punkte, Qualifikation, Provision | 1.264 | +| `app/Services/BusinessPlan/GrowthBonusCalculator.php` | Tiefenbonus (Differenzlogik) | 381 | +| `app/Services/BusinessPlan/BusinessUserRepository.php` | Root-/Parentless-Abfragen, Caches | 215 | +| `app/Services/BusinessPlan/SalesPointsVolume.php` | Punktebuchung, Monats-Laufstand | 429 | +| `app/Services/BusinessPlan/LiveCommissionService.php` | „Stand heute"-Kennzahlen, 6 h Cache | 267 | +| `app/Cron/BusinessUsersStoreOptimized.php` | Monatsabschluss | 263 | +| `app/Console/Commands/BusinessStoreOptimized.php` | Command + Gutschriften + Level-Update | 526 | +| `app/Cron/UserLevelUpdate.php`, `app/Cron/UserPaymentCredits.php` | Konsumenten von `user_businesses` | – | +| `app/Services/Backoffice/BackofficeDashboardService.php` | **eigener** Baum-Walker (`lineBuckets()`) für Statistiken | 1.146 | + +Tabellen: `user_sales_volumes` (Buchungsjournal), `user_businesses` (Monatsergebnis je User), +`user_business_structures` (Monatsbaum als JSON), `user_levels` (Regelwerk), `users.m_sponsor` +(Struktur, nicht historisiert). + +### 1.3 Mengengerüst (Entwicklungs-DB, 10.09.2026) + +| Kennzahl | Wert | +| --- | --- | +| User gesamt | 901 | +| Berater (m_level und payment_account gesetzt, nicht gelöscht) | 568 | +| Root-Berater (ohne Sponsor) | 38 | +| `user_businesses`-Zeilen (alle Monate) | 21.670 | +| gespeicherte Monatsstrukturen | 68 (letzte: 08/2026, 566 User, abgeschlossen) | +| `user_sales_volumes`-Zeilen | 44.441 | +| größtes bekanntes Team (Kundenkonto, lt. Feedback 07.09.) | 578 Downline, davon 134 nicht im Provisionsbaum | +| Level-Stufen | 13 (Paylines 3 – 8) | +| tatsächliche Baumtiefe | **noch nicht gemessen** – Aufgabe in Phase 0 | + +### 1.4 Kostentreiber im Live-Pfad + +Für **einen** Berater mit N Personen in der Downline fallen je Aufruf ungefähr an: + +| Schritt | Abfragen | Stelle | +| --- | --- | --- | +| Kinder laden | 1 je Knoten | `BusinessUserItemOptimized::readParentsBusinessUsers()` | +| Punkte des Monats | 1 – 2 je Knoten | `getUserSalesVolumeOptimized()` → `User::getUserSalesVolumeBy()` | +| Level-Auswahl | 1 je Knoten | `calcuQualLevel()` (`UserLevel::where(...)`) | +| nächstes Level | 1 – 2 je Knoten | `setNextUserLevel()`, `setQualNextLevel()` | +| Growth-Bonus | PHP-Rekursion über den Teilbaum; bei leerer Kindliste **auch live** ein DB-Nachladen je Blatt (`loadChildrenFromDatabase()`) | `GrowthBonusCalculator` | + +Bei 578 Knoten sind das grob **2.500 – 3.000 SQL-Abfragen pro Aufruf**. Der Monatsabschluss +wiederholt das für jeden User einzeln (`initBusinesslUserDetail` mit `forceLive`), d. h. jeder +Teilbaum wird so oft neu gerechnet, wie er Vorfahren hat. Produktionslaufzeiten sind nicht gemessen. + +### 1.5 Schwachstellen, die der Plan adressiert + +1. **Keine persistente Zwischenlage für den laufenden Monat.** Alles wird aus `users.m_sponsor` und + `user_sales_volumes` bei jedem Aufruf neu abgeleitet. Die 6-h-Caches (`live_business_v3_*`, + `payline_context_*`, `root_users_*` 1 h, `user_relations_*` 30 min) sind die einzige Bremse und + machen das Dashboard bis zu 6 h alt. +2. **Rekursion in PHP mit N+1-Abfragen** statt Mengenoperationen. +3. **Anzeige weicht vom gespeicherten Stand ab.** `enrichStoredDataWithUserModel()` und + `updateSalesVolumeFields()` verändern das geladene Ergebnisobjekt (ohne `save()`), `makeUser()` + rechnet bei fehlenden Level-Daten `calcQualPP()` nach. Was der Berater sieht, ist nicht immer, + was in `user_businesses` steht. +4. **Zwei Baum-Definitionen.** Provisionsbaum (`readParentsBusinessUsers`, Anhang A) und + Statistik-Baum (`BackofficeDashboardService::lineBuckets()`, nur `m_sponsor` + nicht gelöscht) + laufen auseinander (A07/K11). Am Kundenkonto: 134 von 578 Personen nur in einem der beiden. +5. **Struktur nur am Monatsende historisiert**, und zwar mit dem Sponsorstand des Ausführungstags, + nicht des Monatsletzten. +6. **Kein Nachweis der Richtigkeit.** Kein automatischer Abgleich Live ↔ Monatsergebnis. Der + Integrationstest `tests/Feature/BusinessPlan/TreeCalcBotIntegrationTest.php` prüft keine Zahlen + und **schlägt aktuell fehl** (3 von 6 Tests: `User::factory()` existiert nicht, das Model hat kein + `HasFactory`). +7. **Abschluss ohne Erfolgsnachweis.** `storeBusinessUsersDetail()` markiert einen User auch bei + Exception als erledigt; `storeBusinessCompleted()` prüft nur diese Markierungen. `completed = 1` + kann mit fehlenden Ergebnissen zusammenfallen. + +Nebenbefunde (nicht Teil des Plans, aber festgehalten): + +- `getRootUsers()` verlangt `payment_account > Monatsende`; fällt ein Root raus, werden seine + Nachkommen zu „parentless", deren Teams in der Admin-Strukturansicht nicht gerechnet werden + (`calculateAllParentlessUsers()` ohne `readParentsBusinessUsers`). Das Struktur-JSON enthält + deshalb für Parentless-Teilbäume keine Beziehungen. Der Monatsabschluss ist nicht betroffen. +- `getParentlessUsers()` filtert nicht auf `m_level`, `readParentsBusinessUsers()` schon. +- `User::getUserSalesVolumeBy()` cached den Monatsdatensatz am User-Objekt **ohne Monat/Jahr als + Schlüssel**. Wer ein User-Objekt für zwei Monate benutzt, bekommt zweimal den ersten Monat. +- Die Growth-Matrix (`getMatrixDetails()`) rundet je Person, der Auszahlungsbetrag + (`calculate()`) je Firstline und Bucket. Matrixsumme und Betrag können um Cents abweichen. + +--- + +## 2. Zielbild: Business-Ledger + +### 2.1 Grundidee + +Je Monat und je Berater führt das System **eine Kontozeile** mit allen abgeleiteten Größen und den +beiden Aggregaten, die die Upline braucht (Punkte je Ebene, Growth-Verteilung). Ändert sich ein +Eingangswert, wird die betroffene Sponsor-Kette von unten nach oben neu gerechnet; jeder Knoten +rechnet dabei nur mit seinen eigenen Werten und den **gespeicherten Aggregaten seiner direkten +Kinder**. Die Kosten je Knoten sind proportional zur Zahl seiner Firstlines, nicht zur Größe seines +Teams. + +Fünf Prinzipien: + +1. **Ereignisgetrieben, aber journalgestützt.** Jede Änderung wird als Zeile in ein Ereignisjournal + geschrieben, **bevor** irgendetwas berechnet wird. Verarbeitung heißt: offene Journalzeilen lesen, + betroffene Menge bestimmen, rechnen, Zeilen quittieren. Ein verlorener Job verliert keine Änderung. +2. **Neuberechnung je Knoten ist idempotent und vollständig.** Kein Delta-Buchen. Jede Zahl eines + Knotens ist aus (eigene Werte, Kindaggregate, Regelstand) reproduzierbar. Ein Rebuild heilt + jeden Fehler. +3. **Revisionen statt Flags.** Jeder Knoten trägt `input_revision` (wie oft haben sich seine Eingaben + geändert) und `calculated_revision` (mit welcher Eingangsrevision wurde zuletzt gerechnet). + Aktuell ist ein Knoten genau dann, wenn beide gleich sind. +4. **Regeln in PHP, ohne Datenbankzugriff.** Qualifikation, Provision und Growth sind reine + Funktionen über Arrays und den Regelstand des Monats. Sie werden gegen die Legacy-Klassen und + gegen händisch gerechnete Sollwerte getestet. +5. **Ein Monat hat einen Stichtag.** Offen → Annahmestopp → abgerechnet → geprüft. Was nach dem + Annahmestopp kommt, wird protokolliert, aber nicht mehr eingerechnet. Das entspricht der + Kundenentscheidung vom 09./10.09.2026. + +### 2.2 Was bleibt, was kommt dazu + +| Bleibt unverändert (bis zum Abrechnungs-Cutover) | Kommt dazu | +| --- | --- | +| `user_sales_volumes`, `reCalculateSalesPointsVolume()` | 5 Tabellen `business_ledger_*` | +| `TreeCalcBotOptimized` & Co. als Quelle aller Ansichten | Ereignis-Hooks und Observer, ein Verarbeiter je Monat | +| `business:store-optimized`, Gutschriften, Level-Update, Struktur-JSON | Monatsrechner, Rebuild-, Vergleichs- und Statuskommandos | +| Backoffice-Statistik und deren Snapshots | Regelstand-Snapshot je Monat | + +--- + +## 3. Datenmodell + +Präfix `business_ledger_`. Alle Punkt- und Betragsfelder `decimal(13,2)` vorzeichenbehaftet, +Prozentwerte `decimal(5,2)`. Keine JSON-Spalte wird in SQL ausgewertet. + +### 3.1 `business_ledger_months` – Registry und Regelstand + +| Spalte | Bedeutung | +| --- | --- | +| year, month | Schlüssel (unique) | +| status | `open` (nimmt Ereignisse an) · `cutoff` (Annahmestopp, Verarbeitung offener Ereignisse) · `settled` (Abschlusslauf erfolgreich, Vergleich läuft) · `verified` (Vergleich ohne unerklärte Abweichung) · `frozen` (keine Änderung mehr) | +| levels_snapshot | JSON: vollständige `user_levels`-Tabelle zum Zeitpunkt des Öffnens bzw. des letzten Regelereignisses; Grundlage jeder Berechnung dieses Monats | +| levels_snapshot_at, rules_version | Zeitpunkt und Algorithmusversion | +| edges_source | `live` · `user_items` · `structure_json` · `mixed` (Backfill) | +| expected_user_ids | JSON: Menge der `is_consultant`-User zum Stichtag (für den Vollständigkeitsvergleich) | +| cutoff_at, settled_at, settled_run_id, verified_at, frozen_at | Zeitstempel und Referenz auf den Abschlusslauf | +| notes | z. B. Herkunftsreport-Kurzfassung | + +### 3.2 `business_ledger_edges` – Struktur je Monat + +| Spalte | Bedeutung | +| --- | --- | +| year, month, user_id | unique | +| sponsor_id | `users.m_sponsor` zum Zeitpunkt der letzten Änderung im Monat (null erlaubt) | +| in_tree | erfüllt „Kind im Provisionsbaum" (Anhang A) – bezieht sich auf die **eingehende** Kante dieses Users | +| is_consultant | erfüllt „wird berechnet" (Anhang A) | +| exclusion_reasons | JSON-Liste der nicht erfüllten Prädikate (`no_level`, `no_payment_account`, `deleted`, `admin`, `future_active_date`, `self_sponsor`) – für den späteren Statistik-Schalter, der **nicht** „`in_tree = false`" heißen darf | +| revision, changed_at, changed_by | Strukturrevision und Herkunft (Event-ID) | + +Index: (year, month, sponsor_id), (year, month, in_tree). + +Mitgeführt werden **alle** User des Monats mit ihrer Sponsorbeziehung, auch außerhalb des +Provisionsbaums. Innerhalb eines Monats gibt es keinen Verlauf; der steht im Ereignisjournal. + +### 3.3 `business_ledger_nodes` – Kontozeile je User und Monat + +| Gruppe | Spalten | +| --- | --- | +| Schlüssel | year, month, user_id (unique) | +| Stammdaten zum Berechnungszeitpunkt | career_level_id, career_level_pos, active_account (bool), active_date, payment_account_date | +| Eigene Werte (Spiegel der letzten `user_sales_volumes`-Zeile nach `id DESC`; 0 wenn keine) | own_kp_points, own_tp_points, own_shop_points, own_total_net, own_shop_total_net, kp_sum (KP + Shop), tp_sum (TP + Shop), total_net_sum | +| Aggregate für die Upline | tp_contribution (= `max(0, tp_sum)`, siehe D4), growth_distribution (JSON `{bucket => volumen}`, Abschnitt 4.3), growth_pct (Schutz, den dieser Knoten ausübt) | +| Ergebnis | team_points_total, payline_points, payline_points_qual_kp, calc_qual_kp, qual_level_id, qual_paylines, qual_next_level_id, next_qual_level_id, next_can_level_id, next_level_data (JSON mit allen `_calculated_*`-Werten der drei „nächsten" Stufen, damit die bisherigen Ansichten bedient werden können) | +| Provision | commission_pp, commission_shop, commission_growth, commission_total | +| Revision und Status | input_revision, calculated_revision, calc_status (`ok`, `error`, `never`), last_error, calculated_at, calc_duration_ms, run_id | + +Index: (year, month, calc_status), (year, month, qual_level_id). Ein Knoten existiert für **jeden** +`is_consultant`-User des Monats, auch ohne Buchung – Aggregation über `LEFT JOIN` nie über `INNER JOIN`. + +### 3.4 `business_ledger_lines` – Punkte je Ebene + +| Spalte | Bedeutung | +| --- | --- | +| year, month, user_id, line | unique | +| points | Σ `tp_contribution` aller Knoten dieser Ebene unterhalb von user_id | +| members | Anzahl Knoten auf der Ebene | +| margin, commission | Prozentsatz und gerundete Provision dieser Ebene (0 außerhalb der Payline) | + +### 3.5 `business_ledger_events` – Ereignisjournal (wird **vor** der Verarbeitung geschrieben) + +| Spalte | Bedeutung | +| --- | --- | +| id, created_at | | +| type | `points`, `structure`, `user_created`, `rules`, `rebuild`, `month_open`, `month_cutoff` | +| user_id, year, month | betroffener Schlüssel | +| old_user_id, old_year, old_month | alter Schlüssel bei Umbuchungen (`changeSalesPointsVolumeUser`, `setToUserAndReCalculate`) | +| payload | JSON: geänderte Felder alt/neu, Auslöser (Klasse/Command/Route), Eloquent-Event | +| status, attempts, last_error, processed_at, run_id | Verarbeitungszustand | + +Aufbewahrung 180 Tage für verarbeitete Zeilen; Reports und Vergleichsergebnisse liegen als Dateien +unter `storage/app/business-ledger/`, nicht im Journal. + +--- + +## 4. Berechnungslogik + +### 4.1 Reihenfolge je Knoten X + +``` +1. Eigene Werte spiegeln letzte user_sales_volumes-Zeile (id DESC) → own_*, kp_sum, tp_sum + tp_contribution = max(0, tp_sum) (D4) +2. Ebenen aus den Kindern C = direkte Kinder mit in_tree = 1 (edges.sponsor_id = X) + lines[1].points = Σ_C tp_contribution(C) + lines[i].points = Σ_C lines_C[i-1].points für i ≥ 2 + members analog +3. Qualifikation QualificationCalculator (rein) über kp_sum, lines[], career_pos, + levels_snapshot → qual_level, payline_points, next_*, growth_pct +4. Growth-Verteilung distribution(X) = { "0.0": tp_contribution(X) falls > 0 } + ⊕ für jedes Kind C, für jeden Bucket (k, v) in distribution(C): + key = (string) max((float) k, growth_pct(X)) ; += v +5. Provision CommissionCalculator (rein): + commission_pp = Σ_{i ≤ paylines} round(lines[i].points / 100 · pr_line_i, 2) + commission_shop = round(own_shop_total_net / 100 · margin_shop(career_level), 2) + commission_growth = Σ_C Σ_{(k,v) ∈ distribution(C)} + round(v / 100 · max(0, growth_pct(X) − k), 2) + (nur wenn qual_level.growth_bonus > 0) +6. Persistieren nodes + lines in einer Transaktion; calculated_revision := input_revision + zum Zeitpunkt des Lesens in Schritt 1 +``` + +Schritt 4 bildet `GrowthBonusCalculator::getVolumeByProtectionLevel()` exakt nach: Das Eigenvolumen +eines Knotens liegt unter dem PHP-Schlüssel `"0.0"`; alles, was durch ihn hindurchfließt, unter +`(string) max(...)`, was für 0 den Schlüssel `"0"` ergibt. Beides sind im Legacy **getrennte +Rundungsgruppen** (B02). Parität verlangt, diese Schlüssel unverändert zu führen; siehe D3. + +Weil `distribution(X)` nur von X und seinen direkten Kindern abhängt, ist es vollständig +komponierbar und wird je Knoten gespeichert (wenige Einträge, einer je vorkommendem +Schutzprozentsatz plus `"0.0"`). + +### 4.2 Reihenfolge in einer Kette und im Rebuild + +Ereignis für User X → betroffene Menge = X und alle Vorfahren entlang `edges.sponsor_id`, bis kein +Sponsor mehr existiert. **Jeder** Knoten der Kette wird gerechnet (auch ein Parentless-User rechnet +seinen Teilbaum); ob sein Ergebnis in den nächsthöheren Knoten **einfließt**, entscheidet allein +`in_tree` seiner eingehenden Kante (B12). Reihenfolge: X zuerst, dann aufwärts. + +Zwei Ketten (Strukturereignis, Geschwisterbuchungen) werden **gemeinsam** verarbeitet: betroffene +Menge vereinigen, nach Tiefe absteigend sortieren (tiefer Knoten vor seinem Vorfahren), jeden Knoten +genau einmal rechnen. Damit wird ein gemeinsamer Vorfahr erst nach beiden geänderten Zweigen +berechnet. + +Rebuild eines Monats: (1) alle eigenen Werte aller Knoten laden und schreiben, (2) topologische +Reihenfolge über die Kanten (Blätter zuerst), (3) jeden Knoten rechnen, (4) Zyklen und fehlende +Sponsoren als Fehler ausweisen, nicht stillschweigend abschneiden. Ein Rebuild schreibt in eine +`run_id`-Generation und veröffentlicht atomar; Ereignisse, die währenddessen eingehen, bleiben offen. + +### 4.3 Tiefe + +Legacy lädt Kinder bis `depth ≤ 20`, also 21 Ebenen unter dem Wurzelknoten; Growth rekursiert ab dem +direkten Kind ebenfalls 21 Ebenen tief. Der Ledger hat **kein** Tiefenlimit (die Komposition über +Kinder braucht keines). Ob das ein Paritätsbruch ist, hängt von der realen Tiefe ab – Messung in +Phase 0, Fixtures für 20/21/22 Ebenen, Entscheidung D5. + +### 4.4 Reine Regelklassen + +`app/Services/BusinessPlan/Ledger/QualificationCalculator.php` und `CommissionCalculator.php` erhalten +ausschließlich Arrays/Skalare und den `levels_snapshot`. Sie implementieren Anhang B. Die +Legacy-Klassen werden **nicht** angefasst. + +--- + +## 5. Ereignisse und Verarbeitung + +### 5.1 Schreibpfad-Matrix (Punkte) + +| Auslöser | Was passiert im Legacy | Ereignis(se) | +| --- | --- | --- | +| `SalesPointsVolume::User()` (Bestellung) | create + reCalculate | `points` (user, M) | +| `addSalesPointsVolume()` (Gutschrift, Bonus, Registrierung) | create + reCalculate | `points` (user, M) | +| `editSalesPointsVolume()` | save + reCalculate | `points` (user, M) | +| `cancelSalesPointsVolume()` (Storno) | create im **laufenden** Monat + reCalculate | `points` (user, laufender M) | +| `changeSalesPointsVolumeUser()` | user_id A → B, reCalculate A und B; **A erzeugt keine Speicherung mehr, wenn seine letzte Zeile verschoben wurde** | `points` (A, M) **und** (B, M) | +| `setToUserAndReCalculate()` | user_id, month, year, status ändern | `points` (alt-user, alt-M) und (neu-user, neu-M); alt-user kann null sein → dann nur neu | +| `RepairAboOrderUserAssignment`, `SponsorBuyingPointsRepairService`, `RepairMissingInvoices`, `CreateAboReplacementOrder` | rufen `User()` bzw. `reCalculate` | wie oben | +| direkte SQL-/Query-Builder-Änderungen, `withoutEvents()` | kein Model-Event | nur der nächtliche Rebuild-Vergleich (5.4) erkennt sie | + +Primärer Hook: **am Ende von `reCalculateSalesPointsVolume()`** wird genau ein `points`-Ereignis +für (user, month, year) geschrieben – das ist die fachliche Abschlussgrenze der Laufstandberechnung. +Zusätzlich schreibt ein Observer auf `UserSalesVolume` (`saved`, `deleted`) ein Ereignis mit altem +und neuem Schlüssel aus `getOriginal()`. Der Observer ist das Sicherheitsnetz für Schreibpfade ohne +`reCalculate` (dann rechnet der Ledger mit dem alten Laufstand – der Vergleich zeigt es). +Zwischenstände sind unschädlich: Jede Speicherung erzeugt ein Ereignis, das zuletzt verarbeitete +liest den fertigen Stand. + +### 5.2 Schreibpfad-Matrix (Struktur und Regeln) + +| Auslöser | Ereignis | +| --- | --- | +| `User` `created` (Registrierung Web/API, Admin-Anlage) | `user_created` → Knoten + Kante des laufenden Monats anlegen | +| `User` `updated` mit Änderung an `m_sponsor`, `m_level`, `payment_account`, `active_date`, `admin` | `structure` mit alt/neu → Kante neu schreiben; Kette alt **und** neu **und der User selbst** | +| `User` `deleted` / `restored` (SoftDeletes) | `structure` | +| `UserLevel` `created` / `updated` / `deleted` | `rules` → `levels_snapshot` des offenen Monats erneuern, **alle** Knoten des Monats `input_revision++` | +| Massen-Umhängen in `UserUtil` (Cleanup/Restore), `AdminUserCleanupController` | je Kind ein `updated`-Event, gebündelte Verarbeitung | + +Änderungen an `payment_account`, `admin`, `active_date` oder Löschung betreffen die eingehende Kante +**und** `is_consultant` des Users selbst; er wird mitgerechnet (bzw. sein Knoten auf „nicht +berechnet" gesetzt) und seine Vorfahren ebenfalls. + +Ein Observer darf einen erfolgreichen Geschäftsvorgang (Registrierung, Zahlung) nie durch eine +eigene Exception abbrechen: Ereignisschreiben ist in `try/catch` gekapselt, Fehler landen im Log +und im nächtlichen Vergleich. + +### 5.3 Verarbeitung + +- **Ein Verarbeiter je Monat, serialisiert.** Command `business:ledger-process` läuft jede Minute + (`withoutOverlapping`, Cache-Lock je Monat). Er liest alle offenen Ereignisse eines Monats, + erhöht `input_revision` der betroffenen Knoten, bestimmt die vereinigte betroffene Menge, rechnet + sie nach 4.2 und quittiert die Ereignisse. Ereignisse, die während des Laufs eingehen, bleiben + offen und erhöhen die Revision erneut – ein Knoten, dessen `input_revision` beim Schreiben höher + ist als die gelesene, wird im nächsten Lauf erneut gerechnet. Kein Lost Update, keine Rennen + zwischen Ketten. +- **Kein Unique-Job, keine Verzögerungsheuristik.** Queue und Worker-Parallelität sind eine + Optimierung für später und nur mit Konkurrenztests (Anhang D). +- **Aktualität:** Ereignis → Ergebnis in ≤ 60 – 90 s. Für das Dashboard ausreichend; ein + synchroner Modus ist nicht vorgesehen, weil er die Abschlussgrenze aus 5.1 nicht ersetzen kann. +- **Konfiguration:** `config/business.php` → `ledger.enabled`, `ledger.read` (`legacy|ledger`), + `ledger.settle_engine` (`legacy|both|ledger`), `ledger.compare_tolerance`. + +### 5.4 Zeitgesteuerte Abläufe und Stichtags-Vertrag + +| Wann | Was | +| --- | --- | +| Tag 1, 00:05 | `business:ledger-open-month`: Monat M+1 anlegen (idempotent: vorhandene Knoten/Kanten/Ereignisse bleiben), `levels_snapshot`, Kanten aus `users`. Monat M → `cutoff`; `expected_user_ids` festhalten. | +| Tag 1, 00:05 – 03:00 | Offene Ereignisse für M werden noch verarbeitet (z. B. `sales:repair-sponsor-vp` um 02:30 bucht in M+1, nicht in M – trotzdem prüfen). Strukturereignisse nach `cutoff` werden für M protokolliert, nicht angewendet (D6). | +| konfigurierter Tag, 03:00 | Legacy-Abschluss `business:store-optimized`. Der Ledger hört auf ein Abschlussereignis, das der Command **am Ende** von `storeBusinessStructureUsersDetailMonth()` mit `run_id` schreibt (kleine Ergänzung im Command, kein Eingriff in die Berechnung). | +| nach Abschlussereignis | `business:ledger-compare M` läuft **ereignisgesteuert**, nicht um 06:00. Vergleich gegen `user_businesses` mit `b_structure_id` des Laufs. Danach `settled` → `verified` → `frozen`. | +| täglich 04:15 | `business:ledger-verify --sample=50` für den laufenden Monat: 20 größte Teams + 30 zufällig, jeweils frisches `TreeCalcBotOptimized`, frische User-Instanz, `Cache::flush` der Legacy-Schlüssel. | +| täglich 04:20 | `business:ledger-rebuild M --dry-run`: Vollrechnung in eine Vergleichsgeneration; Abweichung = verpasstes Ereignis oder Bug. Report, kein Auto-Apply. | + +Wichtig für den Vergleich: `UserLevelUpdate` schreibt am Tag 1 neue `users.m_level`. Der Ledger +rechnet M mit `career_level` **aus dem Knoten** (vor dem Upgrade), nicht aus `users`. + +--- + +## 6. Parallelbetrieb und Nachweis + +### 6.1 Reihenfolge des Nachweises + +1. **Regelvertrag** (Phase 0): Fixtures mit händisch gerechneten Sollwerten, Legacy-Ergebnisse als + Golden Files. `known-differences.md` beginnt hier. +2. **Deterministischer Monatsrechner** (Phase 1): eingefrorener Eingangsdatensatz → Ledger und + Legacy aus **demselben** Stand → Vergleich. Erst wenn das für abgeschlossene Monate stimmt, folgt + der Ereignisbetrieb. +3. **Backfill** abgeschlossener Monate mit Herkunftsreport. +4. **Shadow-Betrieb** mit Stichprobe, Dry-Run-Rebuild und ereignisgekoppeltem Abschlussvergleich. + +### 6.2 Backfill: Quellen und Herkunftsreport + +| Größe | Quelle | Verlässlichkeit | +| --- | --- | --- | +| Kanten | `user_businesses.user_items` je User (rekursiv `user_id`/`parents`, aus dem individuellen Live-Teilbaum) | vollständig für jeden User mit Ergebniszeile; Punkte darin ignorieren (`sales_volume_points_sum` ist leer) | +| Kanten (Ergänzung) | `user_business_structures.structure` + `parentless` | Parentless-Teilbäume fehlen | +| Sponsor eines Users | `user_businesses.sponsor` (JSON, `user_id`) | vorhanden, wenn `checkSponsor()` lief | +| eigene Punkte | `user_businesses.sales_volume_*` **und** letzte `user_sales_volumes`-Zeile | zunächst getrennt vergleichen, nicht als identisch annehmen (B07) | +| Regelstand | `qual_user_level`, `next_*`-JSONs (nur die vorkommenden Level) ∪ heutige `user_levels` | Report weist aus, welche Level nur aus der heutigen Tabelle stammen | +| Karriere-Level | `user_businesses.m_level_id` | vollständig | + +`business:ledger-backfill M` erzeugt zuerst `storage/app/business-ledger/provenance-YYYY-MM.md`: +Abdeckung je Quelle, Widersprüche zwischen `user_items` und Struktur-JSON, User ohne Sponsorangabe, +Level ohne historischen Snapshot. Erst mit diesem Report wird entschieden, ob der Monat als +Abnahmemonat taugt. Ziel bleiben die Monate 11/2025 – 08/2026 (neue Growth-Logik); ihre +Rekonstruierbarkeit ist zu beweisen, nicht vorausgesetzt. + +### 6.3 Vergleichsumfang (`business:ledger-compare`) + +| Prüfung | Legacy | Ledger | Toleranz | +| --- | --- | --- | --- | +| **Mengen**: erwartete User-IDs, fehlende, doppelte, `calc_status = error` | `user_businesses` mit `b_structure_id` | `nodes` mit `is_consultant` | exakt | +| eigene Punkte/Umsätze | sales_volume_KP_points, TP, shop, KP_sum, TP_sum, total, total_shop, total_sum | own_*, kp_sum, tp_sum, total_net_sum | exakt | +| Ebenen | business_lines[i].points **für alle vorhandenen i** | lines.points, members | exakt | +| Payline | payline_points, payline_points_qual_kp, calc_qual_kp | gleichnamig | exakt | +| Level | qual_user_level.id/.paylines, qual_user_level_next.id, next_qual_user_level.id, next_can_user_level.id und deren `_calculated_*` | qual_level_id, qual_next_level_id, next_qual_level_id, next_can_level_id, next_level_data | exakt | +| Karriere-Level | m_level_id | career_level_id | exakt | +| Provision je Ebene und gesamt | business_lines[i].commission, commission_pp_total, commission_shop_sales, commission_growth_total, Summe | lines.commission, commission_* | 0,00; Abweichungen ≤ 0,01 gesondert ausgewiesen | +| Growth-Matrix | growth_bonus_details | eigene Matrix (je Person gerundet) | getrennt vom Betragsvergleich (B02) | + +Ausgabe: Summenzeile, CSV je Feld, Markdown-Kurzfassung `compare-YYYY-MM.md` in diesem Ordner, +Exit-Code ≠ 0 bei Abweichungen. Jede Abweichungszeile trägt Quellenstand (`run_id`, +`b_structure_id`) und Regelstand (`levels_snapshot_at`). + +### 6.4 Umgang mit Abweichungen + +Jede Abweichung wird eingeordnet als **Ledger-Fehler** (beheben, Monat neu rechnen), +**Legacy-Fehler** (in `known-differences.md` mit Betrag und Usern; Entscheidung der Kundin: +Parität nachbilden oder korrigieren) oder **Quellenlücke** (Herkunftsreport, Monat nicht als +Abnahmemonat). Aus zwei widersprüchlichen Legacy-Läufen folgt **nicht**, dass der Ledger recht hat – +in dem Fall wird der Eingangsstand eingefroren und beides gegen eine händische Sollrechnung geprüft. + +### 6.5 Abnahmekriterien + +| Für | Kriterium | +| --- | --- | +| Lese-Cutover | (1) Backfill-Vergleich aller freigegebenen Abnahmemonate ohne unerklärte Abweichung; (2) zwei aufeinanderfolgende Monatsabschlüsse im Shadow ohne unerklärte Abweichung; (3) 30 Tage Stichprobe ohne unerklärte Abweichung; (4) 14 Tage Dry-Run-Rebuild ohne Differenz; (5) alle Testfälle aus Anhang D grün, auf SQLite **und** MySQL 8 | +| Abrechnungs-Cutover | zusätzlich: ein Abschluss mit `settle_engine = both` (beide rechnen, Vergleich vor Veröffentlichung, genau eine Veröffentlichung, genau ein Satz Gutschriften und Level-Updates) ohne Abweichung | +| Laufzeit (zu messen, nicht zu versprechen) | Kettenverarbeitung, Rebuild eines Monats, Ende-zu-Ende-Aktualität; Zielwerte werden nach der ersten Messung in Phase 1 festgelegt | + +--- + +## 7. Cutover (nach Abnahme, zwei getrennte Schritte) + +### 7.1 Lese-Cutover + +Ein Adapter `BusinessFiguresProvider` mit zwei Implementierungen (`LegacyProvider` über +`TreeCalcBotOptimized`, `LedgerProvider` über `nodes`/`lines`/`edges`) und einem definierten +Zustand `unavailable` (Knoten fehlt, `calc_status = error`, Revision veraltet). `unavailable` darf +nie als Nullprovision erscheinen – der bestehende `failed`-Mechanismus in `LiveCommissionService` +wird übernommen. Umschaltung über `ledger.read`, Rückweg jederzeit. + +| Lesepfad | Umstellung | +| --- | --- | +| `LiveCommissionService::snapshot()` | Provider; 6-h-Cache entfällt | +| `PaylineForecastService::paylineContext()` | Payline-Tiefe aus nodes, Mitglieder aus edges | +| `TeamController` Struktur/DataTable/Level-Reports | Teilbaum aus edges + nodes; Sortierung `active_date, id` beibehalten | +| `ModalController` Berater-Detail | nodes + lines + Growth-Matrix + next_level_data | +| `BusinessControllerOptimized` (laufender Monat) | Datatable auf nodes | +| Home-Dashboard | wie LiveCommissionService | + +Abnahme je Lesepfad: Feature-Test, der dieselbe View mit beiden Providern rendert und die +View-Daten vergleicht, inklusive leerer und fehlerhafter Zustände. + +### 7.2 Abrechnungs-Cutover + +`business:store-optimized --settle-engine=ledger` schreibt `user_businesses` und +`user_business_structures` **aus dem Ledger** (Mapping inklusive `business_lines`, `user_items`, +`growth_bonus_details`, `sponsor`, `qual_user_level*` als JSON, damit Gutschriften, Level-Update +und alte Ansichten unverändert funktionieren). Bestehende `user_businesses`-IDs bleiben erhalten +(Gutschriften referenzieren sie); ein Wiederanlauf aktualisiert, statt zu löschen. Erster +Ledger-Abschluss mit `both`; Rückschalten der Lesequelle macht ausgeführte Gutschriften und +Level-Upgrades **nicht** rückgängig, deshalb der eigene Abnahmepunkt. + +--- + +## 8. Phasenplan + +| Phase | Inhalt | Ergebnis / Nachweis | Aufwand | +| --- | --- | --- | --- | +| **0 – Regel- und Datenvertrag** | Testbasis reparieren (`HasFactory` am User-Model oder Tests auf `forceCreate` umstellen); Anhang A/B gegen Code verifizieren; Fixtures: Kette, Gabel, Growth-Blocker, negative Salden, `"0.0"`/`"0"`-Rundung, Matrix ≠ Betrag, Tiefe 20/21/22, Parentless mit Kindern, Selbstsponsor, Zyklus; Legacy-Golden-Files + händische Sollrechnungen; Tiefen-Report und Herkunftsinventar auf der Dev-DB; DB-Version, Indizes, Horizon/Queue-Konfiguration prüfen; `known-differences.md` anlegen; Entscheidungen D1 – D6 einholen | `tests/Feature/BusinessLedger/Fixtures/`, Golden Files, Reports, Entscheidungsliste | 2 – 4 Tage | +| **1 – Deterministischer Monatsrechner** | Migrationen (5 Tabellen), `config/business.php`, Models, Regelklassen, `LedgerMonthCalculator` (Eingangsdatensatz → Ergebnis, ohne verstecktes Nachladen, Zyklen als Fehler), `business:ledger-backfill` mit Herkunftsreport, `business:ledger-compare`, `business:ledger-rebuild` mit Generation | Vergleich der Abnahmemonate; Unit-Tests der Regelklassen gegen Golden Files; erste Laufzeitmessung | 4 – 6 Tage | +| **2 – Ereignisse und Betrieb** | Hook in `reCalculateSalesPointsVolume()`, Observer `UserSalesVolume`/`User`/`UserLevel`, Journal, `business:ledger-process`, `-open-month`, `-verify`, Abschlussereignis im Legacy-Command, Scheduler, Status-Seite im Admin | Tests aus Anhang D (Umbuchung, gleichzeitige Ketten, Wiederanlauf, Monatsanlage, Regeländerung), auf SQLite und MySQL | 4 – 6 Tage | +| **3 – Shadow-Betrieb** | Deploy mit `ledger.read = legacy`; Reports auswerten; Abweichungen einordnen | Abnahmekriterien 6.5 für den Lese-Cutover | 2 – 3 Tage aktive Arbeit, ≥ 2 Monatsabschlüsse Kalenderzeit | +| **4 – Lese-Cutover** | Provider-Adapter, Lesepfade aus 7.1, Caches entschärfen | View-Vergleichstests je Lesepfad | 2 – 3 Tage | +| **5 – Abrechnungs-Cutover** | `--settle-engine`, Mapping in `user_businesses`, `both`-Lauf | Abnahme 6.5 für die Abrechnung | 2 – 3 Tage | +| **6 – Aufräumen** (optional) | Legacy-Klassen auf Regelklassen, `TreeCalcBot` (nicht-optimiert) entfernen | Testsuite grün | 1 – 2 Tage | + +Gesamt Phasen 0 – 5: **16 – 25 Tage** plus Beobachtungszeit. Das ist eine Planungsschätzung, die +nach Phase 0 (Herkunftsinventar, Tiefenreport) aktualisiert wird. Phase 1 ist für sich wertvoll: +Der Backfill-Vergleich deckt Legacy-Fehler auf, ohne dass etwas im Betrieb geändert wird. + +--- + +## 9. Entscheidungen, die vor Phase 1 gebraucht werden + +| # | Frage | Empfehlung | +| --- | --- | --- | +| D1 | **Verarbeitung:** serialisierter Minuten-Verarbeiter je Monat (Scheduler) oder Queue mit Worker? | Scheduler-Verarbeiter. Queue erst mit Konkurrenztests; dann Horizon-Supervisor um `business-ledger` ergänzen und `QUEUE_DRIVER` (nicht `QUEUE_CONNECTION`) setzen. | +| D2 | **Baumprädikat:** Parität zur heutigen Regel (nur `payment_account` gesetzt, Gültigkeit egal)? | Ja, Parität. Die Regel steht an einer Stelle; `exclusion_reasons` liefert die Grundlage für einen späteren fachlich definierten Schalter. | +| D3 | **Growth-Rundung:** Legacy-Doppelgruppe `"0.0"`/`"0"` nachbilden (Parität) oder zusammenfassen (Korrektur, Cent-Abweichungen)? | Phase 1 – 3 Parität; Korrektur als Eintrag in `known-differences.md` mit Betrag, Entscheidung der Kundin vor dem Abrechnungs-Cutover. | +| D4 | **Negative Monatssalden:** Legacy zählt für die Upline nur positive `tp_sum`; ein Storno mindert die Upline also nur, solange der Saldo des Kindes positiv bleibt. Parität oder Korrektur? | Parität im Ledger; fachliche Frage an die Kundin, zusammen mit der Stornoregel vom 09.09. | +| D5 | **Tiefenlimit:** Legacy 21 Ebenen, Ledger unbegrenzt. | Nach Tiefenreport: liegt die reale Tiefe deutlich unter 21, ist es kein Thema; sonst Vergleich kennzeichnet `depth > 21`. | +| D6 | **Stichtag:** Annahmestopp für Struktur um 00:05 am Ersten; Punkte bis zum Abschlusslauf. Legacy nimmt Sponsorstand des Abschlusstags. | 00:05, als dokumentierter Unterschied zum Legacy (Sponsorwechsel zwischen 00:05 und 03:00 am Ersten). | + +--- + +## 10. Anschluss: Statistik auf dieselbe Basis (Folgeprojekt, hier nur vorbereitet) + +- `BackofficeDashboardService::lineBuckets()` und `PaylineForecastService::paylineContext()` können + nach dem Lese-Cutover die Ebenen aus `business_ledger_edges` beziehen – Standard `in_tree = 1` + (Provisionssicht); ein Schalter „inklusive …" braucht eine eigene fachliche Definition auf Basis + von `exclusion_reasons` (z. B. nur `no_payment_account`), nicht `in_tree = 0`. +- Die VIP-Snapshots (`backoffice_statistics_snapshots`) können für alle Berater aus dem + eingefrorenen Ledger-Monat abgeleitet werden. +- `user_business_structures.structure` wird dann nur noch aus Kompatibilität geschrieben. + +--- + +## Anhang A – Prädikate (aus dem Code, Stand 10.09.2026) + +**Kind im Provisionsbaum** (`BusinessUserItemOptimized::readParentsBusinessUsers()`, Zeilen 1080 – 1091): + +``` +deleted_at IS NULL AND id <> 1 AND admin < 4 AND m_level IS NOT NULL +AND id <> m_sponsor AND m_sponsor = +AND payment_account IS NOT NULL AND active_date <= +Sortierung: active_date, id +``` + +**Root** (`BusinessUserRepository::getRootUsers()`): wie oben, zusätzlich `m_sponsor IS NULL` und +`payment_account > `. **Parentless** (`getParentlessUsers()`): `deleted_at IS NULL, id <> 1, +admin < 4, payment_account IS NOT NULL, active_date <= `, nicht bereits im Baum – **ohne** +`m_level`-Bedingung. + +**Wird berechnet** (Ledger `is_consultant`): `deleted_at IS NULL AND id <> 1 AND admin < 4 AND +payment_account IS NOT NULL AND active_date <= Monatsende`. + +**Punkte** (`User::getUserSalesVolumeBy()`): letzte Zeile (`id DESC`) von `user_sales_volumes` für +(user, month, year); `kp_sum = month_KP_points + month_shop_points`, +`tp_sum = month_TP_points + month_shop_points`, `total_net_sum = month_total_net + month_shop_total_net`. +Keine Zeile → alle Werte 0. + +## Anhang B – Regelkatalog Qualifikation und Provision + +1. **Ebenen** (`calculateUserPointsOptimized()`, `collectChildrenPointsForUser()`): je Nachkomme + auf Ebene i wird `tp_sum` addiert, **nur wenn `tp_sum > 0`**. +2. **Kandidaten-Level** (`calcuQualLevel()`, 833 – 835): alle `user_levels` mit `qual_kp <= kp_sum` + und `pos <= career_pos`, absteigend nach `pos`. +3. **Je Kandidat:** `payline_points = Σ lines[1..paylines].points`; `rest_kp = max(0, kp_sum − + level.qual_kp)`; qualifiziert wenn `payline_points + rest_kp >= level.qual_pp`. Erster Treffer. + `calc_qual_kp = rest_kp > 0 ? level.qual_kp : kp_sum`. +4. **Kein Treffer:** `qual_level = null`, `qual_user_level_next` = Level mit `pos = 1` inkl. + `_calculated_*` (`setFirstQualLevel()`). Alle übrigen Provisionsfelder 0. +5. **Nächstes Karriere-Level** (`setNextUserLevel()`): Level mit `pos = career_pos + 1`. Wenn + `kp_sum < next.qual_kp` → `next_can`; sonst wenn `payline_points(next.paylines) + rest_kp(next) >= + next.qual_pp` → `next_qual`; sonst `next_can`. Kein Level → beide null. +6. **Nächste Provisionsstufe** (`setQualNextLevel()`): nur wenn `qual_level.id <> career_level_id` + und `qual_level.next_id` gesetzt: Level `next_id` mit `_calculated_*`; sonst null. +7. **Payline-Provision** (`calculateCommissions()`): `i = 1..qual.paylines`: + `round(lines[i].points / 100 * qual.pr_line_i, 2)`; Summe der gerundeten Einzelwerte. +8. **Shop-Provision:** `round(own_shop_total_net / 100 * career_level.margin_shop, 2)` – mit dem + **Karriere**-Level (`initializeFromUserModel()`), nicht mit dem Quali-Level. +9. **Growth-Bonus** (ab 11/2025, `GrowthBonusCalculator::calculate()`), nur wenn + `qual.growth_bonus > 0`: für jedes direkte Kind C die Verteilung `dist(C)` (Abschnitt 4.1, + Schritt 4; Eigenvolumen nur wenn `> 0`, Schlüssel `"0.0"`; durchfließendes Volumen unter + `(string) max(k, growth_pct(C))`, für 0 also `"0"`); je Kind **und** je Bucket + `round(v / 100 * max(0, growth_pct(X) − k), 2)`; Summe. Schutz eines Knotens = `growth_bonus` + seines **erreichten** Quali-Levels, 0 wenn nicht qualifiziert. +10. **Growth-Matrix** (`getMatrixDetails()`): je Person `round(volumen / 100 * (growth_pct(X) − + max Schutz der Zwischenvorfahren), 2)`; Summe kann vom Betrag aus 9 abweichen. +11. **Tiefe:** Kinder werden geladen, solange `depth <= 20` (Wurzel = 0), also 21 Ebenen; Growth + ab dem direkten Kind (Tiefe 0) ebenfalls bis `depth <= 20`. +12. **Reihenfolge:** Kinder vor Eltern (`calculateQualificationsForStructure()`), damit Schutzwerte + vorliegen. + +## Anhang C – Konsumenten von `user_businesses` (bleiben bis Phase 5 unberührt) + +`app/Cron/UserPaymentCredits.php` (commission_pp_total, commission_shop_sales, +commission_growth_total; Idempotenz über `user_business_id` + `status`), `app/Cron/UserLevelUpdate.php` +(next_qual_user_level, `hasUpdated`), `app/Services/LevelReportService.php`, +`app/Services/NextLevelBadgeHelper.php`, `app/Http/Controllers/BusinessControllerOptimized.php`, +`app/Repositories/CreditRepository.php`, Views unter `resources/views/admin/business_optimized/`, +`user/team/`, `admin/modal/business_user_detail`. + +## Anhang D – Test- und Abnahmematrix + +| Testfall | Zu beweisende Eigenschaft | +| --- | --- | +| Negativer eigener TP-Saldo, positiver Geschwistersaldo | Behandlung nach D4 in Ebenen, Qualifikation, Growth | +| Zwei Firstlines mit je 0,10 Punkten; Kind mit 0,10 eigen + 0,10 ungeschützt darunter | `"0.0"`/`"0"`-Rundung nach D3, centgenau | +| Growth-Betrag vs. Matrixsumme | Abweichung sichtbar, getrennt verglichen | +| Letzte Buchung von A nach B; Löschen der letzten Buchung | A wird 0, beide Uplines aktuell | +| `setToUserAndReCalculate` mit Wechsel von User und Monat | alter und neuer Schlüssel verarbeitet | +| kein Level, höchstes Level, Level-Abstieg | alle zuvor gesetzten Felder werden überschrieben | +| neuer User, Ein-/Austritt aus dem Baum, Parentless mit Kindern | eigener Zustand und Vorfahren stimmen; Parentless rechnet seinen Teilbaum | +| gleichzeitige Geschwisterbuchungen; Ereignis während laufender Verarbeitung | keine ältere Revision überschreibt eine neuere; Ereignis bleibt offen | +| Rollback der Buchungstransaktion; Abbruch des Verarbeiters | keine falsche Veröffentlichung; Ereignis bleibt offen | +| Monatsanlage nach ersten Buchungen; zweimalige Anlage | idempotent, vorhandene Werte bleiben | +| Abschluss mit fehlgeschlagenem User, Wiederanlauf | keine voreilige Freigabe, keine doppelten Gutschriften, IDs bleiben | +| Regeländerung nach `frozen` | historischer Rebuild reproduzierbar aus `levels_snapshot` | +| Rebuild während Buchung/Sponsorwechsel | Generation konsistent, neue Ereignisse offen | +| Tiefe 20/21/22, Selbstsponsor, mehrgliedriger Zyklus | keine Endlosschleife; Zyklus als Fehler; Tiefe nach D5 | +| identische Fixtures auf SQLite und MySQL 8 | gleiche Ergebnisse; Sperren auf MySQL geprüft | +| jeder Leseadapter mit beiden Providern | gleiche View-Daten, gleiche Sortierung, `unavailable` korrekt | +| Legacy-Vergleich mit frischen User-Instanzen | kein Monats-Cache-Effekt aus `getUserSalesVolumeBy()` | diff --git a/dev/_tickets/2026-09-08.md b/dev/_tickets/2026-09-08.md new file mode 100644 index 00000000..654bca61 --- /dev/null +++ b/dev/_tickets/2026-09-08.md @@ -0,0 +1,92 @@ +Ticket #1 +Wir brauchen einen neuen Navigationspunkt, indem wir Berater übertragen können, d.h. die Kunden und den ganzen Berater Stamm auf einen einzelnen Berater. +Hintergrund ist, wenn zum Beispiel ein Berater aufhört, soll es die Möglichkeit geben, dass ein anderer Berater sein ganzen Berater und Kundenstamm bekommt ähnlich ist es wenn ein Berater nicht mehr zahlt, geht es an seinen Sponsor über. + +Beispiel: Nadine Ried: Der ganze Baum soll an Claudia Zürn übertragen werden. + +Ticket #2 +Kunden löschen. Wenn ein Kunde eine Bestellung hat, kann man ihn so nicht mehr aus dem System löschen. Das ist etwas unglücklich unten gibt es einen Button DSGVO löschen, Hier werden die Sachen unkenntlich gemacht, das ist auch noch etwas unglücklich. +Ich würde es so machen kriegt man auf Kunden löschen geht ein Modal Fenster auf wo man die Möglichkeit hat den Kunden komplett zu löschen falls er noch keine Bestellung hat oder automatisch dort steht. Der Kunde hat schon eine Bestellung es geht nur DSGVO löschen. Wenn dann ein Kunde gelöscht wird oder entfernt wird, ist es wichtig, dass seine personenbezogenen Daten raus sind und auch weitere Kundenanlagen denn es ist möglich, dass der Kunde mehrfach angelegt ist. Wenn er mehrere Bestellung hat, wird jeweils ein Kunde angelegt d.h. hier müsste schon im Vorfeld geprüft werden. Wie oft der Kunde würde ich über die E-Mail-Adresse machen dies Unique in diesem Augenblick auswerten und sagen hier und hier wird der Eintrag entsprechend entfernt. + +Ein Beispiel ist der Kunde 61009 Hier gibt es mehr Anlagen. + + + + + +--- + +# Umsetzung (14.09.2026) + +## Ticket #1 – Berater übertragen + +**Neuer Navigationspunkt** im Superadmin-Bereich: *Berater übertragen* (unterhalb von *User Bereinigung*), URL `/admin/user/transfer`. + +**Ablauf auf der Seite** +1. *Von Berater* (gibt den Stamm ab, alle nicht gelöschten Berater wählbar) und *An Berater* (übernimmt; nur aktive, nicht gesperrte Berater mit gültigem Account) über Live-Suche auswählen → *Vorschau anzeigen*. +2. Vorschau zeigt: Firstline-Berater (mit Liste), gesamte Downline (alle Ebenen), deaktivierte Berater mit geparktem Sponsor-Bezug, Kunden-Datensätze (davon gelöscht), Kunden-Abos (davon aktiv), Portal-Logins. +3. Checkbox bestätigen → *Übertragung ausführen*. Erfolgsmeldung mit Anzahl der bewegten Firstline-Berater, Kunden und Abos. + +**Was übertragen wird** (in einer DB-Transaktion, `App\Services\ConsultantTransferService`) +- Firstline: `users.m_sponsor` = Quelle → Ziel. Die Teilbäume darunter folgen automatisch (sie hängen an ihrer Firstline). +- Geparkte Sponsor-Bezüge deaktivierter Berater: `users.pre_sponsor` = Quelle → Ziel (Reaktivierung landet dann unter dem Zielberater). +- Kunden: `shopping_users.member_id` = Quelle → Ziel, auch soft-gelöschte Einträge. +- Kunden-Abos: `user_abos.member_id` = Quelle → Ziel (künftige Abo-Bestellungen werden dem Zielberater zugeordnet). +- Kunden-Portal-Logins: `customers.member_id` = Quelle → Ziel. + +**Was bewusst nicht übertragen wird** +- Bestehende Bestellungen (`shopping_orders.member_id`), Umsätze und Provisionsdaten bleiben als Historie beim bisherigen Berater. Bereits berechnete Monats-Snapshots (`user_businesses`, `user_business_structures`) werden nicht rückwirkend geändert; der laufende Monat wird bei der nächsten Provisionsberechnung (`business:store-optimized`) auf Basis der neuen Struktur gerechnet. +- Der Quellberater selbst bleibt unverändert (Sponsor, Status). Deaktivieren/Löschen erfolgt wie bisher separat (User-Verwaltung / DSGVO). + +**Schutzregeln** +- Quelle ≠ Ziel; Ziel muss aktiv, nicht gesperrt, nicht gelöscht und mit gültigem Account sein. +- Ziel darf nicht *unterhalb* der Firstline der Quelle liegen (würde einen Kreis im Baum erzeugen) → Fehlermeldung. +- Ziel *in* der Firstline der Quelle ist erlaubt: Der Zielberater behält seinen Sponsor und bekommt die übrigen Firstline-Berater als Downline (Hinweis in der Vorschau). + +**Protokollierung**: wie bei der automatischen User-Bereinigung – `user_clean_up_logs` (je umgehängtem Berater) und `shopping_user_member_logs` (je Kunde), damit die Übertragung in *User Bereinigung → Downline-Übertragungen / Kunden-Übertragungen* sichtbar ist. Zusätzlich SysLog-Eintrag `consultant-transfer` und Log-Kanal `cleanup`. + +**Beispiel aus dem Ticket** (Nadine Ried → Claudia Zürn): *Von Berater* = Nadine Ried, *An Berater* = Claudia Zürn, Vorschau prüfen, bestätigen. + +**Dateien** +- neu: `app/Services/ConsultantTransferService.php`, `app/Exceptions/ConsultantTransferException.php`, `app/Http/Controllers/AdminUserTransferController.php`, `app/Http/Requests/UserTransfer/{Preview,Execute}ConsultantTransferRequest.php`, `resources/views/admin/user/transfer/index.blade.php`, `tests/Feature/ConsultantTransferTest.php` +- geändert: `routes/domains/crm.php` (3 Routen im Superadmin-Block), `resources/views/layouts/includes/layout-sidenav.blade.php`, `resources/lang/{de,en,es,fr}/navigation.php` (`user_transfer`), `resources/lang/{de,en,es,fr}/msg.php` (`transfer_*`) + +## Ticket #2 – Kunden löschen + +**Neues Verhalten auf der Kunden-Detailseite (Admin)**: Der Button *Kunden löschen* öffnet ein Modal statt direkt zu löschen. Der alte Direkt-Link (GET) ist entfernt, die Löschung läuft jetzt per POST mit Bestätigungs-Checkbox. + +**Das Modal zeigt** +- wie oft der Kunde angelegt ist, ermittelt über **Kundennummer ODER E-Mail-Adresse** (Groß-/Kleinschreibung egal; Platzhalter-Adressen wie Faker-/bereits anonymisierte Mails werden nicht zur Gruppierung benutzt, sonst würden fremde Kunden zusammengezogen). Beispiel Kunde 61009: alle Einträge mit derselben Kundennummer plus alle Endkunden-Einträge mit derselben E-Mail, auch mit anderer Kundennummer. +- eine Tabelle aller betroffenen Einträge (ID, Kd-Nr., Name, Berater, Anzahl Bestellungen, angelegt am, ggf. „gelöscht“). +- **Fall A – keine Bestellung in keinem Eintrag**: Hinweis *„kann komplett gelöscht werden“* + Checkbox + Button *Kunde komplett löschen* (Admin-Recht genügt). +- **Fall B – mindestens ein Eintrag hat eine Bestellung**: Hinweis *„Der Kunde hat schon N Bestellung(en). Es ist nur DSGVO löschen möglich“* + Checkbox + Button *Kunde DSGVO-löschen* (wie bisher nur Superadmin; normale Admins sehen den Hinweis). +- Bei aktivem Abo wird jede Löschung blockiert (Abo zuerst kündigen), bei Berater-Datensätzen wird auf die User-Verwaltung verwiesen. + +**Komplett löschen** (`GdprDeletionService::purgeShoppingUser`), nur ohne Bestellungen: +- alle Einträge der Gruppe werden vollständig anonymisiert (Name, Adressen, Telefon, E-Mail, Notizen, Newsletter) **und** soft-gelöscht → verschwinden aus allen Listen; +- inaktive Abos verlieren E-Mail, Kartendaten und Zahlungs-Tokens (Payone/Stripe); +- Portal-Logins (`customers`) werden gelöscht; +- Stripe-Customer-Objekte werden gelöscht; Löschnachweis im SysLog (`gdpr-purge-customer`) und Log-Kanal `cleanup`. +- Kein Hard-Delete der Zeilen: Fremdschlüssel (Abos, Checkout-Funnel, Logs) zeigen auf `shopping_users`, und der Lösch-Nachweis bleibt erhalten. Personenbezogene Daten sind vollständig entfernt. + +**DSGVO löschen** (bestehend, erweitert): arbeitet jetzt ebenfalls auf der E-Mail-Gruppe (nicht mehr nur Kundennummer) und entfernt zusätzlich die Portal-Logins. Wie bisher bleiben bei Einträgen mit Bestellung Name und Rechnungsadresse (Aufbewahrungspflicht), alle Kontaktdaten werden entfernt. + +**Entfernt**: die alte „Löschung“ (`CustomerRepository::deleteCustomer`), die nur den einen Datensatz mit „Deleted User“ überschrieb und ihn in der Liste stehen ließ. + +**Nebenkorrektur**: Fehlermeldungen der DSGVO-Löschung wurden mit dem Session-Key `alert-danger` gesetzt, den kein Layout anzeigt – jetzt `alert-error` (wird als Growl-Meldung angezeigt). + +**Offener Punkt / Hinweis**: DHL-Versanddatensätze (`dhl_package_shipments`) enthalten Empfängerdaten je Bestellung und werden – wie Bestellungen und Rechnungen – bewusst nicht angefasst (Beleg zur Bestellung). Falls gewünscht, könnte dort bei DSGVO-Löschung zusätzlich E-Mail/Telefon geleert werden. + +**Dateien** +- geändert: `app/Services/GdprDeletionService.php` (`getCustomerRows`, `groupingEmail`, `analyzeShoppingUser`, `purgeShoppingUser`, `deletePortalCustomers`), `app/Http/Controllers/CustomerController.php` (`delete` → POST + Purge, `detail` liefert Analyse), `app/Repositories/CustomerRepository.php`, `routes/domains/crm.php` (`admin_customer_delete` GET → POST), `resources/views/admin/customer/detail.blade.php` (Modal), `resources/views/admin/customer/_detail.blade.php` (Button), `resources/lang/{de,en,es,fr}/msg.php` (`gdpr_purge_*`, `gdpr_error_customer_has_orders`, `delete_confirm_checkbox_missing`) +- neu: `tests/Feature/CustomerDeleteTest.php` + +## Tests + +```bash +php artisan test --compact tests/Feature/ConsultantTransferTest.php tests/Feature/CustomerDeleteTest.php tests/Feature/GdprDeletionTest.php +``` + +Hinweis für HTTP-Tests auf Admin-Routen: Die CRM-Routen werden host-abhängig geladen. Die beiden neuen Testdateien setzen deshalb in `beforeEach` `$_SERVER['HTTP_HOST']` auf den CRM-Host, starten die App neu, migrieren die In-Memory-DB und stellen den Host in `afterEach` wieder her (sonst booten nachfolgende Tests mit den CRM-Routen). + +Stand der Gesamt-Suite am 14.09.2026: 92 Fehlschläge bestehen unabhängig von diesen Tickets bereits vorher (identische Liste mit und ohne die neuen Dateien); alle 22 neuen Tests sind grün. diff --git a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md index afe8a7c3..2cdeb6dc 100644 --- a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md +++ b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md @@ -1,6 +1,6 @@ # Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026 -**Stand:** 18.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort. **E1 (Recruiting-Challenge) umgesetzt 18.09., Deploy offen** — Konfiguration im Admin, Details unten. +**Stand:** 23.09.2026 · **Seit 18.09.:** B2 umgesetzt (Deploy offen), E1 Gleichstand + Zählung nach Zahlungsdatum, A07, Warnung für überfällige Abos (`abo:check-overdue`) — Details in den Abschnitten. Kundenübersicht B2: [2026-09-23-mitgliedschaft-ablauf-uebersicht.md](2026-09-23-mitgliedschaft-ablauf-uebersicht.md). · Vorher: **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort. **E1 (Recruiting-Challenge) umgesetzt 18.09., Deploy offen** — Konfiguration im Admin, Details unten. **Quellen:** `2026 09 Protokoll September-komprimiert.pdf` (Protokoll), `2026-09-14.md` (Ticket-Entwurf Abos #1–#3) **Verwandte Pläne:** `dev/2026-07-22/entwicklungsplan-2026-09-07.md` (K11–K14 behandeln dieselben Themen aus dem Kundenbrief; die dortigen Rückfragen F4–F7 sind weiterhin offen), `dev/2026-09-10-business-plan/` (Ledger-Plan, löst A07/K11 langfristig) @@ -295,7 +295,34 @@ Dazu gibt es einen dritten, abweichenden Pfad: setzt ein Admin das Datum in die - **Admin-Restore nach dem harten Cut** (`user:restore`) holt heute nur das Team über die Logs zurück. Laut Kundin ist nach dem Cut eine Neuregistrierung mit Starterpaket der Regelfall; Restore bleibt Admin-Ausnahme und holt zurück, was die Logs noch hergeben – so dokumentieren. - Nebenbefunde: Kernel referenziert `UserCleanup::class`, Klasse heißt `UserCleanUp` (funktioniert nur durch case-insensitives Autoload); `AdminUserCleanupController::restore()` ruft `user:restore` zweimal auf (Zeilen 319/325). -**Aufwand:** 3–4 PT. **Entschieden (14.09.):** Sponsor bekommt bei Stufe 1 die Kurzinfo ohne Listen, bei Stufe 2 die Listen-Mail; der Status-Umbruch beim Deploy ist akzeptiert. **Offen (R3):** 2 Tage Versatz auch für Karten-/PayPal-Zahler oder dort sofort? +**Aufwand:** 3–4 PT. **Entschieden (14.09.):** Sponsor bekommt bei Stufe 1 die Kurzinfo ohne Listen, bei Stufe 2 die Listen-Mail; der Status-Umbruch beim Deploy ist akzeptiert. **R3 beantwortet:** 2 Tage Versatz für alle Zahlungsarten. + +#### Umsetzung B2 (23.09.2026) — Deploy offen + +- **Ein Weg für alle drei Pfade:** [`ConsultantSuspensionService`](../../app/Services/ConsultantSuspensionService.php) mit `suspend()`, `reinstate()`, `terminate()`. `user:cleanup`, der Zahlungseingang (`Payment::paymentStatusPaidAction`) und das Ablaufdatum im Admin (`AdminUserController`, save-account) rufen nur noch diese Methoden. +- **Fristen** in `config/mivita.php`: `cleanup_suspend_days` = 2, `cleanup_delete_days` = 28 (Env `MIVITA_CLEANUP_SUSPEND_DAYS` / `MIVITA_CLEANUP_DELETE_DAYS`). +- **Stufe 1 (Ablauftag + 2 Kalendertage, Nachtlauf 03:30):** Team wie bisher über `user_clean_up_logs`, dazu jetzt Kunden (`shopping_users`, auch gelöschte), Kundenabos (`user_abos.member_id`) und Portal-Logins (`customers`). Jede Umhängung steht in der neuen Tabelle `consultant_suspension_transfers` (Migration `2026_09_23_140613`); Kunden zusätzlich wie bisher in `shopping_user_member_logs`, damit sie in der User-Bereinigung sichtbar bleiben. Kurzinfo an den Sponsor (`SponsorSuspensionNoticeMail`), keine Mail an das Firmenkonto. +- **Rückgabe:** Team über die bestehenden Logs, Kundendaten über `consultant_suspension_transfers` — nur Datensätze, die noch bei dem Sponsor liegen, der sie bekommen hat. Von Hand weiter übertragene Kunden bleiben, wo sie sind. +- **Stufe 2 (Ablauftag + 28 Kalendertage; Zahlung bis Tag 27 gibt alles zurück):** was noch am Berater hängt (auch Altfälle, die nach der alten 2-Wochen-Regel inaktiv sind und deren Kunden nie umgezogen wurden), geht endgültig an den Sponsor. Der Berater wird Kunde des Sponsors — ohne Duplikat, wenn dort schon ein Kundendatensatz mit seiner E-Mail oder seinem Login liegt. Listen-Mail (`SponsorTransferMail`, Vorname/Nachname/Telefon/E-Mail) an jeden Sponsor, der Kunden oder Team dieses Beraters hält; beim Sponsor des harten Cuts steht der Berater selbst mit auf der Liste. +- **Texte:** Mail 35 und Dashboard-Hinweis nennen jetzt das Datum der Pause und die Frist für die Rückgabe (`:suspend_date`, `:deadline`), de/en/es/fr. Neue Sprachdatei `suspension.php`. +- **`user:cleanup --dry-run`** listet beide Stufen mit Team, Kunden, Kundenabos, Portal-Logins und bereits geparkten Datensätzen, ohne etwas zu ändern oder zu senden. +- **Nebenbefunde behoben:** Kernel referenziert `UserCleanUp` (vorher `UserCleanup`), `AdminUserCleanupController::restore()` ruft `user:restore` nur noch einmal auf. +- **Testschema:** `shopping_users.auth_user_id` ist auf Produktion nullable, in der Ursprungsmigration NOT NULL — `setUserToClient()` scheiterte deshalb nur in der Testdatenbank. Migration `2026_09_23_141152` zieht das nach und ändert nur, wo die Spalte noch NOT NULL ist (auf Produktion nichts). +- **Tests:** [`ConsultantSuspensionTest`](../../tests/Feature/ConsultantSuspensionTest.php) (12 Tests): Tag +1 nichts, Tag +2 Stufe 1 komplett, Kurzinfo mit Frist ohne Listen, keine Mail an Firma, Trockenlauf, Rückgabe per Zahlung, manuell verschobener Kunde bleibt, harter Cut mit Mail-Inhalt, kein Duplikat, Altfall mit liegengebliebenen Kunden, Admin-Pfad, `user:restore` einmal. + +**Messung an der Live-Kopie (23.09.):** Der erste Lauf würde 6 Berater pausieren (64 Kundendatensätze, 2 Teampartner) und 20 Berater endgültig entfernen (150 Kundendatensätze, 1 Portal-Login). Keine aktiven Kundenabos betroffen. Heute liegen bei 24 inaktiven Beratern 162 Kundendatensätze, deren Umsätze bei niemandem ankommen. + +**Deploy:** + +```bash +php artisan migrate # consultant_suspension_transfers (+ Schema-Angleichung, auf Produktion ohne Wirkung) +php artisan view:clear && php artisan config:clear +php artisan user:cleanup --dry-run # Liste an Alois, erst danach laufen lassen +``` + +Der reguläre Lauf um 03:30 übernimmt danach. Kein Snapshot-Neuaufbau. + +**Offen / bewusst nicht gebaut:** eigenes Berater-Abo (`is_for = me`) eines pausierten Beraters läuft zu Partnerkonditionen weiter (heute kein Fall); Admin-Restore nach dem harten Cut holt wie bisher nur das Team über die Logs zurück. --- @@ -476,7 +503,8 @@ Das Incentive-Modul trägt jetzt optional eine Geldprämie; Montenegro bleibt un **Offen / bewusst nicht gebaut** - Bonushöhe Top 3, Beschreibungstexte, Bild (Dani/Alois). -- **Punktgleichstand** in den Top 3: Rangfolge ist dann bestätigte Teilnahme vor anonym, sonst Datenbankreihenfolge. Falls es auf die Boni ankommt, Regel festlegen (z. B. wer die Anzahl zuerst erreicht hat) – Umsetzung 0,5 PT. +- ~~**Punktgleichstand** in den Top 3~~ — **umgesetzt 23.09.** (Teilnahmebedingungen Punkt 6): bei gleichen Punkten bestätigte Teilnahme vor anonym, danach wer den Stand zuerst erreicht hat (Zeitpunkt des letzten zählenden Neupartners bzw. Neuabos aus `incentive_new_partners.registered_at` / `incentive_new_abos.activated_at`), zuletzt die Teilnehmer-ID. `IncentiveTracker::updateRanking()`, Tests in `IncentiveRankingTest`. An der Live-Kopie gegengerechnet: Montenegro tauscht nur Plätze ab 81, die 20 Gewinner bleiben gleich. +- **Neupartner zählen ab der Zahlung (23.09.):** wie in der Statistik (Kundenentscheid 16.09.) zählt `users.partner_paid_at`, nicht das Bestelldatum — im Live-Tracking, in der nächtlichen Reparatur und im Neuaufbau (`IncentiveTracker::newPartnerCountedAt()`). Wer am 29.09. bestellt und am 01.10. zahlt, zählt in der Challenge. Montenegro ist abgeschlossen und bleibt unberührt; im Admin auf „Beendet“ stellen, damit der Nachtlauf es nicht mehr anfasst. - **Stornos/Rücktritte:** Ein einmal getrackter Neupartner bleibt gezählt, auch wenn die Registrierung später storniert wird (Verhalten wie Montenegro). Vor der Auszahlung die Liste gegen stornierte Registrierungsbestellungen prüfen oder Regel mit Dani/Alois klären. - Gutschrift-Erzeugung aus dem Export (siehe oben). - Firmenweite Rangliste (D2) unverändert offen; die Challenge-Rangliste ist wie Montenegro für alle aktiven Berater sichtbar. diff --git a/dev/errors-26-08-2026.md b/dev/errors-26-08-2026.md new file mode 100644 index 00000000..e69de29b