mivita/dev/2026-07-22/audit-statistiken-2026-09-09-nachpruefung.md
Kevin 233cd01bb5 Doku: Statistik-Audit 09.09., Bewertung und Umsetzungsstand
Der Ordner dev/2026-07-22 ist ueber Monate gewachsen; ohne Einstieg findet man
darin nichts mehr. Deshalb ein README als Wegweiser, mit dem Hinweis, dass bei
Widerspruechen immer die juengere Entscheidung gilt (Beispiel: F7 wurde am 10.09.
abgeloest).

Die Bewertung traegt oben einen Stand-Block mit erledigten und offenen Befunden
samt Sprungmarken. Ueberholte Aussagen der ersten Fassung sind markiert statt
geloescht, damit der Weg nachvollziehbar bleibt.

Der Monatsabschluss steht als eigenes Dokument, weil es eine dauerhafte
Fachregel ist und keine Audit-Antwort: was eingefroren wird, was die Regel nicht
leisten kann, und die Betriebsbefehle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:19:50 +00:00

32 KiB
Raw Blame History

Nachprüfung der Audit-Bewertung und Storno-Korrekturen — 09.09.2026

Geprüft wurden die Bewertung einschließlich Nachträgen 8 und 9, die Änderungen gegenüber dem ursprünglichen Audit, vorhandene Regressionstests und zusätzliche Gegenbeispiele. Ergänzend erfolgten ausschließlich lesende Aggregatabfragen gegen die im Entwicklungscontainer konfigurierte MySQL-Datenbank. Diese Datenbank wird hier nicht ungeprüft mit dem laufenden Produktionssystem gleichgesetzt.

1. Ergebnis

Die Korrektur für reguläre Eigen- und Shop-Bestellungen ist nachvollziehbar und durch Tests bestätigt. Die abschließende Aussage „A15 und A06 erledigt“ ist jedoch zu weitgehend: A06 besteht weiterhin. Außerdem hat die neue Herkunftszuordnung einen nicht abgedeckten Sonderfall bei manuellen Gutschriften, und die neuen Tests zur Monatsregel sind nicht unabhängig vom tatsächlichen Systemmonat.

Gegenstand Ergebnis der Nachprüfung
A15: doppelte Berücksichtigung eines Shop-Stornos Behoben: Eigen-/Shop-Prädikate sind disjunkt. Regulärer Shop-Storno mindert Shop-Punkte und Shop-Nettobasis genau einmal.
A15: früher falsche Herkunft bei regulären Shop-Bestellungen Für neue Stornos behoben: Status 2 des Originals führt zuverlässig zu status_turnover=2. Vorhandene Altzeilen bleiben absichtlich unverändert.
A06: Aufteilung in wiederkehrende Punkte und Einmal-Zusätze Nicht behoben: Zusätze einer vollständig stornierten Bestellung bleiben in der Monatsaufteilung enthalten; Nachweis R01.
Neue Zuordnung für Status 4/Shop-Umsatz Fehlerhafter Sonderfall: Storno spiegelt die ursprünglichen Punktekategorien nicht; Nachweis R02.
Neue Monatsregel und Settings-Schalter Im derzeitigen Monat funktionieren die dokumentierten Kalenderfälle. Schalter ist ohne Eintrag aus; eingeschaltet wird im laufenden Monat gebucht. Testuhr wird jedoch umgangen; R03.
Migration status_turnover Passt zur gelesenen Datenbankdefinition: nullable, kein Default. Kein Daten-Backfill enthalten.
Übrige 21 Audit-Befunde Die zugehörige Implementierung wurde gegenüber der ursprünglichen Prüfbasis nicht korrigiert. Änderungen ihrer Priorität sind Bewertungen, keine Behebungen.

Die notwendigen nächsten Schritte sind damit überschaubar: A06 offen lassen und die Positionsaufteilung korrigieren, die Herkunft von Punkten und Nettoumsatz beim Status-4-Sonderfall getrennt behandeln und die Monatstests von der Systemuhr lösen. Für diese Korrekturen muss keine erneute Entscheidung über den bereits festgelegten Zeitpunkt der Stornobuchung abgewartet werden.

2. Tatsächlich geänderter Umfang

Der SHA-256-Vergleich mit der ursprünglichen Audit-Prüfbasis zeigt folgende neue bzw. geänderte Implementierung:

  • app/Services/BusinessPlan/SalesPointsVolume.php: Monatsregel, Schalterabfrage, Herkunft des Stornos und exklusive Eigen-/Shop-Neuberechnung.
  • app/Services/Backoffice/SalesVolumePoints.php: disjunkte Eigen-/Shop-Prädikate.
  • resources/views/admin/settings/index.blade.php: neuer Schalter.
  • database/migrations/2026_09_09_140000_make_status_turnover_nullable_on_user_sales_volumes.php: neue Migration.
  • tests/Feature/StornoClosedMonthTest.php und StornoPointsCategoryTest.php: elf neue Tests.

Die Änderungen wurden gegen den damaligen Arbeitsstand verglichen, nicht nur gegen Git-HEAD. Dadurch werden bereits vor dem Audit vorhandene, noch nicht eingecheckte Änderungen nicht versehentlich als jetzige Korrekturen gewertet. Die Anwendungsdatei BackofficeDashboardService.php, in der A06 entsteht, ist unverändert.

3. Verbliebene und neu erkannte Probleme

R01 — P2: A06 bleibt trotz korrigierter Storno-Herkunft bestehen

Stellen: app/Services/Backoffice/BackofficeDashboardService.php:753, :851–883; neuer Schreibpfad app/Services/BusinessPlan/SalesPointsVolume.php:352–403.

Die neue Herkunftsangabe bewirkt, dass ein regulärer Shop-Storno die abgerechneten Kundenabo-Gesamtpunkte korrekt mindert. Die Zusatzpunkte werden aber weiterhin aus unterschiedlichen Benutzer-/Bestellungs-Paaren und deren ursprünglich positiven Positionen ermittelt. Der Nettosaldo der zugehörigen Buchungen und deren Vorzeichen fehlen in dieser Berechnung.

Mit dem echten korrigierten Storno-Service nachgestellt:

  1. Kundenabo-Bestellung mit 100 Punkten, davon 20 Einmal-Zusatzpunkte. Das Original hat wie im Bestellpfad status_turnover=NULL.
  2. Vollstorno über cancelSalesPointsVolume(). Der neue Code erzeugt selbst status_turnover=2; die Gegenprobe setzt den Stornostatus nicht künstlich.
  3. Zweite, nicht stornierte Kundenabo-Bestellung mit 100 reinen wiederkehrenden Punkten.
  4. Monatsrangliste öffnen.
Ausgabe Ist Soll
Gesamt abgerechnet 100 100
Einmal-Zusatzpunkte 20 0
Wiederkehrende Punkte 80 100

Der ursprüngliche Audit beschrieb hier eine eigenständige Positions-/Vorzeichenursache. Die falsche Herkunft im alten Buchungspfad war ein zusätzliches Problem. Das Beheben der Herkunft beseitigt die Positionsursache nicht.

Empfehlung: Zuerst pro Bestellung die Nettowirkung der im Monat zählbaren Buchungen auf wiederkehrende und einmalige Positionen bestimmen, danach je Berater summieren. Die pauschale Begrenzung des Zusatzwertes auf die Monatsgesamtsumme reicht nicht. Regressionen für Vollstorno mit weiterer Bestellung, Teilstorno und optional erlaubtem Folgemonatsstorno ergänzen. A06 nicht als erledigt führen.

R02 — P2: Storno einer manuellen Gutschrift kann falsche Punktekategorien erzeugen

Stellen: app/Services/BusinessPlan/SalesPointsVolume.php:99–104,128–133,301–310; app/Services/Backoffice/SalesVolumePoints.php:36–38.

Die neue Methode behandelt Status 4 mit status_turnover=2 pauschal wie eine Shop-Bestellung. Der ursprüngliche Status-4-Zweig macht jedoch etwas anderes: Punkte gehen nach KP/TP, nur der Nettoumsatz wird dem Shop zugeordnet. Der Storno verschiebt dagegen sowohl Punkte als auch Nettoumsatz in die Shop-Kategorie.

Nachgestellt: Manuelle Gutschrift mit 100 Punkten, 100 Nettoumsatz, status=4, status_turnover=2, status_points=2 (nur KP); anschließend Vollstorno über den neuen Service.

Kennzahl nach Vollstorno Ist Soll bei vollständiger Gegenbuchung
KP 100 0
Shop-Punkte −100 0
Shop-Nettoumsatz 0 0
Statistik: Eigenpunkte 100 0
Statistik: Kundenpunkte −100 0
Punkte ohne Payline-Wirkung 100 0

Die Gesamtpunktesumme ist zwar null, aber die ursprünglichen KP bleiben stehen und im Shop entsteht ein negativer Punktesaldo, den die Ursprungsbuchung dort nie aufgebaut hat. Bei status_points=1 ist zusätzlich die TP-Zuordnung betroffen.

Reichweite: Der Sonderfall ist im neuen Service ausdrücklich vorgesehen und wurde auf Service-Ebene nachgewiesen. In der gelesenen Datenbank existieren 13 Status-4-Buchungen mit Shop-Nettoumsatz; ein bereits erfolgter Storno genau dieser Buchungen wurde nicht nachgewiesen. Die Erreichbarkeit für sämtliche administrativen Rechnungsarten wurde nicht per Oberfläche getestet. Dies ist daher keine Behauptung eines bereits entstandenen neuen Auszahlungsschadens.

Empfehlung: Herkunft der Punkte und Herkunft des Nettoumsatzes getrennt abbilden bzw. die Ursprungsbuchungsart bei der Gegenbuchung erhalten. Eine gemeinsame Zahl 1/2 reicht für den bestehenden Status-4-Sonderfall nicht. Tests für Status 4 × Umsatzzuordnung 1/2 × Punkteart KP bzw. KP+TP ergänzen; jede Gegenbuchung muss alle Kategorien des Originals aufheben.

R03 — P2: Die Monatstests laufen nur zufällig im passenden Systemmonat

Stellen: app/Services/BusinessPlan/SalesPointsVolume.php:320–323,368–370; tests/Feature/StornoClosedMonthTest.php:13–14,50–60; tests/Feature/StornoPointsCategoryTest.php:13.

Die neuen Tests frieren Carbon auf September 2026 ein. Der neue Monatsvergleich und die Stornobuchung verwenden dagegen PHP-date(). Diese Funktion folgt weiterhin der tatsächlichen Systemuhr. Die Fixtures und Erwartungen bleiben fest bei September 2026.

Nachgestellt: Reale Systemuhr September 2026; Anwendungs-/Carbon-Uhr auf Oktober 2026 gestellt; Ursprungsbuchung Oktober 2026. isStornoMonthOpen() ergibt false, obwohl Original und eingefrorener Anwendungsmonat identisch sind.

Die elf Tests bestehen heute. Nach einem tatsächlichen Monatswechsel werden mehrere trotz unveränderter fachlicher Implementation fehlschlagen bzw. andere Buchungsmonate verwenden. Das ist zunächst ein Reproduzierbarkeitsfehler der Tests; es ist kein Nachweis, dass die Produktionsanwendung heute grundsätzlich im falschen Monat bucht.

Empfehlung: Für Prüfung und Buchung denselben einmal ermittelten, testbaren Zeitpunkt verwenden, etwa now() oder eine injizierte Uhr. Monat/Jahr daraus gemeinsam ableiten. Monats- und Jahreswechsel unabhängig von der Systemuhr testen. Der Kommentar in StornoClosedMonthTest.php, die Testmigration verbiete NULL, ist durch die neue Migration ebenfalls überholt.

4. Was an den Korrekturen bestätigt ist

Normale Shop-/Eigenstornos: Ein Shop-Original mit status_turnover=NULL erhält im neuen Storno die Herkunft 2. Ein Vollstorno von 100 Punkten/100 Nettoumsatz ergibt KP 0, Shop-Punkte 0, Shop-Nettobasis 0 und Gesamtpunkte 0. Die ursprüngliche doppelte Berücksichtigung ist beseitigt. Eigenstornos und unveränderte Behandlung alter NULL-Stornos werden von den neuen vorhandenen Tests ebenfalls abgedeckt.

Zeitpunktregel: Der frühe Rücksprung verhindert bei ausgeschaltetem Schalter eine Punkte-/Umsatzzeile für ältere Kalendermonate. Der einzige gefundene aufrufende Rechnungspfad (InvoiceRepository::correctPointsForCancellation(), Zeile 511) dereferenziert das nun nullable Ergebnis nicht. Die Rechnungserstellung wird dadurch nicht durch einen neuen Nullzugriff unterbrochen. Der gesamte PDF-/Mail-Ablauf wurde nicht live ausgelöst.

Settings: Setting::getContentBySlug() liefert ohne Datensatz false; bei Typ bool liefert es einen echten Boolean. Der Controller setzt ein nicht übermitteltes Checkbox-Feld bei vorhandenem Settings-Eintrag auf false. Ein- und Ausschalten sind damit auch im Formularpfad nachvollziehbar, nicht nur bei direktem Schreiben des Settings.

Grenze der Abschlussregel: Implementiert ist „aktueller Kalendermonat“, keine Prüfung, ob tatsächlich schon eine Monatsauswertung/Gutschrift erstellt wurde. Bei der dokumentierten Auslegung ist das konsistent. Falls künftig ein expliziter Abschlussstatus maßgeblich werden soll, genügt die aktuelle Funktion nicht.

Incentive: Der frühe Rücksprung überspringt weiterhin auch IncentiveTracker::trackStorno(). Die Bewertung benennt den Fall einer noch laufenden Incentive-Aktion zu Recht als offen. Die neue Kategorienkorrektur löst diese Produktentscheidung nicht.

Migration: Die Entwicklungsdatenbank hat bereits tinyint unsigned, nullable, Default NULL. Die Migration ändert keine vorhandenen Werte. Der leere down()-Pfad ist offen kommentiert: Ein Rollback stellt die alte NOT-NULL-Definition nicht wieder her. „Kein Rückbau“ sollte auch in einer späteren Release-Notiz stehen; es wurde hier keine Migration gegen die Entwicklungsdatenbank ausgeführt.

5. Gegenprüfung der Daten- und Prioritätsaussagen

Lesende Datenbanknachweise

Die Abfragen liefen über eine separate PDO-Verbindung in START TRANSACTION READ ONLY, anschließend Rollback. Keine Laravel-Anwendung wurde dafür gebootet; keine Zugangsdaten, Namen, E-Mails oder einzelnen Kundendatensätze wurden ausgegeben. Die folgenden Zahlen sind Aggregate des konfigurierten Entwicklungsdatenbestands zum Prüfzeitpunkt.

Aussage der Bewertung Eigene Nachprüfung
20 Stornos, alle status_turnover=NULL Bestätigt. Kein vorhandener Storno mit Herkunft 2.
13 Stornos auf Kunden-/Shop-Bestellungen Bestätigt über shopping_orders.payment_for=6.
Statusverteilung mit 19.525 Eigen-, 24.151 Shop-, 76/13 manuellen und 647 Registrierungszeilen Bestätigt; zusätzlich 1 Status-0- und 8 Status-3-Zeilen wie dokumentiert.
7 VIP-Konten, 5 englischsprachige Nutzer Bestätigt bei nicht gelöschten Konten.
901 Nutzer insgesamt Präzisierung: 901 nicht gelöschte Konten, 1.634 physische Benutzerzeilen einschließlich Soft-Deletes. Kein abweichendes Wachstumsszenario daraus abgeleitet.
44 Kündigungen, 10 Deaktivierungen, 6 Pausen, keine Reaktivierungshistorie Bestätigt. Die 10 Abos mit Deaktivierungshistorie sind derzeit inaktiv.
7 gleichmonatige Shop-Stornos Bestätigt durch Verknüpfung Stornozeile → Originalrechnung → Ursprungsbuchung. Sie verteilen sich auf 6 Benutzer-/Monatskombinationen; betroffener Nettobetrag insgesamt −596,05 €.
178,82 € Shop-Provisionswirkung Als rechnerische Differenz der Shop-Basis bestätigt, siehe Einschränkung unten.

Für die sechs Benutzer-/Monatskombinationen wurden die gespeicherten Monatsabrechnungen verknüpft. Die dortigen Shop-Margen betragen jeweils 30 %. Die Differenz

SUM(ROUND(Shopbasis × Marge / 100, 2) − ROUND((Shopbasis + Storno-Netto) × Marge / 100, 2))

ergibt 178,82 €. Einfaches Runden jedes einzelnen Stornoanteils und anschließendes Summieren kann hiervon um einen Cent abweichen; die Monatsberechnung ist daher ausdrücklich angegeben.

Nicht bestätigt ist die Formulierung „178,82 € zu viel ausgezahlt“. Es wurden keine Auszahlungstransaktionen abgestimmt. Zudem stimmt bei einer der sechs gespeicherten Monatsabrechnungen commission_shop_sales nicht exakt mit der bloßen Formel aus Shopbasis und Marge überein. Die Ursache dieser Abweichung ist nicht Gegenstand der Nachprüfung. Eine sichere Auszahlungsaussage braucht den Abgleich der tatsächlich festgesetzten und ausgezahlten Beträge einschließlich vorhandener Korrekturen.

Einordnung der Bewertung

  • A15, Bestandsdaten: Die Konkretisierung ist richtig: Die alte Doppelberücksichtigung war in den gelesenen Daten nicht aktiv, weil dort Herkunft 2 bei Stornos fehlte. Der ursprüngliche Audit hatte eine bedingte Codewirkung beschrieben und die fehlende Produktionsprüfung ausdrücklich benannt. Das darf nicht nachträglich als schon belegter Schaden an Gesamtpunkten gelesen werden. Die Fixture ist ein gültiger Test des überlappenden Zweigs; sie war kein Beleg dafür, dass dieser Datenzustand damals im Bestand vorkam. Durch den korrigierten Writer wird Herkunft 2 für neue Shop-Stornos jetzt tatsächlich erzeugt.
  • Provisionswirkung: Die Aussagen in Abschnitten 3, 5 und 6 der Bewertung, die Auswirkung auf Provisionen sei null bzw. nur Darstellung betroffen, sind durch den eigenen Nachtrag 9 überholt. Gesamtpunkte allein bestimmen die Shop-Provision nicht; BusinessUserItemOptimized.php:233–236 verwendet die Shop-Nettobasis. Bei Weiterverwendung des Dokuments sollte der gültige Endstand vorne klar stehen.
  • A16 und A22: Die gelesenen Bestandsgrößen rechtfertigen eine geringere kurzfristige Dringlichkeit. Keine heute aktive der zehn Deaktivierungen ist aber kein Beweis für vollständige historische Richtigkeit sämtlicher Zwischenzustände, Teamwechsel und anderer Schreibpfade. Beim Speicherbefund wurde ursprünglich ein Skalierungsrisiko beschrieben, kein bereits eingetretener Absturz.
  • A14: Die Begrenzung auf Englisch stimmt. Schon der ursprüngliche Befund demonstrierte englische Formate; die Empfehlung, alle vier Sprachen zu testen, behauptet keinen Fehler in allen vier Sprachen. Fünf betroffene Nutzer senken die Reichweite, nicht die Fehlerhaftigkeit ihrer Sortierung.
  • A05: „Nur ein Tooltip genügt“ ist keine vollständig abgesicherte Schlussfolgerung. Das Juli-Dokument empfiehlt tatsächlich customerAboCounts(); das neuere September-PDF verlangt aber ausdrücklich nur bereits abgerechnete Abos. Es besteht ein Widerspruch zwischen ursprünglichem Umsetzungsdetail und erneuertem Kundenwunsch. Aktuellen Bestand klar beschriften ist sinnvoll; ob damit die geforderte Anzahl abgegolten ist, bleibt fachlich zu entscheiden.
  • A01: P1/P2 ist eine Prioritätsentscheidung. Dass nur das eigene Abo betroffen ist, war bereits im ursprünglichen Audit abgegrenzt. Ausfall künftiger Abo-Lieferungen/-Zahlungen durch Umgehung einer Mindestlaufzeit ist trotzdem eine relevante Geschäftsregelverletzung. Ohne Untersuchung von Änderungsereignissen ist „kein aktueller Schaden“ hier nicht belegt.
  • A18: Teamdaten sind nicht ausschließlich vom exportierenden Benutzer kontrollierter Input. Namen, Kontaktdaten und weitere Textfelder können von anderen Personen stammen. Die Teamgrenze beseitigt die Formel-Interpretation im Tabellenprogramm nicht. Eine niedrigere organisatorische Priorität kann vereinbart werden; die Begründung „kein fremder Input“ trägt technisch nicht.
  • A20: Öffentliche Bilder können eine bewusst akzeptierte Produktentscheidung sein. Ein mit uniqid() präfigierter Dateiname ist aber keine Team-Zugriffskontrolle. „Teamintern“ muss entweder nur die Listenauswahl oder auch den Bildabruf meinen; das sollte ausdrücklich festgelegt sein.
  • A07: Die noch offene allgemeine Vereinheitlichung K11 blockiert nicht zwangsläufig die Konsistenz zweier bereits vorhandener Payline-Prognosen. Gemeinsame Filter innerhalb derselben Prognosesicht können gesondert korrigiert werden.

6. Tests und Aussagegrenzen

Alle schreibenden Testaktivitäten liefen in /tmp/mivita-statistics-recheck, einer eigenen Kopie mit separaten vendor-Dateien, isolierter Testkonfiguration und SQLite :memory:. Die neuen/angepassten Dateien wurden aus dem Arbeitsstand übernommen. Die Bestandsdatenbank wurde ausschließlich für die obigen lesenden Abfragen verwendet.

Lauf Ergebnis
Bestehende Statistik-Kernmodule plus beide neuen Storno-Testdateien 120 bestanden, 711 Assertions
Zusätzliche Gegenproben R01–R04 4 bestanden, 12 Assertions; R01–R03 bestätigen ausdrücklich das beanstandete Ist-Verhalten, R04 bestätigt die gelungene reguläre Korrektur
Erweiterter Lauf Incentive/Sponsor-Reparatur/Storno/DATEV 98 bestanden, 3 fehlgeschlagen, 275 Assertions
Derselbe erweiterte Filter auf isoliertem Stand vor den jetzigen Korrekturen 87 bestanden, dieselben 3 fehlgeschlagen, 242 Assertions

Die drei Fehler betreffen IncentiveTeaserPageTest (Galerie) und zwei Fälle in RegenerateCancellationInvoicePdfCommandTest. Sie treten im gleichen isolierten Umfeld bereits ohne die jetzigen Änderungen auf. Damit sind sie keine nachgewiesenen Regressionen dieser Korrektur. Der Filter Storno erfasst auch Testbeschreibungen und schließt deshalb PDF-Regenerierungstests ein. Die in der Bewertung genannte Zusammenstellung „245 bestanden, 1 Fehler“ wurde ohne deren vollständigen Testbefehl nicht als identischer Lauf behauptet.

Die PHPUnit-Warnung zur veralteten XML-Konfiguration besteht weiterhin. Es wurden kein umfassender Browser-/Lasttest, keine echte Rechnungserstellung mit Versand und kein Zahlungsabgleich ausgeführt.

Die initiale Einrichtung der zusätzlichen Fixtures erforderte das Entfernen doppelter Hilfsfunktionsnamen und das explizite Setzen von status_points=1, bevor ein direkt erzeugtes Eloquent-Objekt an den Writer übergeben wurde. Diese Fixturefehler sind keine Anwendungsbefunde. Die oben genannten finalen Ergebnisse stammen aus dem bereinigten Lauf.

7. Ausgeführte Testbefehle

Im isolierten Nachprüfungsverzeichnis:

php artisan test --compact --filter='StornoClosedMonthTest|StornoPointsCategoryTest|BackofficeStatistics|BackofficeDashboardService|PersonalStatisticsService|HomeMonthlyStats|PaylineForecastService|LiveCommissionService|TeamAboOverview|TeamFirstlinesPage|TopResultsService|AnnualFeeDueService|CustomerAboListView|PointsAboHighlight|TeamStructureAboBadges|TeamCustomerAbosRedirect'
php artisan test --compact --filter=StatisticsRecheckProbeTest
php artisan test --compact --filter='Incentive|SponsorBuyingPointsRepairTest|Storno|DatevExportCancellationTest'

Der letzte Befehl wurde zusätzlich in /tmp/mivita-statistics-audit mit dem Stand vor den jetzigen Korrekturen ausgeführt, um die drei Fehlschläge abzugrenzen. Keine dieser Prüfungen änderte die Testdateien im Repository.

8. Integrität und dauerhafte Nachweise

Vor Beginn der Nachprüfung wurde eine neue SHA-256-Prüfbasis für 10.391 vorhandene Dateien erstellt. Der Abschlussvergleich erfolgt gegen diese Basis einschließlich des Bewertungsdokuments und aller inzwischen erfolgten Implementierungskorrekturen. Neu angelegt wurde nur dieses Nachprüfungsdokument. Die vorangegangenen Auditberichte und die Bewertung wurden nicht redigiert.

Die nachfolgenden Anhänge enthalten die ausgeführten zusätzlichen Gegenproben und anonymen Aggregatergebnisse dauerhaft im Auditordner. Die temporären Dateien werden zum Verständnis des Ergebnisses nicht benötigt.

Anhang A: Zusätzliche isolierte Gegenproben

Die Erwartungen von R01–R03 beschreiben den beobachteten Fehlerzustand. Bei einer späteren Behebung müssen daraus Soll-Regressionen werden; der grüne Gegenproben-Lauf bedeutet keine Freigabe dieser drei Verhaltensweisen. R04 prüft dagegen bereits das gewünschte Ergebnis. Die Beispiele wurden am 09.09.2026 ausgeführt; R03 wechselt die Carbon-Uhr bewusst relativ zum realen Systemmonat.

<?php
use App\Models\{Setting, Product, ShoppingOrder, ShoppingOrderItem, UserAbo, UserAboItem, UserAccount, UserBusiness, UserSalesVolume};
use App\Services\Backoffice\{BackofficeDashboardService, SalesVolumePoints};
use App\Services\BusinessPlan\SalesPointsVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\{Cache, DB};
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () { Cache::flush(); Carbon::setTestNow('2026-09-09 12:00:00'); });
afterEach(fn () => Carbon::setTestNow());
function recheckRecord(string $case, array $data): void {
    file_put_contents('/tmp/statistics-recheck-results.jsonl', json_encode(['case'=>$case]+$data)."\n", FILE_APPEND);
}
function recheckUser(?int $sponsor = null): User {
    return User::forceCreate(['email'=>'audit-'.uniqid().'@example.test','password'=>'test','lang'=>'de','admin'=>0,'m_level'=>1,'m_sponsor'=>$sponsor,'active_date'=>'2026-01-01','payment_account'=>'2027-01-01']);
}
function recheckVolume(User $u, float $points, int $status = 1, ?ShoppingOrder $order = null, int $month = 9): UserSalesVolume {
    return UserSalesVolume::forceCreate(['user_id'=>$u->id,'shopping_order_id'=>$order?->id,'month'=>$month,'year'=>2026,'date'=>"2026-{$month}-01",'status'=>$status,'status_points'=>1,'status_turnover'=>null,'points'=>$points,'total_net'=>$points]);
}
function recheckOrder(User $u): ShoppingOrder {
    return ShoppingOrder::create(['shopping_user_id'=>0,'auth_user_id'=>$u->id,'member_id'=>$u->id,'country_id'=>1,'language'=>'de','user_shop_id'=>1,'payment_for'=>6,'total'=>100,'subtotal'=>100,'points'=>100,'paid'=>true,'shipped'=>0,'is_abo'=>true,'mode'=>'test']);
}
it('R01 addon split remains wrong after cancellation through the corrected writer', function () {
    $u=recheckUser(); $a=recheckUser($u->id); $o=recheckOrder($a);
    ShoppingOrderItem::forceCreate(['shopping_order_id'=>$o->id,'product_id'=>0,'qty'=>1,'price'=>20,'points'=>20,'is_abo_addon'=>true]);
    $v=recheckVolume($a,100,2,$o);
    $cancel=SalesPointsVolume::cancelSalesPointsVolume($v,9991);
    recheckVolume($a,100,2,recheckOrder($a));
    $r=app(BackofficeDashboardService::class)->overview($u,9,2026)['top_customer_abos']['by_points'][0];
    recheckRecord('R01',['original_turnover'=>$v->status_turnover,'cancel_turnover'=>$cancel->status_turnover,'actual'=>$r['points_actual'],'addon'=>$r['points_addon'],'recurring'=>$r['points_recurring']]);
    expect($cancel->status_turnover)->toBe(2)->and($r['points_actual'])->toBe(100.0)->and($r['points_addon'])->toBe(20.0)->and($r['points_recurring'])->toBe(80.0);
});
it('R02 cancellation of manual shop-net credit does not reverse its original point categories', function () {
    $u=recheckUser(); $v=recheckVolume($u,100,4); $v->update(['status_turnover'=>2,'status_points'=>2]);
    SalesPointsVolume::reCalculateSalesPointsVolume($u->id,9,2026);
    $before=$v->fresh();
    $cancel=SalesPointsVolume::cancelSalesPointsVolume($v,9992)->fresh();
    $aggregate=UserSalesVolume::where('user_id',$u->id)->selectRaw(SalesVolumePoints::ownPoints().' as own')->selectRaw(SalesVolumePoints::externalPoints().' as shop')->selectRaw(SalesVolumePoints::nonPaylinePoints().' as nonpayline')->first();
    recheckRecord('R02',['before_KP'=>$before->month_KP_points,'before_shop_points'=>$before->month_shop_points,'after_KP'=>$cancel->month_KP_points,'after_shop_points'=>$cancel->month_shop_points,'after_shop_net'=>$cancel->month_shop_total_net,'aggregate_own'=>(float)$aggregate->own,'aggregate_shop'=>(float)$aggregate->shop,'nonpayline'=>(float)$aggregate->nonpayline]);
    expect($cancel->month_KP_points)->toBe(100.0)->and($cancel->month_shop_points)->toBe(-100.0)->and((float)$aggregate->nonpayline)->toBe(100.0);
});
it('R03 month check ignores the frozen application clock', function () {
    $month=(int)date('m'); $year=(int)date('Y');
    Carbon::setTestNow(Carbon::create($year,$month,15)->addMonth());
    $u=recheckUser();$v=recheckVolume($u,100,2);$v->update(['month'=>now()->month,'year'=>now()->year]);
    $open=SalesPointsVolume::isStornoMonthOpen($v);
    recheckRecord('R03',['system_month'=>date('Y-m'),'app_month'=>now()->format('Y-m'),'original_month'=>$v->year.'-'.str_pad($v->month,2,'0',STR_PAD_LEFT),'considered_open'=>$open]);
    expect($open)->toBeFalse();
});
it('R04 ordinary customer cancellation now reverses totals and shop basis exactly once', function () {
    $u=recheckUser(); $v=recheckVolume($u,100,2); $c=SalesPointsVolume::cancelSalesPointsVolume($v,9993)->fresh();
    $total=SalesVolumePoints::totalPointsByUser([$u->id],9,2026)->get($u->id);
    recheckRecord('R04',['turnover'=>$c->status_turnover,'KP'=>$c->month_KP_points,'shop_points'=>$c->month_shop_points,'shop_net'=>$c->month_shop_total_net,'total'=>$total]);
    expect($c->status_turnover)->toBe(2)->and($c->month_shop_points)->toBe(0.0)->and($c->month_shop_total_net)->toBe(0.0)->and($total)->toBe(0.0);
});

Aufgezeichnete Ergebnisse:

{"case":"R01","original_turnover":null,"cancel_turnover":2,"actual":100,"addon":20,"recurring":80}
{"case":"R02","before_KP":100,"before_shop_points":0,"after_KP":100,"after_shop_points":-100,"after_shop_net":0,"aggregate_own":100,"aggregate_shop":-100,"nonpayline":100}
{"case":"R03","system_month":"2026-09","app_month":"2026-10","original_month":"2026-10","considered_open":false}
{"case":"R04","turnover":2,"KP":0,"shop_points":0,"shop_net":0,"total":0}

Anhang B: Anonyme Datenbankaggregate

Erste Abfragegruppe:

{
    "status_counts": [
        {
            "status": 0,
            "status_turnover": null,
            "n": 1
        },
        {
            "status": 1,
            "status_turnover": null,
            "n": 19525
        },
        {
            "status": 2,
            "status_turnover": null,
            "n": 24151
        },
        {
            "status": 3,
            "status_turnover": null,
            "n": 8
        },
        {
            "status": 4,
            "status_turnover": 1,
            "n": 76
        },
        {
            "status": 4,
            "status_turnover": 2,
            "n": 13
        },
        {
            "status": 5,
            "status_turnover": null,
            "n": 647
        },
        {
            "status": 6,
            "status_turnover": null,
            "n": 20
        }
    ],
    "users": [
        {
            "total": 1634,
            "vip": "7",
            "english": "5"
        }
    ],
    "history": [
        {
            "action": "paused",
            "n": 6
        },
        {
            "action": "cancelled",
            "n": 44
        },
        {
            "action": "deactivated",
            "n": 10
        }
    ],
    "deactivated_now": [
        {
            "deactivated_abos": 10,
            "active_history_rows": "0"
        }
    ],
    "storno_orders": [
        {
            "payment_for": 1,
            "n": 1
        },
        {
            "payment_for": 6,
            "n": 13
        },
        {
            "payment_for": 3,
            "n": 6
        }
    ],
    "same_month_shop": [
        {
            "n": 7,
            "cancelled_net": "-596.05",
            "hypothetical_margin_30": "178.81"
        }
    ],
    "turnover_schema": [
        {
            "COLUMN_TYPE": "tinyint unsigned",
            "IS_NULLABLE": "YES",
            "COLUMN_DEFAULT": null
        }
    ]
}

Ergänzende Abfragegruppe zur Rundung und gespeicherten Shop-Basis:

{
    "users_scope": [
        {
            "physical_rows": 1634,
            "not_deleted": "901"
        }
    ],
    "rounding": [
        {
            "affected_user_months": 6,
            "net": "-596.05",
            "round_after_sum": "178.82",
            "sum_after_round": "178.81"
        }
    ],
    "stored_business": [
        {
            "matches": 6,
            "min_margin": "30.00",
            "max_margin": "30.00",
            "commission_basis_difference": "178.82",
            "existing_commission_mismatches": "1"
        }
    ]
}

Wesentliche Abfragen zur Reproduktion; ausschließlich lesend in einer entsprechenden Read-only-Transaktion ausführen. Keine Identitäten ausgeben:

SELECT status, status_turnover, COUNT(*) AS n
FROM user_sales_volumes
GROUP BY status, status_turnover
ORDER BY status, status_turnover;

SELECT COUNT(*) AS physical_rows,
       SUM(deleted_at IS NULL) AS not_deleted,
       SUM(admin >= 1 AND admin < 4 AND deleted_at IS NULL) AS vip,
       SUM(lang = 'en' AND deleted_at IS NULL) AS english
FROM users;

SELECT action, COUNT(*) AS n
FROM user_abo_status_histories
GROUP BY action;

SELECT COUNT(DISTINCT a.id) AS deactivated_abos,
       SUM(a.active = 1) AS active_history_rows
FROM user_abo_status_histories h
JOIN user_abos a ON a.id = h.user_abo_id
WHERE h.action = 'deactivated';

SELECT o.payment_for, COUNT(*) AS n
FROM user_sales_volumes v
JOIN shopping_orders o ON o.id = v.shopping_order_id
WHERE v.status = 6
GROUP BY o.payment_for;

SELECT COUNT(*) AS matches,
       MIN(b.margin_shop) AS min_margin,
       MAX(b.margin_shop) AS max_margin,
       SUM(ROUND(b.sales_volume_total_shop * b.margin_shop / 100, 2)
         - ROUND((b.sales_volume_total_shop + q.net) * b.margin_shop / 100, 2))
           AS commission_basis_difference,
       SUM(ABS(b.commission_shop_sales
         - ROUND(b.sales_volume_total_shop * b.margin_shop / 100, 2)) > 0.005)
           AS existing_commission_mismatches
FROM (
    SELECT c.user_id, c.month, c.year, SUM(c.total_net) AS net
    FROM user_sales_volumes c
    JOIN user_invoices i ON i.cancellation_id = c.user_invoice_id
    JOIN user_sales_volumes v ON v.user_invoice_id = i.id
    WHERE c.status = 6 AND v.status = 2
      AND c.month = v.month AND c.year = v.year
    GROUP BY c.user_id, c.month, c.year
) q
JOIN user_businesses b
  ON b.user_id = q.user_id AND b.month = q.month AND b.year = q.year;

Anhang C: Geprüfte Korrekturfassungen

SHA-256 der zentralen geänderten Dateien:

Datei SHA-256
app/Services/BusinessPlan/SalesPointsVolume.php 4d668b5f91885926b40c8f27b9f501f02837d6775f835d851cef33cb58b5895d
app/Services/Backoffice/SalesVolumePoints.php 5dcd93954b37f6562ddf07df0895f7c36b02b8648745b79aac2bb78aca0a8fd5
resources/views/admin/settings/index.blade.php 3b39e30d70cf259e85a45ec0090b08d85ee0e554ec25817d750219d105d9e5e8
database/migrations/2026_09_09_140000_make_status_turnover_nullable_on_user_sales_volumes.php 9effa46911e9edc2b67ad190e45018d9bcff755288a8a5148d1d47170e24cc2f
tests/Feature/StornoClosedMonthTest.php e364eac7144875d847c90555d12c12e6994ae13727bf1100c941c2742b07d3e3
tests/Feature/StornoPointsCategoryTest.php d92a65fb09befad2b0972f040b6925cbc5edf4043ea025a92f3c1aebaa3747b8

Abschluss des Dateivergleichs: 10.391 vorhandene Dateien geprüft, 0 verändert oder entfernt; ausschließlich dieses Nachprüfungsdokument neu angelegt. Anwendungscode, bestehende Tests, Konfiguration und vorhandene Dokumente blieben unverändert.