From 92344f70806cbd18e6c91057c78759fb783615bf Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 10 Sep 2026 11:19:06 +0000 Subject: [PATCH] Statistik: abgeschlossene Monate werden vollstaendig eingefroren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Am ersten des Folgemonats wird der Monat abgerechnet, die Provision berechnet und Gutschriften werden ausgestellt. Deswegen fliessen auch Stornos nicht mehr rueckwirkend ein. Genau dieser Stand muss eingefroren werden — und zwar einschliesslich Teamstruktur und Detaillisten, nicht nur der Kennzahlen. Bisher hat `overlayCurrentAboStock()` beim Lesen eines gespeicherten Monats die gesamte Kundenabo-Topliste live neu gerechnet, also auch abgerechnete Monatswerte. Und die Detaillisten hinter den Kacheln lasen Monatsbuchungen und Teamzuordnung grundsaetzlich neu — nach einem Sponsorwechsel oder einer Loeschung konnte derselbe historische Klick andere Ergebnisse liefern als die Kachel darueber. Das Overlay entfaellt; der Snapshot wird unveraendert zurueckgegeben. Die Detaillisten lesen die eingefrorene Mitgliedermenge aus dem Snapshot (`user_ids` lag dort bereits, wurde nur nie benutzt) und die eingefrorenen Abo-Ids aus dem neuen Feld `abo_ids`. Payload-Version 2. Grenze, die bleibt: Ein Rebuild vergangener Monate friert die Struktur des AUSFUEHRUNGSTAGS ein, nicht die des damaligen Monatsendes — frueheres Sponsoring wird nirgends historisiert. Der `--force`-Lauf ist deshalb nur richtig, wenn man bewusst einen einheitlichen Stand herstellt. Das ist hier der Fall (Schemawechsel des Payloads), fuer den laufenden Betrieb genuegt der naechtliche Lauf ohne `--force`. Loest die August-Entscheidung F7 ("Abo-Bestaende tagesaktuell") ab. Zwei Tests hielten die alte Regel fest und beschreiben jetzt die neue. Mit drin, weil dieselben Dateien betroffen sind: - Negative Monatssalden verschwanden aus dem Drilldown (`HAVING > 0`), obwohl ihre Betraege in der Uebersicht steckten. Nur die 1000-Punkte-Liste behaelt ihren Schwellenfilter (A03). - Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet statt voll abgezogen (A06). - Unformatierte Sortierwerte fuer die Detailliste (A14, siehe Folgecommit). Audit-Befunde A03, A04, A06, A12. Co-Authored-By: Claude Opus 5 (1M context) --- .../BackofficeStoreStatisticsSnapshots.php | 13 + .../Backoffice/BackofficeDashboardService.php | 338 ++++++++++++------ .../Backoffice/BackofficeDrilldownService.php | 182 ++++++++-- .../backoffice/statistics/index.blade.php | 57 ++- .../Feature/BackofficeSnapshotFreezeTest.php | 162 +++++++++ .../Feature/BackofficeStatisticsListsTest.php | 117 +++++- tests/Feature/CustomerAboAddonSplitTest.php | 131 +++++++ tests/Feature/StatisticsConsistencyTest.php | 243 +++++++++++++ .../BackofficeDashboardServiceTest.php | 16 + 9 files changed, 1101 insertions(+), 158 deletions(-) create mode 100644 tests/Feature/BackofficeSnapshotFreezeTest.php create mode 100644 tests/Feature/CustomerAboAddonSplitTest.php create mode 100644 tests/Feature/StatisticsConsistencyTest.php diff --git a/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php b/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php index 4110d18b..713d8181 100644 --- a/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php +++ b/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php @@ -28,6 +28,19 @@ class BackofficeStoreStatisticsSnapshots extends Command */ protected $description = 'Speichert Backoffice-Statistik-Snapshots fuer abgeschlossene Monate'; + /** + * Der Snapshot IST der abgeschlossene Monat — siehe + * {@see BackofficeDashboardService} zur fachlichen Begruendung. Er wird am + * ersten Tag des Folgemonats gebaut, wenn Abrechnung und Gutschriften stehen. + * + * Wichtig bei `--force`: Ein neu gebauter Snapshot friert die Struktur des + * AUSFUEHRUNGSTAGS ein, nicht die des damaligen Monatsendes — frueheres + * Sponsoring wird nirgends historisiert. Ein Rebuild vergangener Monate ist + * deshalb nur dann richtig, wenn man bewusst einen einheitlichen Stand + * herstellen will (z. B. bei einem Schemawechsel des Payloads). Fuer den + * laufenden Betrieb genuegt der naechtliche Lauf ohne `--force`. + */ + /** * Execute the console command. */ diff --git a/app/Services/Backoffice/BackofficeDashboardService.php b/app/Services/Backoffice/BackofficeDashboardService.php index 65dc4d8b..545b91b7 100644 --- a/app/Services/Backoffice/BackofficeDashboardService.php +++ b/app/Services/Backoffice/BackofficeDashboardService.php @@ -3,6 +3,7 @@ namespace App\Services\Backoffice; use App\Models\BackofficeStatisticsSnapshot; +use App\Models\ShoppingOrderItem; use App\Models\UserAbo; use App\Models\UserLevel; use App\Models\UserSalesVolume; @@ -12,8 +13,43 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +/** + * Monatsuebersicht der Backoffice-Statistik. + * + * ## Warum abgeschlossene Monate eingefroren sind (Kundenentscheidung 2026-09-10) + * + * Am ersten Tag des Folgemonats laeuft die Monatsabrechnung: Provisionen werden + * berechnet, Gutschriften ausgestellt, der Monat ist damit kaufmaennisch + * abgeschlossen. Aus demselben Grund fliessen auch keine Stornos mehr rueckwirkend + * in einen abgeschlossenen Monat ({@see SalesPointsVolume::isStornoMonthOpen()}). + * + * Die Statistik muss dieser Logik folgen, denn die Team-Struktur ist beweglich: + * Teampartner kommen dazu, wechseln den Sponsor oder fallen weg. Wuerde ein + * vergangener Monat weiter aus heutigen Daten gerechnet, saehe eine Beraterin dort + * spaeter andere Zahlen als in der Abrechnung, die sie fuer diesen Monat erhalten + * hat — und die Ursache waere von aussen nicht erkennbar. + * + * Eingefroren wird deshalb der **gesamte** Monatsstand, nicht nur die Summen: + * Punkte, Umsaetze, Abo-Bestaende, die Mitglieder je Linie (`user_ids`) und die + * Abos je Linie (`abo_ids`). Aus diesen beiden ID-Listen baut + * {@see BackofficeDrilldownService} die Detaillisten, sodass jede verlinkte Liste + * zu der Zahl passt, aus der sie aufgerufen wurde. + * + * Bewusst NICHT eingefroren werden Stammdaten wie Name oder Karrierestufe: sie + * werden zur Anzeigezeit gelesen. Eine Umbenennung soll rueckwirkend sichtbar sein, + * eine Strukturaenderung nicht. + */ class BackofficeDashboardService { + /** + * Payload-Schema der gespeicherten Monats-Snapshots. + * + * 2 — Linienzeilen tragen zusaetzlich `abo_ids`, damit die Detaillisten eines + * abgeschlossenen Monats aus demselben eingefrorenen Stand kommen wie die + * Zahl darueber. + */ + public const PAYLOAD_VERSION = 2; + private const MAX_DEPTH_SAFETY_LIMIT = 100; private const LEADER_LEVEL_NAME = 'Vertriebsleiter'; @@ -62,8 +98,12 @@ class BackofficeDashboardService ->first(); if ($snapshot) { + // Abgeschlossener Monat: der gespeicherte Stand wird unveraendert + // ausgeliefert — siehe Klassenkommentar. Frueher wurden hier die + // Abo-Bestaende live nachgerechnet; das hat den eingefrorenen Monat + // mit heutigen Zahlen vermischt. return $this->withMeta( - $this->overlayCurrentAboStock($user, $snapshot->payload), + $snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i') ); @@ -115,6 +155,7 @@ class BackofficeDashboardService private function buildOverview(User $user, int $month, int $year): array { $lineBuckets = $this->lineBuckets($user->id); + $recruiter = $this->recruiterRanking($user, $lineBuckets, $month, $year); $lines = []; $totals = $this->emptyLine(0, []); @@ -133,6 +174,11 @@ class BackofficeDashboardService return [ 'month' => $month, 'year' => $year, + // Schema-Version des Payloads: ab 2 enthalten die Linienzeilen `abo_ids`, + // aus denen die Detaillisten eines abgeschlossenen Monats gebaut werden. + // Aeltere Snapshots bleiben lesbar, ihre Detaillisten fallen auf die + // Monatsend-Rueckrechnung zurueck ({@see BackofficeDrilldownService}). + 'payload_version' => self::PAYLOAD_VERSION, 'metric_labels' => $this->metricLabels(), 'lines' => $lines, 'totals' => $totals, @@ -140,88 +186,13 @@ class BackofficeDashboardService 'new_customer_abos' => $this->newCustomerAboRanking($user, $lineBuckets, $month, $year), 'leader_abos' => $this->leaderAboOverview($lineBuckets), 'top_customer_abos' => $this->topCustomerAboRanking($user, $lineBuckets, $month, $year), - 'recruiter_ranking' => $this->recruiterRanking($user, $lineBuckets, $month, $year), + 'recruiter_ranking' => $recruiter['rows'], + // Eigener Payload-Schlüssel statt geänderter Listenform: ältere + // Snapshots kennen nur recruiter_ranking und bleiben lesbar. + 'recruiter_totals' => $recruiter['totals'], ]; } - /** - * Überlagert einen Snapshot-Payload mit den tagesaktuellen Abo-Bestandszahlen - * (Kundenentscheid F7): Teamabos/Teamkundenabos je Linie + Summe sowie die - * Führungskräfte- und Top-10-Listen sind Bestands-Kennzahlen ohne Monatsbezug - * und werden live nachgerechnet. Monatsbezogene Werte (Punkte, Umsätze, - * Neupartner, "+N neu", neue Kundenabos) bleiben eingefroren. - * - * @param array $payload - * @return array - */ - private function overlayCurrentAboStock(User $user, array $payload): array - { - $lineBuckets = $this->lineBuckets($user->id); - - $totalPartnerAbos = 0; - $totalCustomerAbos = 0; - - $snapshotLines = collect($payload['lines'] ?? []) - ->map(fn ($row, $key) => (int) ($row['line'] ?? $key)) - ->values() - ->all(); - - foreach ($payload['lines'] ?? [] as $key => $row) { - $line = (int) ($row['line'] ?? $key); - $userIds = ($lineBuckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all(); - - $partnerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(); - $customerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(); - - $payload['lines'][$key]['team_partner_abos'] = $partnerAbos; - $payload['lines'][$key]['team_customer_abos'] = $customerAbos; - - $totalPartnerAbos += $partnerAbos; - $totalCustomerAbos += $customerAbos; - } - - // Linien, die erst nach dem Snapshot entstanden sind (Team ist heute tiefer), - // tragen ebenfalls aktuellen Abo-Bestand: als neue Zeile anhängen, Monatswerte 0. - foreach ($lineBuckets as $line => $users) { - if (in_array((int) $line, $snapshotLines, true)) { - continue; - } - - $userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all(); - if ($userIds === []) { - continue; - } - - $partnerAbos = $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(); - $customerAbos = $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(); - - if ($partnerAbos === 0 && $customerAbos === 0) { - continue; - } - - $row = $this->emptyLine((int) $line, $userIds); - $row['team_partner_abos'] = $partnerAbos; - $row['team_customer_abos'] = $customerAbos; - $payload['lines'][(int) $line] = $row; - - $totalPartnerAbos += $partnerAbos; - $totalCustomerAbos += $customerAbos; - } - - $payload['lines'] = collect($payload['lines'] ?? [])->sortBy('line')->all(); - - if (isset($payload['totals'])) { - $payload['totals']['team_partner_abos'] = $totalPartnerAbos; - $payload['totals']['team_customer_abos'] = $totalCustomerAbos; - } - - $payload['leader_abos'] = $this->leaderAboOverview($lineBuckets); - $payload['top_customer_abos'] = $this->topCustomerAboRanking($user, $lineBuckets, (int) ($payload['month'] ?? now()->month), (int) ($payload['year'] ?? now()->year)); - $payload['abo_stock_live'] = true; - - return $payload; - } - /** * @param array $overview * @return array @@ -343,16 +314,32 @@ class BackofficeDashboardService $salesSummary = $this->salesSummary($userIds, $month, $year); + // Nicht nur zaehlen, sondern die Abo-IDs mitschreiben: der Snapshot ist der + // eingefrorene Monatsstand, und die Detailliste hinter der Zahl muss genau + // diese Abos zeigen — auch wenn eines davon spaeter gekuendigt oder geloescht + // wird. Ohne die IDs liesse sich die Liste nur aus dem heutigen Bestand + // rekonstruieren und wuerde von ihrer eigenen Kachel abweichen. + $partnerAboIds = $this->aboIds($userIds, 'user_id', 'me'); + $customerAboIds = $this->aboIds($userIds, 'member_id', 'ot'); + $partnerAboIdsNew = $this->aboIds($userIds, 'user_id', 'me', $month, $year); + $customerAboIdsNew = $this->aboIds($userIds, 'member_id', 'ot', $month, $year); + return [ 'line' => $line, 'label' => 'Linie '.$line, 'user_ids' => $userIds, 'consultants' => $this->activeConsultants($users), 'new_partners' => $this->newPartners($users, $month, $year), - 'team_partner_abos' => $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(), - 'team_partner_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year), - 'team_customer_abos' => $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(), - 'team_customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year), + 'team_partner_abos' => count($partnerAboIds), + 'team_partner_abos_new' => count($partnerAboIdsNew), + 'team_customer_abos' => count($customerAboIds), + 'team_customer_abos_new' => count($customerAboIdsNew), + 'abo_ids' => [ + 'team_partner_abos' => $partnerAboIds, + 'team_partner_abos_new' => $partnerAboIdsNew, + 'team_customer_abos' => $customerAboIds, + 'team_customer_abos_new' => $customerAboIdsNew, + ], 'own_points' => $salesSummary['own_points'], 'external_points' => $salesSummary['external_points'], 'customer_abo_points' => $salesSummary['customer_abo_points'], @@ -436,19 +423,39 @@ class BackofficeDashboardService ->whereNotIn('status', [4, 5, 6]); } + /** + * IDs der aktiven Abos einer Mitgliedermenge. Mit Monat/Jahr nur die in diesem + * Monat gestarteten ("+N neu"). + * + * @param int[] $userIds + * @return int[] + */ + private function aboIds(array $userIds, string $userColumn, string $isFor, ?int $month = null, ?int $year = null): array + { + if ($userIds === []) { + return []; + } + + $query = $this->activeAboQuery() + ->whereIn($userColumn, $userIds) + ->where('is_for', $isFor); + + if ($month !== null && $year !== null) { + $query->whereBetween('start_date', [ + Carbon::create($year, $month, 1)->startOfMonth(), + Carbon::create($year, $month, 1)->endOfMonth(), + ]); + } + + return $query->pluck('id')->map(fn ($id) => (int) $id)->all(); + } + /** * @param int[] $userIds */ private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int { - $startDate = Carbon::create($year, $month, 1)->startOfMonth(); - $endDate = Carbon::create($year, $month, 1)->endOfMonth(); - - return $this->activeAboQuery() - ->whereIn($userColumn, $userIds) - ->where('is_for', $isFor) - ->whereBetween('start_date', [$startDate, $endDate]) - ->count(); + return count($this->aboIds($userIds, $userColumn, $isFor, $month, $year)); } /** @@ -736,18 +743,30 @@ class BackofficeDashboardService $aboCounts = $this->customerAboCounts($memberIds); $targetPoints = $this->customerAboTargetPoints($memberIds); $actualPoints = $this->customerAboActualPoints($memberIds, $month, $year); + $addonPoints = $this->customerAboAddonPoints($memberIds, $month, $year); $rows = collect($usersWithLine) - ->map(fn (array $entry, int $userId) => [ - 'user_id' => $userId, - 'name' => $this->userName($entry['user']), - 'career_level' => $this->careerLevel($entry['user']), - 'line' => $entry['line'], - 'is_self' => $entry['line'] === 0, - 'abo_count' => (int) $aboCounts->get($userId, 0), - 'points_target' => (float) $targetPoints->get($userId, 0.0), - 'points_actual' => (float) $actualPoints->get($userId, 0.0), - ]); + ->map(function (array $entry, int $userId) use ($aboCounts, $targetPoints, $actualPoints, $addonPoints) { + $actual = (float) $actualPoints->get($userId, 0.0); + // Einmal-Zusätze aus der Position ableiten, den wiederkehrenden + // Anteil als Rest: so ergeben die beiden Spalten IMMER die + // angezeigte Gesamtsumme, auch wenn Positions- und + // Zeilensummen in Altdaten einmal auseinanderfallen. + $addon = min(max((float) $addonPoints->get($userId, 0.0), 0.0), $actual); + + return [ + 'user_id' => $userId, + 'name' => $this->userName($entry['user']), + 'career_level' => $this->careerLevel($entry['user']), + 'line' => $entry['line'], + 'is_self' => $entry['line'] === 0, + 'abo_count' => (int) $aboCounts->get($userId, 0), + 'points_target' => (float) $targetPoints->get($userId, 0.0), + 'points_actual' => $actual, + 'points_addon' => round($addon, 2), + 'points_recurring' => round($actual - $addon, 2), + ]; + }); return [ 'by_count' => $rows @@ -810,6 +829,84 @@ class BackofficeDashboardService ->map(fn ($points) => (float) $points); } + /** + * Von den abgerechneten Kundenabo-Punkten der Anteil, der aus EINMALIG zum + * Abo dazubestellten Positionen stammt (Kundenwunsch 2026-09-07: + * "1231 abgerechnet – davon 1000 reine Abo-Punkte – 231 einmalige"). + * + * Grundlage ist `shopping_order_items.is_abo_addon`. Die Positionspunkte sind + * Stückpunkte zum Bestellzeitpunkt, die Zeilensumme also `points * qty` — + * gegen die Produktivdaten geprüft: die Positionssummen je Bestellung stimmen + * mit `user_sales_volumes.points` überein. + * + * Bewusst zweistufig statt als ein JOIN: eine Bestellung darf pro Berater nur + * EINMAL zählen, ein zusätzlicher Join auf die Positionen würde die Zeilen + * vervielfachen, sobald zu einer Bestellung mehrere Umsatzzeilen existieren. + * + * Einschränkung: `is_abo_addon` gibt es erst seit 06/2026. Für ältere + * Bestellungen ist der Wert false — die Aufteilung zeigt dort alles als + * wiederkehrend. + * + * @param int[] $memberIds + * @return Collection + */ + private function customerAboAddonPoints(array $memberIds, int $month, int $year): Collection + { + if ($memberIds === []) { + return collect(); + } + + // Je Berater und Bestellung sowohl die gebuchte Summe (netto, Stornos also + // gegengerechnet) als auch die urspruenglich positive Summe: aus deren + // Verhaeltnis ergibt sich, wie viel von der Bestellung im Monat stehen + // geblieben ist. + $pairs = UserSalesVolume::query() + ->join('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_sales_volumes.user_id', $memberIds) + ->where('user_sales_volumes.month', $month) + ->where('user_sales_volumes.year', $year) + ->where('shopping_orders.is_abo', 1) + ->whereRaw(SalesVolumePoints::shopPredicate()) + ->groupBy('user_sales_volumes.user_id', 'shopping_orders.id') + ->selectRaw('user_sales_volumes.user_id, shopping_orders.id as shopping_order_id') + ->selectRaw('SUM(user_sales_volumes.points) as net_points') + ->selectRaw('SUM(CASE WHEN user_sales_volumes.points > 0 THEN user_sales_volumes.points ELSE 0 END) as gross_points') + ->get(); + + if ($pairs->isEmpty()) { + return collect(); + } + + $addonByOrder = ShoppingOrderItem::query() + ->whereIn('shopping_order_id', $pairs->pluck('shopping_order_id')->unique()->all()) + ->addonItems() + ->groupBy('shopping_order_id') + ->selectRaw('shopping_order_id, COALESCE(SUM(points * qty), 0) as points') + ->pluck('points', 'shopping_order_id'); + + $byUser = []; + foreach ($pairs as $pair) { + $addon = (float) $addonByOrder->get((int) $pair->shopping_order_id, 0.0); + if ($addon <= 0) { + continue; + } + + // Anteil der Bestellung, der im Monat tatsaechlich stehen geblieben ist. + // Vollstorno ergibt 0, eine unstornierte Bestellung 1. Ein Teilstorno wird + // anteilig verrechnet: welche Position storniert wurde, geht aus der + // Stornobuchung nicht hervor — sie hat keine Positionsaufteilung. + $gross = (float) $pair->gross_points; + $factor = $gross > 0 ? max(0.0, min(1.0, (float) $pair->net_points / $gross)) : 0.0; + + $points = $addon * $factor; + if ($points > 0) { + $byUser[(int) $pair->user_id] = ($byUser[(int) $pair->user_id] ?? 0.0) + $points; + } + } + + return collect($byUser); + } + /** * "Recruiter des Monats": Wer im Team (inklusive des Statistik-Nutzers * selbst, Linie 0) hat im gewählten Monat wie viele Neupartner gesponsert? @@ -828,15 +925,22 @@ class BackofficeDashboardService * Sortiert nach Anzahl absteigend (Volumen als Tiebreaker); nur Sponsoren * mit mindestens einem Neupartner. * + * `totals` (Kundenwunsch 2026-09-07) ist bewusst NICHT die Spaltensumme: + * `volume_points` enthält je Sponsor das Kettenvolumen, ein Neupartner steckt + * also gleichzeitig in der Zeile seines Sponsors und in seiner eigenen. Die + * Summe wird deshalb einmal über die deduplizierte Neupartner-Menge gerechnet + * und ist entsprechend kleiner als die Spalte darüber. + * * @param array> $lineBuckets - * @return array}>}> + * @return array{rows: array}>}>, totals: array{new_partner_count: int, volume_points: float}} */ private function recruiterRanking(User $rootUser, array $lineBuckets, int $month, int $year): array { + $emptyTotals = ['new_partner_count' => 0, 'volume_points' => 0.0]; $usersByLine = $this->usersWithLine($lineBuckets, $rootUser); if ($usersByLine === []) { - return []; + return ['rows' => [], 'totals' => $emptyTotals]; } $newPartnersBySponsor = []; @@ -856,16 +960,22 @@ class BackofficeDashboardService } if ($newPartnersBySponsor === []) { - return []; + return ['rows' => [], 'totals' => $emptyTotals]; } $newPartnerIds = collect($newPartnersBySponsor) ->flatMap(fn (array $partners) => collect($partners)->map(fn (User $partner) => (int) $partner->id)) + ->unique() ->values() ->all(); $pointsByUser = $this->totalPointsByUser($newPartnerIds, $month, $year); + $totals = [ + 'new_partner_count' => count($newPartnerIds), + 'volume_points' => round((float) collect($newPartnerIds)->sum(fn (int $id) => (float) $pointsByUser->get($id, 0.0)), 2), + ]; + $chainVolume = function (int $sponsorId, array $visited) use (&$chainVolume, $newPartnersBySponsor, $pointsByUser): float { $volume = 0.0; @@ -933,10 +1043,13 @@ class BackofficeDashboardService ]; } - return collect($rows) - ->sortBy([['new_partner_count', 'desc'], ['volume_points', 'desc']]) - ->values() - ->all(); + return [ + 'rows' => collect($rows) + ->sortBy([['new_partner_count', 'desc'], ['volume_points', 'desc']]) + ->values() + ->all(), + 'totals' => $totals, + ]; } /** @@ -1002,9 +1115,14 @@ class BackofficeDashboardService return $name !== '' ? $name : ($user->email ?: '#'.$user->id); } + /** + * Karriere-Level in der Sprache des Betrachters — dieselbe Auflösung wie in + * den übrigen Team-Auswertungen ({@see TeamAboOverviewService}, Firstlines, + * Jahresgebühr-Liste), sonst steht in einer Karte der deutsche Name. + */ private function careerLevel(User $user): string { - return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-'); + return $user->user_level?->getLang('name') ?: ($user->m_level ? 'Level '.$user->m_level : '-'); } /** diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php index fc82323e..658a0019 100644 --- a/app/Services/Backoffice/BackofficeDrilldownService.php +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -2,6 +2,7 @@ namespace App\Services\Backoffice; +use App\Models\BackofficeStatisticsSnapshot; use App\Models\UserAbo; use App\Models\UserSalesVolume; use App\User; @@ -26,17 +27,30 @@ class BackofficeDrilldownService $onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true); - $userIds = $this->dashboardService->lineUserIds($viewer->id, $line); + // Abgeschlossener Monat: Mitglieder und Abos kommen aus dem eingefrorenen + // Snapshot, damit die Liste zu der Zahl passt, aus der sie aufgerufen wurde. + // Ohne das rechnet die Liste mit der heutigen Struktur — ein spaeter + // hinzugekommener oder verschobener Teampartner taucht dann in einem + // laengst abgerechneten Monat auf (oder verschwindet daraus). + $frozen = $this->frozenLines($viewer, $month, $year); + + $userIds = $frozen !== null + ? $this->frozenUserIds($frozen, $line) + : $this->dashboardService->lineUserIds($viewer->id, $line); if ($metric === 'shop_1000' && $line === 0) { $userIds[] = (int) $viewer->id; } + $frozenAboIds = $frozen !== null + ? $this->frozenAboIds($frozen, $line, $metric, $onlyNew) + : null; + $rows = match ($metric) { 'consultants' => $this->consultantRows($userIds), 'new_partners' => $this->newPartnerRows($userIds, $month, $year), - 'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew), - 'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew), + 'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds), + 'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds), 'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric), default => [], }; @@ -119,7 +133,9 @@ class BackofficeDrilldownService 'is_account_active' => Carbon::parse($user->payment_account)->isFuture(), 'account_status' => Carbon::parse($user->payment_account)->isFuture() ? 'Aktiv' : 'Abgelaufen', 'active_date' => $this->formatDate($user->active_date), + 'active_date_sort' => $this->sortDate($user->active_date), 'payment_account' => $this->formatDate($user->payment_account), + 'payment_account_sort' => $this->sortDate($user->payment_account), ]) ->values() ->all(); @@ -152,12 +168,17 @@ class BackofficeDrilldownService 'user_id' => $user->id, 'name' => $this->userName($user), 'email' => $user->email, + // Telefon mit Mobil-Fallback, gleiche Regel wie auf der + // Firstlines-Seite (Kundenwunsch 2026-09-07: zum Nachfassen). + 'phone' => trim(($user->account?->phone ?? '') !== '' ? (string) $user->account->phone : (string) ($user->account?->mobil ?? '')), 'career_level' => $this->careerLevel($user), 'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-', 'customer_points' => (float) ($monthlyPoints->get((int) $user->id)['external_points'] ?? 0.0), 'total_points' => (float) ($monthlyPoints->get((int) $user->id)['total_points'] ?? 0.0), 'active_date' => $this->formatDate($user->active_date), + 'active_date_sort' => $this->sortDate($user->active_date), 'payment_account' => $this->formatDate($user->payment_account), + 'payment_account_sort' => $this->sortDate($user->payment_account), ]) ->values() ->all(); @@ -193,17 +214,97 @@ class BackofficeDrilldownService ]); } + /** + * Die eingefrorenen Linienzeilen eines abgeschlossenen Monats, oder null, wenn + * der Monat noch laeuft bzw. kein Snapshot (oder ein aelterer ohne `abo_ids`) + * vorliegt. Aeltere Payloads fallen bewusst auf die Live-Auswertung zurueck, + * statt eine Genauigkeit vorzutaeuschen, die sie nicht haben. + * + * @return array>|null + */ + private function frozenLines(User $viewer, int $month, int $year): ?array + { + $snapshot = BackofficeStatisticsSnapshot::query() + ->where('user_id', $viewer->id) + ->where('month', $month) + ->where('year', $year) + ->first(); + + if (! $snapshot) { + return null; + } + + $payload = $snapshot->payload; + + if ((int) ($payload['payload_version'] ?? 1) < BackofficeDashboardService::PAYLOAD_VERSION) { + return null; + } + + return is_array($payload['lines'] ?? null) ? $payload['lines'] : null; + } + + /** + * Mitglieder einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. + * + * @param array> $frozen + * @return int[] + */ + private function frozenUserIds(array $frozen, int $line): array + { + $ids = []; + + foreach ($frozen as $key => $row) { + if ($line !== 0 && (int) ($row['line'] ?? $key) !== $line) { + continue; + } + + foreach ($row['user_ids'] ?? [] as $id) { + $ids[] = (int) $id; + } + } + + return array_values(array_unique($ids)); + } + + /** + * Abos einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. Gibt es zu der + * Kennzahl keine eingefrorene Liste, wird null zurueckgegeben (Live-Auswertung). + * + * @param array> $frozen + * @return int[]|null + */ + private function frozenAboIds(array $frozen, int $line, string $metric, bool $onlyNew): ?array + { + if (! in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) { + return null; + } + + $key = $metric.($onlyNew ? '_new' : ''); + $ids = []; + + foreach ($frozen as $index => $row) { + if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) { + continue; + } + + foreach ($row['abo_ids'][$key] ?? [] as $id) { + $ids[] = (int) $id; + } + } + + return array_values(array_unique($ids)); + } + /** * @param int[] $userIds * @return array> */ - private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array + private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array { - return $this->activeAboQuery() + return $this->aboScope($frozenAboIds) ->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions']) - ->whereIn('user_id', $userIds) - ->where('is_for', 'me') - ->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year)) + ->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('user_id', $userIds)->where('is_for', 'me')) + ->when($frozenAboIds === null && $onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year)) ->orderBy('next_date') ->get() ->map(fn (UserAbo $abo) => [ @@ -215,8 +316,10 @@ class BackofficeDrilldownService 'career_level' => $abo->user ? $this->careerLevel($abo->user) : '-', 'points' => $abo->getTotalPoints(), 'start_date' => $this->formatDate($abo->getRawOriginal('start_date')), + 'start_date_sort' => $this->sortDate($abo->getRawOriginal('start_date')), 'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year), - 'next_date' => $this->formatDate($abo->next_date), + 'next_date' => $this->formatDate($abo->getRawOriginal('next_date')), + 'next_date_sort' => $this->sortDate($abo->getRawOriginal('next_date')), 'deliveries' => $abo->getCountOrders(), 'status' => $abo->status, 'status_label' => $abo->getStatusType(), @@ -235,13 +338,12 @@ class BackofficeDrilldownService * @param int[] $userIds * @return array> */ - private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array + private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array { - return $this->activeAboQuery() + return $this->aboScope($frozenAboIds) ->with(['member.account', 'member.user_level', 'user_abo_items.product']) - ->whereIn('member_id', $userIds) - ->where('is_for', 'ot') - ->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year)) + ->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('member_id', $userIds)->where('is_for', 'ot')) + ->when($frozenAboIds === null && $onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year)) ->orderBy('member_id') ->orderBy('next_date') ->get() @@ -254,7 +356,7 @@ class BackofficeDrilldownService 'abo_id' => $abo->id, 'points' => (float) $abo->getTotalPoints(), 'start_date' => $this->formatDate($abo->getRawOriginal('start_date')), - 'next_date' => $this->formatDate($abo->next_date), + 'next_date' => $this->formatDate($abo->getRawOriginal('next_date')), 'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year), ])->values()->all(); @@ -294,28 +396,32 @@ class BackofficeDrilldownService ->selectRaw(SalesVolumePoints::totalPoints().' as total_points') ->groupBy('user_sales_volumes.user_id'); + // `<> 0` statt `> 0`: ein Berater mit negativem Monatssaldo (Storno) steckt in + // der Uebersichtssumme und muss deshalb auch in der Detailliste auftauchen — + // sonst ergibt die Detailsumme etwas anderes als die verlinkte Kachel. + // Nur die echte Qualifikationsliste behaelt ihre Schwelle. if ($metric === 'own_points') { - $rows->havingRaw(SalesVolumePoints::ownPoints().' > 0'); + $rows->havingRaw(SalesVolumePoints::ownPoints().' <> 0'); } if ($metric === 'external_points') { - $rows->havingRaw(SalesVolumePoints::externalPoints().' > 0'); + $rows->havingRaw(SalesVolumePoints::externalPoints().' <> 0'); } if ($metric === 'customer_abo_points') { - $rows->havingRaw(SalesVolumePoints::customerAboPoints().' > 0'); + $rows->havingRaw(SalesVolumePoints::customerAboPoints().' <> 0'); } if ($metric === 'customer_single_order_points') { - $rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' > 0'); + $rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' <> 0'); } if ($metric === 'customer_other_points') { - $rows->havingRaw(SalesVolumePoints::customerOtherPoints().' > 0'); + $rows->havingRaw(SalesVolumePoints::customerOtherPoints().' <> 0'); } if ($metric === 'total_points') { - $rows->havingRaw(SalesVolumePoints::totalPoints().' > 0'); + $rows->havingRaw(SalesVolumePoints::totalPoints().' <> 0'); } if ($metric === 'shop_1000') { @@ -350,6 +456,25 @@ class BackofficeDrilldownService ->whereNotIn('status', [4, 5, 6]); } + /** + * Grundabfrage der Abo-Listen: im laufenden Monat der heutige Aktivbestand, in + * einem abgeschlossenen Monat exakt die eingefrorenen Abos. + * + * `withTrashed()` ist dort nicht optional — ein Abo, das der Monat zu Recht + * enthaelt, kann inzwischen gekuendigt oder geloescht sein. Ohne den Zusatz + * waere die Liste kuerzer als die Zahl, aus der sie aufgerufen wurde. + * + * @param int[]|null $frozenAboIds + */ + private function aboScope(?array $frozenAboIds): Builder + { + if ($frozenAboIds === null) { + return $this->activeAboQuery(); + } + + return UserAbo::query()->withTrashed()->whereIn('id', $frozenAboIds); + } + /** * Nur Abos, die im gewählten Monat gestartet sind (gleiche Zählweise wie * die "+N neu"-Badges, {@see BackofficeDashboardService::newAboCount()}). @@ -421,4 +546,19 @@ class BackofficeDrilldownService return Carbon::parse($date)->format(\Util::formatDateDB()); } + + /** + * Unformatierter Sortierwert zu einem Datum (A14). Die Anzeige über + * {@see formatDate()} ist lokalisiert — bei `en` liefert + * {@see \Util::formatDateDB()} `Y-m-d` statt `d.m.Y` —, deshalb bekommt die + * Tabelle den Sortierschlüssel getrennt vom Anzeigetext. + */ + private function sortDate(mixed $date): ?string + { + if ($date === null || $date === '') { + return null; + } + + return Carbon::parse($date)->format('Y-m-d'); + } } diff --git a/resources/views/user/backoffice/statistics/index.blade.php b/resources/views/user/backoffice/statistics/index.blade.php index 96df20dc..9566cd4e 100644 --- a/resources/views/user/backoffice/statistics/index.blade.php +++ b/resources/views/user/backoffice/statistics/index.blade.php @@ -26,8 +26,8 @@ @if (!empty($performance['calculated_at'])) Snapshot vom {{ $performance['calculated_at'] }} @endif - @if (!empty($statistics['abo_stock_live'])) - Abo-Bestände tagesaktuell + @if (($performance['source'] ?? null) === 'snapshot') + Monat abgeschlossen · Stand eingefroren @endif @endif @@ -205,7 +205,7 @@ {{ $line['label'] }} @foreach ($clickableMetrics as $metric) - @if ($line[$metric] > 0) + @if ($line[$metric] != 0) +{{ number_format($line[$newMetric], 0, ',', '.') }} @@ -235,7 +235,7 @@ {{ $statistics['totals']['label'] }} @foreach ($clickableMetrics as $metric) - @if ($statistics['totals'][$metric] > 0) + @if ($statistics['totals'][$metric] != 0) +{{ number_format($statistics['totals'][$newMetric], 0, ',', '.') }} @@ -354,11 +354,13 @@ {{ number_format((float) ($leaderAbos['total'] ?? 0), 0, ',', '.') }} @if ($leaderAbos !== null) -
+
{{ number_format((float) ($leaderAbos['total_own'] ?? 0), 0, ',', '.') }} eigene · {{ number_format((float) ($leaderAbos['total_customer'] ?? 0), 0, ',', '.') }} Kunden · {{ number_format((float) ($leaderAbos['total_team'] ?? 0), 0, ',', '.') }} Team · {{ number_format((float) ($leaderAbos['total_team_customer'] ?? 0), 0, ',', '.') }} Teamkunden +
@endif
@@ -383,8 +385,6 @@ Führungskraft Eigene Abos Kundenabos - Teamabos (direkt) - Team-Kundenabos (direkt) Teamabos (gesamt) Team-Kundenabos (gesamt) Gesamt @@ -401,7 +401,10 @@
{{ $row['name'] }}
Linie {{ $row['line'] }} · {{ $row['career_level'] }}
- @foreach (['own_abo_count', 'customer_abo_count', 'team_abo_count', 'team_customer_abo_count', 'team_abo_count_total', 'team_customer_abo_count_total'] as $countKey) + {{-- Die "direkt"-Spalten sind auf Kundenwunsch (07.09.2026) raus; die + Kopf-Summe rechnet intern weiter mit ihnen, weil nur sie + ueberschneidungsfrei ist (siehe Fussnote unter der Tabelle). --}} + @foreach (['own_abo_count', 'customer_abo_count', 'team_abo_count_total', 'team_customer_abo_count_total'] as $countKey) @if (($row[$countKey] ?? 0) > 0) @@ -422,9 +425,10 @@

- direkt = Team bis zur nächsten Führungskraft (überschneidungsfrei, ergibt die Kopf-Summe). gesamt = komplette Downline inklusive der Teams untergeordneter Führungskräfte — dieselben Abos - können dort bei mehreren Führungskräften zählen, die Gesamt-Spalte lässt sich deshalb nicht aufsummieren. + können dort bei mehreren Führungskräften zählen, die Spalten lassen sich deshalb nicht aufsummieren. + Die Summe oben rechts zählt jedes Abo genau einmal (Team jeweils nur bis zur nächsten + Führungskraft) und ist deshalb kleiner als die Spaltensummen.

@endif @@ -434,6 +438,8 @@ @php $recruiterRanking = $statistics['recruiter_ranking'] ?? null; + // Aeltere Snapshots kennen die Summenzeile noch nicht -> dann bleibt sie weg. + $recruiterTotals = $statistics['recruiter_totals'] ?? null; @endphp
@@ -521,6 +527,18 @@ @endforeach + @if ($recruiterTotals !== null) + {{-- Summe ueber die deduplizierte Neupartner-Menge, NICHT ueber die + Spalte: dort steckt jeder Neupartner durch das Kettenvolumen + mehrfach drin (Kundenwunsch 2026-09-07). --}} + + + Summe + {{ number_format((float) $recruiterTotals['new_partner_count'], 0, ',', '.') }} + {{ \App\Services\Util::formatNumber($recruiterTotals['volume_points']) }} + + + @endif
@@ -528,6 +546,11 @@ neues Volumen = Monats-Gesamtpunkte (Eigen- und Kundenpunkte, inkl. Starterpaket) der geworbenen Neupartner — plus rekursiv die Punkte der Neupartner, die diese im selben Monat selbst geworben haben. Klick auf eine Zeile zeigt, wer geworben wurde. + @if ($recruiterTotals !== null) +
Summe = Volumen aller Neupartner des Monats, jeder genau einmal gezählt. + Sie ist kleiner als die Spalte darüber, weil ein Neupartner dort zusätzlich im Kettenvolumen + seines Sponsors steckt. + @endif

@endif @@ -625,6 +648,8 @@ Berater Abos Abgerechnet (Punkte) + davon reine Abo-Punkte + einmalig dazu Bestand/Lieferung @@ -649,6 +674,14 @@ {{ \App\Services\Util::formatNumber($row['points_actual']) }} + {{-- Aufteilung erst ab 06/2026 erfasst; aeltere Snapshots haben die + Schluessel gar nicht -> dann bleiben die Zellen leer. --}} + + {{ isset($row['points_recurring']) ? \App\Services\Util::formatNumber($row['points_recurring']) : '–' }} + + + {{ isset($row['points_addon']) ? \App\Services\Util::formatNumber($row['points_addon']) : '–' }} + {{ \App\Services\Util::formatNumber($row['points_target']) }} diff --git a/tests/Feature/BackofficeSnapshotFreezeTest.php b/tests/Feature/BackofficeSnapshotFreezeTest.php new file mode 100644 index 00000000..e77220e7 --- /dev/null +++ b/tests/Feature/BackofficeSnapshotFreezeTest.php @@ -0,0 +1,162 @@ + Carbon::setTestNow()); + +function freezeUser(?int $sponsor = null): User +{ + return User::forceCreate([ + 'email' => 'freeze-'.uniqid('', true).'@example.test', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'm_sponsor' => $sponsor, + 'active_date' => '2026-01-01', + 'payment_account' => '2027-01-01', + ]); +} + +function freezeVolume(User $user, float $points, int $month = 8): UserSalesVolume +{ + return UserSalesVolume::forceCreate([ + 'user_id' => $user->id, + 'month' => $month, + 'year' => 2026, + 'date' => '2026-0'.$month.'-05', + 'points' => $points, + 'total_net' => $points, + 'status' => 1, + 'status_points' => 1, + 'status_turnover' => 1, + ]); +} + +function freezeAbo(User $member, string $isFor = 'ot', string $startDate = '2026-08-05'): UserAbo +{ + return UserAbo::create([ + 'user_id' => $member->id, + 'member_id' => $member->id, + 'shopping_user_id' => 0, + 'email' => 'a-'.uniqid('', true).'@example.test', + 'is_for' => $isFor, + 'payone_userid' => 0, + 'clearingtype' => 'cc', + 'amount' => 5000, + 'active' => true, + 'status' => 2, + 'abo_interval' => 5, + 'start_date' => $startDate, + 'next_date' => '2026-10-05', + ]); +} + +it('haelt Punkte eines abgeschlossenen Monats nach einer spaeteren Korrektur fest', function () { + $root = freezeUser(); + $anna = freezeUser($root->id); + $volume = freezeVolume($anna, 100); + + app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026); + + $volume->update(['points' => 500]); + + $overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026); + + expect($overview['_meta']['source'])->toBe('snapshot') + ->and((float) $overview['totals']['total_points'])->toBe(100.0); +}); + +it('haelt auch die Abo-Bestaende und Ranglisten fest', function () { + $root = freezeUser(); + $anna = freezeUser($root->id); + $abo = freezeAbo($anna); + + app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026); + + // Kuendigung im September darf den August nicht mehr veraendern. + $abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-10']); + + $overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026); + + expect((int) $overview['totals']['team_customer_abos'])->toBe(1); +}); + +it('laesst einen spaeter dazugekommenen Teampartner aus dem abgeschlossenen Monat heraus', function () { + $root = freezeUser(); + $anna = freezeUser($root->id); + freezeVolume($anna, 100); + + app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026); + + // Neuer Teampartner im September, mit einer Buchung, die im August liegt + // (z. B. nachtraeglich zugeordnet) — der abgeschlossene Monat bleibt unberuehrt. + $spaeter = freezeUser($root->id); + freezeVolume($spaeter, 999); + app(BackofficeDashboardService::class)->flushLineBucketsMemo($root->id); + + $overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026); + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026); + + expect((float) $overview['totals']['total_points'])->toBe(100.0) + ->and($details['summary']['total_points'])->toBe(100.0) + ->and($details['rows'])->toHaveCount(1) + ->and($details['rows'][0]['user_id'])->toBe($anna->id); +}); + +it('zeigt in der Abo-Detailliste genau die eingefrorenen Abos', function () { + $root = freezeUser(); + $anna = freezeUser($root->id); + $abo = freezeAbo($anna); + + app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026); + + $abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-10']); + $abo->delete(); + + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 8, 2026); + + expect($details['summary']['abo_count'])->toBe(1); +}); + +it('haelt Kachel und verlinkte Liste der neuen Abos zusammen', function () { + $root = freezeUser(); + $anna = freezeUser($root->id); + $abo = freezeAbo($anna, 'ot', '2026-08-20'); // neu im August + + app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026); + + $abo->update(['active' => false, 'status' => 4, 'cancel_date' => '2026-09-08']); + + $overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026); + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 8, 2026, true); + + expect((int) $overview['totals']['team_customer_abos_new'])->toBe(1) + ->and($details['summary']['abo_count'])->toBe(1); +}); + +it('rechnet den laufenden Monat weiterhin live', function () { + $root = freezeUser(); + $anna = freezeUser($root->id); + $volume = freezeVolume($anna, 100, 9); + + $volume->update(['points' => 500]); + + $overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026); + + expect($overview['_meta']['source'])->toBe('live') + ->and((float) $overview['totals']['total_points'])->toBe(500.0); +}); diff --git a/tests/Feature/BackofficeStatisticsListsTest.php b/tests/Feature/BackofficeStatisticsListsTest.php index 6bcc78a9..7c77343a 100644 --- a/tests/Feature/BackofficeStatisticsListsTest.php +++ b/tests/Feature/BackofficeStatisticsListsTest.php @@ -2,6 +2,7 @@ use App\Models\Product; use App\Models\ShoppingOrder; +use App\Models\ShoppingOrderItem; use App\Models\UserAbo; use App\Models\UserAboItem; use App\Models\UserLevel; @@ -251,7 +252,11 @@ it('liefert Top-10-Kundenabo-Ranglisten nach Anzahl und Umsatz mit Soll und Ist' ->and($top['by_points'][1]['points_actual'])->toBe(0.0); }); -it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function (): void { +it('haelt die Abo-Bestaende eines abgeschlossenen Monats fest', function (): void { + // Kundenentscheidung 2026-09-10: Ein abgeschlossener Monat ist abgerechnet und + // wird nicht mehr aus heutigen Daten nachgerechnet. Vorher wurden die + // Bestandszahlen hier bewusst live ueberlagert ("Abo-Bestaende tagesaktuell"); + // dieser Test hielt die alte Regel fest und beschreibt jetzt die neue. $root = bslMakeUser(); $anna = bslMakeUser($root->id); @@ -267,15 +272,18 @@ it('ueberlagert Snapshot-Monate mit tagesaktuellen Abo-Bestandszahlen', function $overview = $service->overview($root, 5, 2026); expect($overview['_meta']['source'])->toBe('snapshot') - ->and($overview['abo_stock_live'])->toBeTrue() - ->and($overview['lines'][1]['team_partner_abos'])->toBe(0) // Bestand: live - ->and($overview['lines'][1]['team_customer_abos'])->toBe(1) // Bestand: live - ->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1) // "+N neu" im Mai: eingefroren - ->and($overview['totals']['team_partner_abos'])->toBe(0) - ->and($overview['totals']['team_customer_abos'])->toBe(1); + ->and($overview)->not->toHaveKey('abo_stock_live') + ->and($overview['lines'][1]['team_partner_abos'])->toBe(1) // Stand Mai + ->and($overview['lines'][1]['team_customer_abos'])->toBe(0) // Stand Mai + ->and($overview['lines'][1]['team_partner_abos_new'])->toBe(1) + ->and($overview['totals']['team_partner_abos'])->toBe(1) + ->and($overview['totals']['team_customer_abos'])->toBe(0); }); -it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden sind', function (): void { +it('nimmt spaeter entstandene Linien nicht mehr in einen abgeschlossenen Monat auf', function (): void { + // Genau der Fall, der die Entscheidung ausgeloest hat: Teampartner koennen + // dazukommen oder verschoben werden. Ein abgerechneter Monat darf sich dadurch + // nicht mehr aendern. $root = bslMakeUser(); $anna = bslMakeUser($root->id); @@ -294,12 +302,9 @@ it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden $overview = $service->overview($root, 5, 2026); expect($overview['_meta']['source'])->toBe('snapshot') - ->and($overview['lines'])->toHaveKey(2) - ->and($overview['lines'][2]['team_partner_abos'])->toBe(1) - ->and($overview['lines'][2]['team_customer_abos'])->toBe(1) - ->and($overview['lines'][2]['team_partner_abos_new'])->toBe(0) // Monatswert: im Snapshot-Monat gab es die Linie nicht - ->and($overview['totals']['team_partner_abos'])->toBe(2) - ->and($overview['totals']['team_customer_abos'])->toBe(1); + ->and($overview['lines'])->not->toHaveKey(2) + ->and($overview['totals']['team_partner_abos'])->toBe(1) + ->and($overview['totals']['team_customer_abos'])->toBe(0); }); it('nimmt den Statistik-Nutzer selbst in die Kundenabo-Ranglisten auf', function (): void { @@ -499,7 +504,8 @@ it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Ketten 'status' => 1, ]); - $ranking = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'])->keyBy('user_id'); + $overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026); + $ranking = collect($overview['recruiter_ranking'])->keyBy('user_id'); // Root: Annas 500 (400 eigene + 100 Kunden) + rekursiv Bens 200 expect($ranking->get($root->id)['volume_points'])->toBe(700.0) @@ -507,6 +513,14 @@ it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Ketten ->and($ranking->get($root->id)['partners'][0]['children'][0]['total_points'])->toBe(200.0) // Anna selbst: nur Bens Punkte ->and($ranking->get($anna->id)['volume_points'])->toBe(200.0); + + // Summenzeile: jeder Neupartner genau EINMAL. Die Spaltensumme waere + // 700 + 200 = 900, weil Ben im Kettenvolumen von Root steckt und zusaetzlich + // in Annas Zeile. Richtig sind Annas 500 + Bens 200 = 700 bei 2 Neupartnern. + expect($overview['recruiter_totals'])->toBe([ + 'new_partner_count' => 2, + 'volume_points' => 700.0, + ]); }); it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', function (): void { @@ -536,3 +550,76 @@ it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', funct ->and($details['summary']['customer_points'])->toBe(250.0) ->and($details['summary']['total_points'])->toBe(350.0); }); + +it('teilt die abgerechneten Kundenabo-Punkte in wiederkehrende und einmalig dazubestellte auf', function (): void { + // Kundenwunsch 2026-09-07: "1231 abgerechnet - davon 1000 reine Abo-Punkte - + // 231 einmalige Bestellungen dazu." + $root = bslMakeUser(); + $anna = bslMakeUser($root->id); + + $order = ShoppingOrder::forceCreate([ + 'shopping_user_id' => 0, + 'auth_user_id' => $anna->id, + 'country_id' => 0, + 'user_shop_id' => 0, + 'is_abo' => true, + 'paid' => true, + ]); + + // 2 x 500 wiederkehrend + 1 x 231 einmalig dazu = 1231 + ShoppingOrderItem::forceCreate([ + 'shopping_order_id' => $order->id, 'product_id' => 1, 'qty' => 2, 'points' => 500, 'is_abo_addon' => false, + ]); + ShoppingOrderItem::forceCreate([ + 'shopping_order_id' => $order->id, 'product_id' => 2, 'qty' => 1, 'points' => 231, 'is_abo_addon' => true, + ]); + + UserSalesVolume::forceCreate([ + 'user_id' => $anna->id, + 'shopping_order_id' => $order->id, + 'month' => 6, + 'year' => 2026, + 'points' => 1231, + 'status' => 2, + ]); + + $row = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points']) + ->firstWhere('user_id', $anna->id); + + expect($row['points_actual'])->toBe(1231.0) + ->and($row['points_recurring'])->toBe(1000.0) + ->and($row['points_addon'])->toBe(231.0) + // Die beiden Spalten muessen die angezeigte Gesamtsumme ergeben. + ->and($row['points_recurring'] + $row['points_addon'])->toBe($row['points_actual']); +}); + +it('zaehlt Abo-Bestellungen ohne Einmal-Positionen komplett als wiederkehrend', function (): void { + // Bestellungen vor 06/2026 kennen is_abo_addon nicht (Default false) — + // die Aufteilung darf dort nichts erfinden. + $root = bslMakeUser(); + $anna = bslMakeUser($root->id); + + $order = ShoppingOrder::forceCreate([ + 'shopping_user_id' => 0, + 'auth_user_id' => $anna->id, + 'country_id' => 0, + 'user_shop_id' => 0, + 'is_abo' => true, + 'paid' => true, + ]); + UserSalesVolume::forceCreate([ + 'user_id' => $anna->id, + 'shopping_order_id' => $order->id, + 'month' => 6, + 'year' => 2026, + 'points' => 400, + 'status' => 2, + ]); + + $row = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['top_customer_abos']['by_points']) + ->firstWhere('user_id', $anna->id); + + expect($row['points_actual'])->toBe(400.0) + ->and($row['points_recurring'])->toBe(400.0) + ->and($row['points_addon'])->toBe(0.0); +}); diff --git a/tests/Feature/CustomerAboAddonSplitTest.php b/tests/Feature/CustomerAboAddonSplitTest.php new file mode 100644 index 00000000..be726689 --- /dev/null +++ b/tests/Feature/CustomerAboAddonSplitTest.php @@ -0,0 +1,131 @@ + Carbon::setTestNow('2026-09-10 12:00:00')); +afterEach(fn () => Carbon::setTestNow()); + +function addonUser(?int $sponsor = null): User +{ + return User::forceCreate([ + 'email' => 'addon-'.uniqid().'@example.test', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 0, + 'm_level' => 1, + 'm_sponsor' => $sponsor, + 'active_date' => '2026-01-01', + 'payment_account' => '2027-01-01', + ]); +} + +/** + * Abo-Bestellung mit optionalem Einmal-Zusatz und der zugehoerigen Umsatzbuchung. + */ +function addonOrder(User $member, float $points, float $addonPoints = 0.0): ShoppingOrder +{ + $order = ShoppingOrder::create([ + 'shopping_user_id' => 0, + 'auth_user_id' => $member->id, + 'member_id' => $member->id, + 'country_id' => 1, + 'language' => 'de', + 'user_shop_id' => 1, + 'payment_for' => 6, + 'total' => $points, + 'subtotal' => $points, + 'points' => $points, + 'paid' => true, + 'shipped' => 0, + 'is_abo' => true, + 'mode' => 'test', + ]); + + if ($addonPoints > 0) { + ShoppingOrderItem::forceCreate([ + 'shopping_order_id' => $order->id, + 'product_id' => 0, + 'qty' => 1, + 'price' => $addonPoints, + 'points' => $addonPoints, + 'is_abo_addon' => true, + ]); + } + + return $order; +} + +function addonVolume(User $member, float $points, ShoppingOrder $order): UserSalesVolume +{ + return UserSalesVolume::forceCreate([ + 'user_id' => $member->id, + 'shopping_order_id' => $order->id, + 'month' => 9, + 'year' => 2026, + 'date' => '2026-09-01', + 'status' => 2, + 'status_points' => 1, + 'status_turnover' => null, + 'points' => $points, + 'total_net' => $points, + ]); +} + +function addonRankingRow(User $root): array +{ + return app(BackofficeDashboardService::class)->overview($root, 9, 2026)['top_customer_abos']['by_points'][0]; +} + +it('weist die Zusatzpunkte einer stornierten Bestellung nicht mehr aus', function () { + $root = addonUser(); + $member = addonUser($root->id); + + $storniert = addonOrder($member, 100.0, 20.0); + SalesPointsVolume::cancelSalesPointsVolume(addonVolume($member, 100.0, $storniert), 9911); + + addonVolume($member, 100.0, addonOrder($member, 100.0)); + + $row = addonRankingRow($root); + + expect($row['points_actual'])->toBe(100.0) + ->and($row['points_addon'])->toBe(0.0) + ->and($row['points_recurring'])->toBe(100.0); +}); + +it('weist die Zusatzpunkte einer nicht stornierten Bestellung unveraendert aus', function () { + $root = addonUser(); + $member = addonUser($root->id); + + addonVolume($member, 100.0, addonOrder($member, 100.0, 20.0)); + + $row = addonRankingRow($root); + + expect($row['points_actual'])->toBe(100.0) + ->and($row['points_addon'])->toBe(20.0) + ->and($row['points_recurring'])->toBe(80.0); +}); + +it('rechnet einen Teilstorno anteilig auf die Zusatzpunkte an', function () { + $root = addonUser(); + $member = addonUser($root->id); + + $order = addonOrder($member, 100.0, 20.0); + addonVolume($member, 100.0, $order); + addonVolume($member, -50.0, $order)->update(['status' => 6, 'status_turnover' => 2, 'storno_origin_status' => 2]); + + $row = addonRankingRow($root); + + expect($row['points_actual'])->toBe(50.0) + ->and($row['points_addon'])->toBe(10.0) + ->and($row['points_recurring'])->toBe(40.0); +}); diff --git a/tests/Feature/StatisticsConsistencyTest.php b/tests/Feature/StatisticsConsistencyTest.php new file mode 100644 index 00000000..2e82eb97 --- /dev/null +++ b/tests/Feature/StatisticsConsistencyTest.php @@ -0,0 +1,243 @@ + 'sc-'.uniqid('', true).'@example.test', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'm_sponsor' => $sponsor, + 'active_date' => '2026-01-01', + 'payment_account' => '2027-01-01', + ]); +} + +function scVolume(User $user, float $points, int $status = 1, array $extra = []): UserSalesVolume +{ + return UserSalesVolume::forceCreate(array_merge([ + 'user_id' => $user->id, + 'month' => 9, + 'year' => 2026, + 'date' => '2026-09-05', + 'points' => $points, + 'total_net' => $points, + 'status' => $status, + 'status_points' => 1, + 'status_turnover' => 1, + ], $extra)); +} + +/** + * Ruft eine private Methode des Controllers auf — die Exportzuordnung ist bewusst + * nicht oeffentlich, muss aber gegen die Ueberschriften pruefbar bleiben. + */ +function scCsv(string $method, mixed ...$args): array +{ + $controller = app(BackofficeStatisticsController::class); + + return (new ReflectionMethod($controller, $method))->invoke($controller, ...$args); +} + +// ---------------------------------------------------------------- A09 + +it('haelt in jedem CSV-Export Kopfzeile und Summenzeile gleich breit', function (string $metric) { + $headers = scCsv('csvHeaders', $metric); + $summary = scCsv('csvSummaryRow', [ + 'metric' => $metric, + 'summary' => [ + 'count' => 1, + 'customer_points' => 100, + 'total_points' => 300, + 'own_points' => 10, + 'external_points' => 20, + 'customer_abo_points' => 30, + 'customer_single_order_points' => 40, + 'customer_other_points' => 50, + 'abo_count' => 2, + 'points' => 60, + 'deliveries' => 3, + ], + ]); + + expect($summary)->toHaveCount(count($headers)); +})->with(['new_partners', 'team_customer_abos', 'team_partner_abos', 'total_points', 'active_partners']); + +it('stellt die Neupartner-Summen unter die richtigen Ueberschriften', function () { + $headers = scCsv('csvHeaders', 'new_partners'); + $summary = scCsv('csvSummaryRow', [ + 'metric' => 'new_partners', + 'summary' => ['count' => 1, 'customer_points' => 100, 'total_points' => 300], + ]); + + $byHeader = array_combine($headers, $summary); + + expect($byHeader['Punkte seiner Kunden'])->toBe(100) + ->and($byHeader['Gesamt inkl. Eigenbestellung'])->toBe(300) + ->and($byHeader['Sponsor'])->toBe(''); +}); + +// ---------------------------------------------------------------- A02 + +it('zaehlt in der persoenlichen Statistik nur freigegebene Buchungen', function () { + $user = scUser(); + scVolume($user, 100, 1); + scVolume($user, 50, 3); // pending + scVolume($user, 25, 0); // nicht zugeordnet + + $personal = app(PersonalStatisticsService::class)->monthly($user, 9, 2026); + $canonical = SalesVolumePoints::totalPointsByUser([$user->id], 9, 2026)->get($user->id); + + expect($personal['totals']['total_points'])->toBe(100.0) + ->and($personal['totals']['total_points'])->toBe($canonical); +}); + +it('bucht einen Storno im persoenlichen Segment seiner Ursprungsbuchung', function () { + $user = scUser(); + scVolume($user, 100, 1); + scVolume($user, -100, 6, ['storno_origin_status' => 1]); + + $groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups']; + + expect($groups['own']['points'])->toBe(0.0) + ->and($groups['other']['points'])->toBe(0.0); +}); + +it('laesst Altstornos ohne Ursprungsart unter Sonstige', function () { + $user = scUser(); + scVolume($user, 100, 1); + scVolume($user, -100, 6); + + $groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups']; + + expect($groups['own']['points'])->toBe(100.0) + ->and($groups['other']['points'])->toBe(-100.0); +}); + +// ---------------------------------------------------------------- A03 + +it('zeigt negative Monatssalden im Drilldown und bleibt mit der Uebersicht gleich', function () { + $root = scUser(); + $plus = scUser($root->id); + $minus = scUser($root->id); + + scVolume($plus, 100, 1); + scVolume($minus, -40, 1); + + $overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026); + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026); + + expect((float) $overview['totals']['total_points'])->toBe(60.0) + ->and($details['summary']['total_points'])->toBe(60.0) + ->and($details['rows'])->toHaveCount(2); +}); + +it('behaelt die Schwelle der 1000-Punkte-Liste', function () { + $root = scUser(); + $stark = scUser($root->id); + $schwach = scUser($root->id); + + scVolume($stark, 1200, 1); + scVolume($schwach, -40, 1); + + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'shop_1000', 9, 2026); + + expect($details['rows'])->toHaveCount(1) + ->and($details['rows'][0]['user_id'])->toBe($stark->id); +}); + +// ---------------------------------------------------------------- A18 + +it('kennzeichnet Formelinhalte im Export als Text', function (string $value) { + $safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow')) + ->invoke(null, [$value]); + + expect($safe[0])->toBe("'".$value); +})->with(['=1+1', '+49 170 1234567', '-SUM(A1)', '@import']); + +it('laesst unverfaengliche Werte im Export unveraendert', function () { + $safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow')) + ->invoke(null, ['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]); + + expect($safe)->toBe(['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]); +}); + +// ---------------------------------------------------------------- A10 + +it('laedt die Lieferungszahl der Kundenabo-Liste vor', function () { + $user = scUser(); + + for ($i = 0; $i < 5; $i++) { + $abo = UserAbo::create([ + 'user_id' => $user->id, + 'member_id' => $user->id, + 'shopping_user_id' => 0, + 'email' => 'a-'.uniqid('', true).'@example.test', + 'is_for' => 'ot', + 'payone_userid' => 0, + 'clearingtype' => 'cc', + 'amount' => 5000, + 'active' => true, + 'status' => 2, + 'abo_interval' => 5, + 'start_date' => '2026-08-01', + 'next_date' => '2026-10-01', + ]); + + UserAboOrder::create([ + 'user_abo_id' => $abo->id, + 'shopping_order_id' => 1, + 'status' => 2, + 'paid' => true, + ]); + } + + $relations = (new ReflectionClass(AboController::class))->getConstant('CUSTOMER_LIST_RELATIONS'); + + $abos = UserAbo::with($relations)->where('member_id', $user->id)->where('is_for', 'ot')->get(); + + DB::enableQueryLog(); + DB::flushQueryLog(); + + foreach ($abos as $abo) { + $abo->getCountPaidOrders(); + } + + $queries = count(DB::getQueryLog()); + DB::disableQueryLog(); + + expect($abos)->toHaveCount(5) + ->and($queries)->toBe(0); +}); + +// ---------------------------------------------------------------- A17 + +it('zaehlt am Monatsende nicht in die falsche Faelligkeitsgruppe', function () { + $service = app(AnnualFeeDueService::class); + $key = fn (string $due, string $now) => (new ReflectionMethod($service, 'groupKeyFor')) + ->invoke($service, Carbon::parse($due), Carbon::parse($now)); + + // Vom 31.01. aus endet "innerhalb eines Monats" am 28.02., nicht am 03.03. + expect($key('2026-03-02', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS) + ->and($key('2026-02-28', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_1_MONTH) + ->and($key('2026-03-31', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS) + ->and($key('2026-04-01', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_3_MONTHS); +}); diff --git a/tests/Unit/Services/BackofficeDashboardServiceTest.php b/tests/Unit/Services/BackofficeDashboardServiceTest.php index b241e343..d54f99c9 100644 --- a/tests/Unit/Services/BackofficeDashboardServiceTest.php +++ b/tests/Unit/Services/BackofficeDashboardServiceTest.php @@ -22,6 +22,7 @@ beforeEach(function () { Schema::dropIfExists('user_abos'); Schema::dropIfExists('payment_transactions'); Schema::dropIfExists('shopping_payments'); + Schema::dropIfExists('shopping_order_items'); Schema::dropIfExists('shopping_orders'); Schema::dropIfExists('users'); Schema::dropIfExists('user_levels'); @@ -104,6 +105,19 @@ beforeEach(function () { $table->softDeletes(); }); + // Positionsebene: nur fuer die Aufteilung der Abo-Punkte in wiederkehrende + // und einmalig dazubestellte (is_abo_addon) noetig. + Schema::create('shopping_order_items', function ($table) { + $table->increments('id'); + $table->unsignedInteger('shopping_order_id'); + $table->unsignedInteger('product_id')->nullable(); + $table->unsignedInteger('qty')->default(1); + $table->decimal('points', 10, 2)->nullable(); + $table->boolean('is_abo_addon')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + Schema::create('shopping_payments', function ($table) { $table->increments('id'); $table->unsignedInteger('shopping_order_id'); @@ -134,6 +148,8 @@ beforeEach(function () { $table->decimal('total_net', 13, 2)->nullable(); $table->unsignedTinyInteger('status')->nullable(); $table->unsignedTinyInteger('status_turnover')->nullable(); + // Ursprungsart einer Stornobuchung — gebraucht von den Eigen-/Shop-Praedikaten. + $table->unsignedTinyInteger('storno_origin_status')->nullable(); // Nur-KP-Kennzeichen (Neupartner-Einschreibungspunkte) — gebraucht von // SalesVolumePoints::nonPaylinePoints(). $table->unsignedTinyInteger('status_points')->nullable();