20 ab; der Knoten auf Tiefe 20 laedt seine Kinder also noch — * das ist Linie 21. */ public const COMMISSION_TREE_MAX_LINES = 21; private const MAX_DEPTH_SAFETY_LIMIT = 100; private const LEADER_LEVEL_NAME = 'Vertriebsleiter'; /** * Prozess-Memo der Baum-Traversierung: ein Dashboard-Request braucht die * Downline mehrfach (KPIs, Payline, Top 3, Prognose) — ohne Memo liefe die * BFS pro Aufruf neu. Der Service ist als Singleton registriert * ({@see AppServiceProvider}); pro Root-User wird einmal * traversiert, Batch-Läufe über viele User bleiben durch den Key getrennt. * * @var array>> */ private array $lineBucketsMemo = []; /** * Memo des Provisionsbaums je Root und Monat (das Monatsende entscheidet ueber * `active_date`), abgeleitet aus {@see $lineBucketsMemo}. * * @var array>> */ private array $commissionLineBucketsMemo = []; /** * @return string[] */ public static function trees(): array { return [self::TREE_COMMISSION, self::TREE_DOWNLINE]; } /** * @return array */ public static function treeLabels(): array { return [ self::TREE_COMMISSION => 'Provisionsbaum (wie Team-Struktur)', self::TREE_DOWNLINE => 'Gesamte Downline (inkl. nicht provisionsberechtigte Berater)', ]; } /** * Unbekannte oder leere Werte (alte Links, manipulierte Parameter) fallen auf * den Standard zurueck. */ public static function normalizeTree(?string $tree): string { return in_array($tree, self::trees(), true) ? $tree : self::TREE_COMMISSION; } /** * @return array */ public function metricLabels(): array { return [ 'consultants' => 'Berater', 'new_partners' => 'Neupartner', 'team_partner_abos' => 'Teamabos', 'team_customer_abos' => 'Teamkundenabos', 'own_points' => 'Eigenpunkte', 'external_points' => 'Externe Kundenpunkte', 'customer_abo_points' => 'Kundenabo-Punkte', 'customer_single_order_points' => 'Einzelbestellungs-Punkte', 'customer_other_points' => 'Sonstige Kundenpunkte', 'total_points' => 'Gesamtpunkte', 'shop_1000' => '1000 Punkte Shop', ]; } /** * @return array{month: int, year: int, metric_labels: array, lines: array>, totals: array} */ public function overview(User $user, int $month, int $year, string $tree = self::TREE_COMMISSION): array { $tree = self::normalizeTree($tree); if ($this->isClosedMonth($month, $year)) { $snapshot = BackofficeStatisticsSnapshot::query() ->where('user_id', $user->id) ->where('month', $month) ->where('year', $year) ->where('tree', $tree) ->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( $snapshot->payload, 'snapshot', $tree, $snapshot->calculated_at?->format('d.m.Y H:i') ); } } return $this->withMeta($this->buildOverview($user, $month, $year, $tree), 'live', $tree); } /** * Der Monatsstand immer frisch aus den heutigen Daten — auch fuer abgeschlossene * Monate (Mess-/Vergleichslaeufe, Snapshot-Aufbau). Die Seite selbst geht ueber * {@see overview()}, damit eingefrorene Monate eingefroren bleiben. * * @return array */ public function liveOverview(User $user, int $month, int $year, string $tree = self::TREE_COMMISSION): array { return $this->withMeta($this->buildOverview($user, $month, $year, self::normalizeTree($tree)), 'live', $tree); } /** * Baum-Memo verwerfen — nötig, wenn sich die Team-Struktur innerhalb eines * langlebigen Prozesses ändert (Batch-Kommandos, Tests). */ public function flushLineBucketsMemo(?int $rootUserId = null): void { if ($rootUserId === null) { $this->lineBucketsMemo = []; $this->commissionLineBucketsMemo = []; return; } unset($this->lineBucketsMemo[$rootUserId]); foreach (array_keys($this->commissionLineBucketsMemo) as $key) { if (str_starts_with($key, $rootUserId.':')) { unset($this->commissionLineBucketsMemo[$key]); } } } public function storeSnapshot(User $user, int $month, int $year, string $tree = self::TREE_COMMISSION): BackofficeStatisticsSnapshot { $tree = self::normalizeTree($tree); // Frisch traversieren: der Snapshot-Cron läuft lange und über viele User — // ein Memo aus früheren Aufrufen desselben Roots wäre potenziell veraltet. $this->flushLineBucketsMemo((int) $user->id); $payload = $this->buildOverview($user, $month, $year, $tree); return BackofficeStatisticsSnapshot::query()->updateOrCreate( [ 'user_id' => $user->id, 'year' => $year, 'month' => $month, 'tree' => $tree, ], [ 'payload' => $payload, 'calculated_at' => now(), ] ); } /** * @return array{month: int, year: int, metric_labels: array, lines: array>, totals: array} */ private function buildOverview(User $user, int $month, int $year, string $tree): array { $lineBuckets = $this->lineBucketsFor((int) $user->id, $tree, $month, $year); $recruiter = $this->recruiterRanking($user, $lineBuckets, $month, $year); $lines = []; $totals = $this->emptyLine(0, []); foreach ($lineBuckets as $line => $users) { $row = $this->buildLineRow($line, $users, $month, $year); $lines[$line] = $row; $totals = $this->addToTotals($totals, $row); } // 1000-Punkte-Shop: der VIP selbst zählt mit (Kundenwunsch), obwohl er in // keiner Linie steckt — nur diese Kennzahl bekommt ihn zusätzlich. $totals['shop_1000'] += $this->shop1000Count([(int) $user->id], $month, $year); $totals['label'] = 'Summe'; 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, 'tree' => $tree, 'tree_source' => ($tree === self::TREE_COMMISSION && $this->storedStructureFor($month, $year) !== null) ? 'stored_structure' : 'live', 'metric_labels' => $this->metricLabels(), 'lines' => $lines, 'totals' => $totals, 'non_payline_points' => $this->nonPaylinePointsSum($totals['user_ids'], $month, $year), 'storno_floor_points' => $totals['storno_floor_points'], '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' => $recruiter['rows'], // Eigener Payload-Schlüssel statt geänderter Listenform: ältere // Snapshots kennen nur recruiter_ranking und bleiben lesbar. 'recruiter_totals' => $recruiter['totals'], ]; } /** * @param array $overview * @return array */ private function withMeta(array $overview, string $source, string $tree, ?string $calculatedAt = null): array { $overview['_meta'] = [ 'source' => $source, 'source_label' => $source === 'snapshot' ? 'Snapshot' : 'Live', 'calculated_at' => $calculatedAt, 'tree' => $tree, 'tree_label' => self::treeLabels()[$tree] ?? $tree, ]; return $overview; } private function isClosedMonth(int $month, int $year): bool { return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth()); } /** * @return array> */ public function lineBuckets(int $rootUserId): array { if (isset($this->lineBucketsMemo[$rootUserId])) { return $this->lineBucketsMemo[$rootUserId]; } $lineBuckets = []; $currentSponsorIds = [$rootUserId]; $visitedUserIds = [$rootUserId]; for ($line = 1; $line <= self::MAX_DEPTH_SAFETY_LIMIT && $currentSponsorIds !== []; $line++) { $users = User::query() ->with(['account', 'user_level']) ->whereIn('m_sponsor', $currentSponsorIds) ->whereNotIn('id', $visitedUserIds) ->whereColumn('id', '!=', 'm_sponsor') ->whereNull('deleted_at') ->get(); if ($users->isEmpty()) { break; } $lineBuckets[$line] = $users; $currentSponsorIds = $users->pluck('id')->all(); $visitedUserIds = array_merge($visitedUserIds, $currentSponsorIds); } return $this->lineBucketsMemo[$rootUserId] = $lineBuckets; } /** * Die Linien des Provisionsbaums: {@see lineBuckets()} gefiltert und gekappt * nach denselben Regeln, mit denen der TreeCalcBot seine Struktur laedt * ({@see BusinessUserItemOptimized::readParentsBusinessUsers()}). Ein Knoten, * der die Bedingungen nicht erfuellt, faellt samt seinem GESAMTEN Unterbaum * heraus — genau wie in der Provisionsberechnung, wo unter einem * ausgefilterten Sponsor nicht weitergelesen wird. * * Die BFS-Reihenfolge von lineBuckets() garantiert, dass der Sponsor immer * schon entschieden ist, wenn sein Teampartner drankommt. * * @return array> */ public function commissionLineBuckets(int $rootUserId, int $month, int $year): array { $memoKey = $rootUserId.':'.$year.'-'.$month; if (isset($this->commissionLineBucketsMemo[$memoKey])) { return $this->commissionLineBucketsMemo[$memoKey]; } // Abgeschlossener Monat mit gespeichertem Provisionslauf: die damals // verprovisionierte Struktur ist massgeblich, nicht der heutige Baum. Sonst // laufen Statistik und Struktur wieder auseinander, sobald sich seit dem // Monatsabschluss ein Sponsor geaendert hat oder ein Berater reaktiviert wurde. $storedStructure = $this->storedStructureFor($month, $year); if ($storedStructure !== null) { return $this->commissionLineBucketsMemo[$memoKey] = $this->storedCommissionLineBuckets($rootUserId, $storedStructure); } $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); $buckets = []; $pruned = []; foreach ($this->lineBuckets($rootUserId) as $line => $users) { if ($line > self::COMMISSION_TREE_MAX_LINES) { break; } $kept = $users->filter(function (User $member) use (&$pruned, $endOfMonth): bool { if (isset($pruned[(int) $member->m_sponsor]) || ! self::countsInCommissionTree($member, $endOfMonth)) { $pruned[(int) $member->id] = true; return false; } return true; })->values(); if ($kept->isEmpty()) { // Ohne Mitglieder auf dieser Linie kann es auch darunter keine geben. break; } $buckets[$line] = $kept; } return $this->commissionLineBucketsMemo[$memoKey] = $buckets; } /** * Der abgeschlossene Provisionslauf eines Monats — nur fuer abgeschlossene * Monate: im laufenden Monat rechnet die Statistik live, auch wenn der * Nachtlauf schon eine Struktur gespeichert hat (Buchungen des Tages sollen * sofort sichtbar sein; die Struktur-Seite holt sie am Folgetag nach). */ public function storedStructureFor(int $month, int $year): ?UserBusinessStructure { if (! $this->isClosedMonth($month, $year)) { return null; } return UserBusinessStructure::query() ->where('year', $year) ->where('month', $month) ->where('completed', true) ->first(); } /** * Linien aus der gespeicherten Struktur: `user_businesses` traegt je Mitglied * des Provisionsbaums den Sponsor zum Zeitpunkt des Laufs; wer damals nicht im * Baum stand, hat keine Zeile. Ein BFS ueber diese Zeilen ergibt exakt die * Struktur, mit der der Monat verprovisioniert wurde. * * @return array> */ private function storedCommissionLineBuckets(int $rootUserId, UserBusinessStructure $structure): array { $childrenBySponsor = []; UserBusiness::query() ->where('month', $structure->month) ->where('year', $structure->year) ->where('b_structure_id', $structure->id) ->orderBy('user_id') ->get(['user_id', 'sponsor']) ->each(function (UserBusiness $row) use (&$childrenBySponsor): void { $sponsorId = (int) ($row->sponsor->user_id ?? 0); if ($sponsorId > 0 && $sponsorId !== (int) $row->user_id) { $childrenBySponsor[$sponsorId][] = (int) $row->user_id; } }); $idsByLine = []; $visited = [$rootUserId => true]; $currentIds = [$rootUserId]; for ($line = 1; $line <= self::COMMISSION_TREE_MAX_LINES && $currentIds !== []; $line++) { $nextIds = []; foreach ($currentIds as $parentId) { foreach ($childrenBySponsor[$parentId] ?? [] as $childId) { if (isset($visited[$childId])) { continue; } $visited[$childId] = true; $nextIds[] = $childId; } } if ($nextIds === []) { break; } $idsByLine[$line] = $nextIds; $currentIds = $nextIds; } if ($idsByLine === []) { return []; } $users = User::query() ->with(['account', 'user_level']) ->whereIn('id', array_merge(...array_values($idsByLine))) ->whereNull('deleted_at') ->get() ->keyBy('id'); $buckets = []; foreach ($idsByLine as $line => $ids) { $buckets[$line] = collect($ids) ->map(fn (int $id) => $users->get($id)) ->filter() ->values(); } return $buckets; } /** * Dieselben Bedingungen, mit denen der TreeCalcBot seine Struktur laedt * ({@see BusinessUserItemOptimized::readParentsBusinessUsers()}): kein * System-/Adminkonto, Mitgliedsebene und Abrechnungskonto gesetzt (das * Ablaufdatum spielt dort KEINE Rolle), bis zum Monatsende aktiv geworden. * Geloeschte User sind schon in lineBuckets() ausgeschlossen. */ public static function countsInCommissionTree(User $member, Carbon $endOfMonth): bool { if ((int) $member->id === 1 || $member->admin === null || (int) $member->admin >= 4) { return false; } if ($member->m_level === null || $member->payment_account === null || $member->active_date === null) { return false; } return Carbon::parse($member->active_date)->lte($endOfMonth); } /** * @return array> */ public function lineBucketsFor(int $rootUserId, string $tree, int $month, int $year): array { return self::normalizeTree($tree) === self::TREE_DOWNLINE ? $this->lineBuckets($rootUserId) : $this->commissionLineBuckets($rootUserId, $month, $year); } /** * Mitglieder einer Linie in der gewaehlten Baum-Definition; Linie 0 = das ganze Team. * * @return int[] */ public function lineUserIds(int $rootUserId, int $line, int $month, int $year, string $tree = self::TREE_COMMISSION): array { if ($line < 0) { return []; } $buckets = $this->lineBucketsFor($rootUserId, $tree, $month, $year); if ($line === 0) { return collect($buckets) ->flatMap(fn (Collection $users) => $users->pluck('id')) ->map(fn ($id) => (int) $id) ->values() ->all(); } return ($buckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all(); } /** * @return int[] */ public function teamUserIds(int $rootUserId): array { return collect($this->lineBuckets($rootUserId)) ->flatMap(fn (Collection $users) => $users->pluck('id')) ->map(fn ($id) => (int) $id) ->values() ->all(); } /** * IDs der gesamten Downline, aber nur aktive Berater * (gleiche Definition wie die "Berater"-Spalte der Backoffice-Statistik). * * @return int[] */ public function activeTeamUserIds(int $rootUserId): array { return collect($this->lineBuckets($rootUserId)) ->flatMap(fn (Collection $users) => $users) ->filter(fn (User $user) => $this->isActiveConsultant($user)) ->map(fn (User $user) => (int) $user->id) ->values() ->all(); } /** * Ein aktiver Berater hat eine Mitgliedsebene und ein gesetztes Abrechnungskonto. * Maßgeblich für die "Berater"-Zählung in den Team-Übersichten. */ public function isActiveConsultant(User $user): bool { return $user->m_level !== null && $user->payment_account !== null; } /** * @param Collection $users * @return array */ private function buildLineRow(int $line, Collection $users, int $month, int $year): array { $userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all(); if ($userIds === []) { return $this->emptyLine($line, $userIds); } $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' => 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'], 'customer_single_order_points' => $salesSummary['customer_single_order_points'], 'customer_other_points' => $salesSummary['customer_other_points'], 'total_points' => $salesSummary['total_points'], 'storno_floor_points' => $salesSummary['storno_floor_points'], 'turnover_net' => $salesSummary['turnover_net'], 'shop_1000' => $this->shop1000Count($userIds, $month, $year), ]; } /** * @return array */ private function emptyLine(int $line, array $userIds): array { return [ 'line' => $line, 'label' => $line > 0 ? 'Linie '.$line : 'Summe', 'user_ids' => $userIds, 'consultants' => 0, 'new_partners' => 0, 'team_partner_abos' => 0, 'team_partner_abos_new' => 0, 'team_customer_abos' => 0, 'team_customer_abos_new' => 0, 'own_points' => 0.0, 'external_points' => 0.0, 'customer_abo_points' => 0.0, 'customer_single_order_points' => 0.0, 'customer_other_points' => 0.0, 'total_points' => 0.0, 'storno_floor_points' => 0.0, 'turnover_net' => 0.0, 'shop_1000' => 0, ]; } /** * @param array $totals * @param array $row * @return array */ private function addToTotals(array $totals, array $row): array { foreach (array_keys($this->metricLabels()) as $metric) { $totals[$metric] += $row[$metric]; } $totals['turnover_net'] += $row['turnover_net']; $totals['storno_floor_points'] += $row['storno_floor_points'] ?? 0.0; $totals['team_partner_abos_new'] += $row['team_partner_abos_new']; $totals['team_customer_abos_new'] += $row['team_customer_abos_new']; $totals['user_ids'] = array_merge($totals['user_ids'], $row['user_ids']); return $totals; } /** * @param Collection $users */ private function activeConsultants(Collection $users): int { return $users ->filter(fn (User $user) => $this->isActiveConsultant($user)) ->count(); } /** * @param Collection $users */ private function newPartners(Collection $users, int $month, int $year): int { return $users ->filter(fn (User $user) => $user->isNewPartnerIn($month, $year)) ->count(); } private function activeAboQuery(): Builder { return UserAbo::query() ->where('active', true) ->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 { return count($this->aboIds($userIds, $userColumn, $isFor, $month, $year)); } /** * @param int[] $userIds * Die Kennzahl `total_points` („Umsatz (Punkte)") sind die Provisionspunkte mit * der Knotenregel des Provisionsbaums ({@see SalesVolumePoints::floorPerUser()}) — * dieselbe Zahl wie die Linien-Punkte der Team-Struktur. Die Aufteilung * (Eigen-/Kundenpunkte) bleibt die reine Zeilensumme; `storno_floor_points` * weist aus, was die Knotenregel nicht abgezogen hat. * @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, storno_floor_points: float, turnover_net: float} */ private function salesSummary(array $userIds, int $month, int $year): array { $summary = UserSalesVolume::query() ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') ->whereIn('user_id', $userIds) ->where('month', $month) ->where('year', $year) ->selectRaw(SalesVolumePoints::ownPoints().' as own_points') ->selectRaw(SalesVolumePoints::externalPoints().' as external_points') ->selectRaw(SalesVolumePoints::customerAboPoints().' as customer_abo_points') ->selectRaw(SalesVolumePoints::customerSingleOrderPoints().' as customer_single_order_points') ->selectRaw(SalesVolumePoints::customerOtherPoints().' as customer_other_points') ->selectRaw(SalesVolumePoints::turnoverNet().' as turnover_net') ->first(); $commission = SalesVolumePoints::floorPerUser(SalesVolumePoints::commissionPointsByUser($userIds, $month, $year)); return [ 'own_points' => (float) ($summary->own_points ?? 0), 'external_points' => (float) ($summary->external_points ?? 0), 'customer_abo_points' => (float) ($summary->customer_abo_points ?? 0), 'customer_single_order_points' => (float) ($summary->customer_single_order_points ?? 0), 'customer_other_points' => (float) ($summary->customer_other_points ?? 0), 'total_points' => $commission['points'], 'storno_floor_points' => $commission['storno_floor'], 'turnover_net' => (float) ($summary->turnover_net ?? 0), ]; } /** * Summe der Punkte des Teams, die nicht in die Payline fließen (nur-KP-Einträge * wie Neupartner-Einschreibungspunkte). Seit die Kennzahl „Umsatz (Punkte)" die * Provisionspunkte zeigt, sind sie dort NICHT mehr enthalten — die Zahl erklaert * die Differenz zu den Eigenpunkten der Aufteilung. * * @param int[] $userIds */ private function nonPaylinePointsSum(array $userIds, int $month, int $year): float { if ($userIds === []) { return 0.0; } return (float) UserSalesVolume::query() ->whereIn('user_id', $userIds) ->where('month', $month) ->where('year', $year) ->selectRaw(SalesVolumePoints::nonPaylinePoints().' as points') ->value('points'); } /** * Anzahl der Neupartner (aktiver Account, im Monat aktiv geworden). * Konsistent mit der Drilldown-Liste {@see BackofficeDrilldownService::newPartnerRows()}. * * @param int[] $userIds */ public function newPartnersCount(array $userIds, int $month, int $year): int { if ($userIds === []) { return 0; } return User::query() ->whereIn('id', $userIds) ->newPartnerIn($month, $year) ->count(); } /** * Ranking: Wer im Team hat im Monat wie viele neue Kundenabos abgeschlossen? * Sortiert nach Anzahl absteigend (nicht nach Linie), Linie wird je Berater mitgeliefert, * dazu der aktuelle Gesamtbestand an Kundenabos des Beraters. * Der Statistik-Nutzer selbst zählt mit (Kundenwunsch, Linie 0 / "ich"). * Zählweise identisch zu team_customer_abos_new {@see newAboCount()}. * * @param array> $lineBuckets * @return array */ private function newCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array { $usersByLine = $this->usersWithLine($lineBuckets, $rootUser); if ($usersByLine === []) { return []; } $startDate = Carbon::create($year, $month, 1)->startOfMonth(); $endDate = Carbon::create($year, $month, 1)->endOfMonth(); $counts = $this->activeAboQuery() ->whereIn('member_id', array_keys($usersByLine)) ->where('is_for', 'ot') ->whereBetween('start_date', [$startDate, $endDate]) ->selectRaw('member_id, COUNT(*) as abo_count') ->groupBy('member_id') ->pluck('abo_count', 'member_id'); $totalCounts = $this->customerAboCounts($counts->keys()->all()); return collect($usersByLine) ->filter(fn (array $entry, int $userId) => $counts->has($userId)) ->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) $counts->get($userId), 'total_abo_count' => (int) $totalCounts->get($userId, 0), ]) ->sortByDesc('abo_count') ->values() ->all(); } /** * Abos der Führungskräfte (ab Stufe Vertriebsleiter) im Team: Gesamtzahlen plus * Liste aller Führungskräfte inklusive Linie, eigener Abos, Kundenabos sowie * Team-Abos und Team-Kundenabos ihres Teilbaums und der Gesamtsumme aller * vier Kennzahlen (Kundenwunsch B7). * * Zwei Team-Sichten je Führungskraft (Kundenentscheid 2026-07-24, revidiert F9): * - team_abo_count / team_customer_abo_count ("direkt"): Teilbaum wird an der * nächsten Führungskraft abgeschnitten — überlappungsfrei, die Kopf-Summen * (total_*) bleiben dadurch echte Gesamtwerte ohne Doppelzählung. * - team_abo_count_total / team_customer_abo_count_total ("gesamt"): komplette * Downline der Führungskraft; verschachtelte Führungskräfte zählen hier * mehrfach, deshalb werden diese Spalten nicht aufsummiert. * total_abo_count_full = eigene + Kundenabos + Team gesamt (Sortierung). * * @param array> $lineBuckets * @return array{level_name: string, total: int, total_own: int, total_customer: int, total_team: int, total_team_customer: int, rows: array} */ private function leaderAboOverview(array $lineBuckets): array { $leaderMinPos = UserLevel::query() ->where('name', self::LEADER_LEVEL_NAME) ->value('pos'); $usersWithLine = $this->usersWithLine($lineBuckets); $isLeader = fn (User $user): bool => $leaderMinPos !== null && ($user->user_level?->pos ?? 0) >= $leaderMinPos; $leadersByLine = $leaderMinPos === null ? [] : collect($usersWithLine) ->filter(fn (array $entry) => $isLeader($entry['user'])) ->all(); if ($leadersByLine === []) { return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'total_team' => 0, 'total_team_customer' => 0, 'rows' => []]; } $leaderIds = array_keys($leadersByLine); $teamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, $isLeader); $fullTeamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, fn (User $user): bool => false); $allCountIds = array_values(array_unique(array_merge($leaderIds, ...array_values($fullTeamIdsByLeader)))); $ownCounts = $this->activeAboQuery() ->whereIn('user_id', $allCountIds) ->where('is_for', 'me') ->selectRaw('user_id, COUNT(*) as abo_count') ->groupBy('user_id') ->pluck('abo_count', 'user_id'); $customerCounts = $this->customerAboCounts($allCountIds); $rows = collect($leadersByLine) ->map(function (array $entry, int $userId) use ($ownCounts, $customerCounts, $teamIdsByLeader, $fullTeamIdsByLeader) { $teamIds = $teamIdsByLeader[$userId] ?? []; $fullTeamIds = $fullTeamIdsByLeader[$userId] ?? []; $own = (int) $ownCounts->get($userId, 0); $customer = (int) $customerCounts->get($userId, 0); $team = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0)); $teamCustomer = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0)); $teamTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0)); $teamCustomerTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0)); return [ 'user_id' => $userId, 'name' => $this->userName($entry['user']), 'career_level' => $this->careerLevel($entry['user']), 'line' => $entry['line'], 'own_abo_count' => $own, 'customer_abo_count' => $customer, 'team_abo_count' => $team, 'team_customer_abo_count' => $teamCustomer, 'team_abo_count_total' => $teamTotal, 'team_customer_abo_count_total' => $teamCustomerTotal, 'total_abo_count' => $own + $customer + $team + $teamCustomer, 'total_abo_count_full' => $own + $customer + $teamTotal + $teamCustomerTotal, ]; }) ->sortByDesc('total_abo_count_full') ->values(); $totalOwn = (int) $rows->sum('own_abo_count'); $totalCustomer = (int) $rows->sum('customer_abo_count'); $totalTeam = (int) $rows->sum('team_abo_count'); $totalTeamCustomer = (int) $rows->sum('team_customer_abo_count'); return [ 'level_name' => self::LEADER_LEVEL_NAME, 'total' => $totalOwn + $totalCustomer + $totalTeam + $totalTeamCustomer, 'total_own' => $totalOwn, 'total_customer' => $totalCustomer, 'total_team' => $totalTeam, 'total_team_customer' => $totalTeamCustomer, 'rows' => $rows->all(), ]; } /** * Teilbaum-User-IDs je Führungskraft aus der gemeinsamen Sponsor-Map * (ein Durchlauf über die bereits geladene Downline, keine Abfragen pro * Führungskraft). Der Abstieg stoppt an jedem Kind, für das $isLeader true * liefert (dieses und sein Team werden übersprungen); mit einem immer-false * Callable ergibt sich die komplette Downline. * * @param array $usersWithLine * @param int[] $leaderIds * @return array */ private function teamIdsPerLeader(array $usersWithLine, array $leaderIds, callable $isLeader): array { $childrenBySponsor = []; foreach ($usersWithLine as $entry) { $childrenBySponsor[(int) $entry['user']->m_sponsor][] = $entry['user']; } $teamIdsByLeader = []; foreach ($leaderIds as $leaderId) { $teamIds = []; $queue = $childrenBySponsor[$leaderId] ?? []; while ($queue !== []) { $child = array_shift($queue); if ($isLeader($child)) { continue; } $teamIds[] = (int) $child->id; foreach ($childrenBySponsor[(int) $child->id] ?? [] as $grandChild) { $queue[] = $grandChild; } } $teamIdsByLeader[$leaderId] = $teamIds; } return $teamIdsByLeader; } /** * Top-10-Ranglisten der Kundenabos im eigenen Team (Kundenwunsch B5/B6, * "unabhängig vom Incentive" = aktueller Gesamtbestand aktiver Kundenabos): * * - by_count: nach Anzahl aktiver Kundenabos je Berater * - by_points: nach Umsatz mit Ist (im gewählten Monat abgerechnete * Kundenabo-Punkte aus user_sales_volumes, Hauptwert und Sortierung — * Kundenentscheid 2026-07-24) und Soll (Abo-Punkte-Bestand, Produktpunkte * × Menge der laufenden Kundenabos) als Zweitwert * * Der Statistik-Nutzer selbst zählt in beiden Listen mit (Linie 0 / "ich"). * * @param array> $lineBuckets * @return array{by_count: array>, by_points: array>} */ private function topCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array { $usersWithLine = $this->usersWithLine($lineBuckets, $rootUser); if ($usersWithLine === []) { return ['by_count' => [], 'by_points' => []]; } $memberIds = array_keys($usersWithLine); $aboCounts = $this->customerAboCounts($memberIds); $targetPoints = $this->customerAboTargetPoints($memberIds); $actualPoints = $this->customerAboActualPoints($memberIds, $month, $year); $addonPoints = $this->customerAboAddonPoints($memberIds, $month, $year); $rows = collect($usersWithLine) ->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 ->filter(fn (array $row) => $row['abo_count'] > 0) ->sortByDesc('abo_count') ->take(10) ->values() ->all(), 'by_points' => $rows ->filter(fn (array $row) => $row['points_target'] > 0 || $row['points_actual'] > 0) ->sortByDesc('points_actual') ->take(10) ->values() ->all(), ]; } /** * Soll-Punkte je Berater: Abo-Punkte-Bestand der aktiven Kundenabos * (Produktpunkte × Menge, nur Hauptartikel comp = 0 — gleiche Logik wie * {@see UserAbo::getTotalPoints()}). * * @param int[] $memberIds * @return Collection */ private function customerAboTargetPoints(array $memberIds): Collection { return UserAbo::query() ->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id') ->join('products', 'products.id', '=', 'user_abo_items.product_id') ->whereNull('products.deleted_at') ->where('user_abos.active', true) ->whereNotIn('user_abos.status', [4, 5, 6]) ->whereIn('user_abos.member_id', $memberIds) ->where('user_abos.is_for', 'ot') ->where('user_abo_items.comp', 0) ->selectRaw('user_abos.member_id, COALESCE(SUM(products.points * user_abo_items.qty), 0) as points') ->groupBy('user_abos.member_id') ->pluck('points', 'member_id') ->map(fn ($points) => (float) $points); } /** * Ist-Punkte je Berater: im Monat tatsächlich abgerechnete Kundenabo-Punkte. * * @param int[] $memberIds * @return Collection */ private function customerAboActualPoints(array $memberIds, int $month, int $year): Collection { return UserSalesVolume::query() ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') ->whereIn('user_id', $memberIds) ->where('month', $month) ->where('year', $year) ->select('user_sales_volumes.user_id') ->selectRaw(SalesVolumePoints::customerAboPoints().' as points') ->groupBy('user_sales_volumes.user_id') ->pluck('points', 'user_id') ->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? * * Neupartner-Definition identisch zur Neupartner-KPI {@see newPartnersCount()}: * m_level gesetzt, aktiver Account (payment_account in der Zukunft), * active_date im Monat — Zuordnung über users.m_sponsor. * * "Neues Volumen" je Sponsor = Summe der Monats-Gesamtpunkte (eigene + * Kundenpunkte, {@see SalesVolumePoints::totalPoints()} — das Starterpaket * mit Status 1 zählt damit mit) aller seiner Neupartner, plus rekursiv die * Gesamtpunkte der Neupartner, die diese Neupartner im selben Monat selbst * geworben haben (Kettenlogik). Die Rekursion läuft rein im Speicher über * die bereits geladene Neupartner-Menge und ist zyklen-sicher. * * 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{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 ['rows' => [], 'totals' => $emptyTotals]; } $newPartnersBySponsor = []; foreach ($usersByLine as $entry) { if ($entry['line'] === 0) { continue; } $user = $entry['user']; if (! $user->isNewPartnerIn($month, $year)) { continue; } $newPartnersBySponsor[(int) $user->m_sponsor][] = $user; } if ($newPartnersBySponsor === []) { 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; foreach ($newPartnersBySponsor[$sponsorId] ?? [] as $partner) { $partnerId = (int) $partner->id; if (isset($visited[$partnerId])) { continue; } $visited[$partnerId] = true; $volume += (float) $pointsByUser->get($partnerId, 0.0) + $chainVolume($partnerId, $visited); } return $volume; }; $rows = []; foreach ($usersByLine as $sponsorId => $entry) { $directPartners = $newPartnersBySponsor[$sponsorId] ?? []; if ($directPartners === []) { continue; } $partners = []; foreach ($directPartners as $partner) { $partnerId = (int) $partner->id; $children = []; foreach ($newPartnersBySponsor[$partnerId] ?? [] as $child) { if ((int) $child->id === (int) $sponsorId) { continue; } $children[] = [ 'user_id' => (int) $child->id, 'name' => $this->userName($child), 'career_level' => $this->careerLevel($child), 'total_points' => (float) $pointsByUser->get((int) $child->id, 0.0), ]; } $partners[] = [ 'user_id' => $partnerId, 'name' => $this->userName($partner), 'career_level' => $this->careerLevel($partner), 'total_points' => (float) $pointsByUser->get($partnerId, 0.0), 'new_partner_count' => count($children), 'children' => $children, ]; } $rows[] = [ 'user_id' => (int) $sponsorId, 'name' => $this->userName($entry['user']), 'career_level' => $this->careerLevel($entry['user']), 'line' => $entry['line'], 'is_self' => $entry['line'] === 0, 'new_partner_count' => count($directPartners), 'volume_points' => $chainVolume((int) $sponsorId, [(int) $sponsorId => true]), 'partners' => $partners, ]; } return [ 'rows' => collect($rows) ->sortBy([['new_partner_count', 'desc'], ['volume_points', 'desc']]) ->values() ->all(), 'totals' => $totals, ]; } /** * Monats-Gesamtpunkte (eigene + Kundenpunkte, inkl. Starterpaket Status 1) * je User — eine gebatchte Query für alle übergebenen IDs. * * @param int[] $userIds * @return Collection */ private function totalPointsByUser(array $userIds, int $month, int $year): Collection { return SalesVolumePoints::totalPointsByUser($userIds, $month, $year); } /** * Aktueller Bestand aktiver Kundenabos je Berater (member_id). * * @param int[] $memberIds * @return Collection */ private function customerAboCounts(array $memberIds): Collection { if ($memberIds === []) { return collect(); } return $this->activeAboQuery() ->whereIn('member_id', $memberIds) ->where('is_for', 'ot') ->selectRaw('member_id, COUNT(*) as abo_count') ->groupBy('member_id') ->pluck('abo_count', 'member_id'); } /** * Downline-User mit ihrer Linie; optional inklusive des Statistik-Nutzers * selbst als Linie 0 (er steckt in keinem lineBucket, {@see lineBuckets()}). * * @param array> $lineBuckets * @return array */ private function usersWithLine(array $lineBuckets, ?User $rootUser = null): array { $usersByLine = []; if ($rootUser !== null) { $usersByLine[(int) $rootUser->id] = ['user' => $rootUser, 'line' => 0]; } foreach ($lineBuckets as $line => $users) { foreach ($users as $user) { $usersByLine[(int) $user->id] = ['user' => $user, 'line' => $line]; } } return $usersByLine; } private function userName(User $user): string { $name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? '')); 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?->getLang('name') ?: ($user->m_level ? 'Level '.$user->m_level : '-'); } /** * Anzahl der Berater mit >= 1000 Punkten Kundenumsatz (KP + Shop) im Monat. * * @param int[] $userIds */ public function shop1000Count(array $userIds, int $month, int $year): int { return UserSalesVolume::query() ->whereIn('user_id', $userIds) ->where('month', $month) ->where('year', $year) ->select('user_id') ->selectRaw(SalesVolumePoints::totalPoints().' as total_points') ->groupBy('user_id') ->havingRaw(SalesVolumePoints::totalPoints().' >= 1000') ->get() ->count(); } }