*/ 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): array { if ($this->isClosedMonth($month, $year)) { $snapshot = BackofficeStatisticsSnapshot::query() ->where('user_id', $user->id) ->where('month', $month) ->where('year', $year) ->first(); if ($snapshot) { return $this->withMeta($snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i')); } } return $this->withMeta($this->buildOverview($user, $month, $year), 'live'); } public function storeSnapshot(User $user, int $month, int $year): BackofficeStatisticsSnapshot { $payload = $this->buildOverview($user, $month, $year); return BackofficeStatisticsSnapshot::query()->updateOrCreate( [ 'user_id' => $user->id, 'year' => $year, 'month' => $month, ], [ '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): array { $lineBuckets = $this->lineBuckets($user->id); $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); } $totals['label'] = 'Summe'; return [ 'month' => $month, 'year' => $year, 'metric_labels' => $this->metricLabels(), 'lines' => $lines, 'totals' => $totals, 'new_customer_abos' => $this->newCustomerAboRanking($lineBuckets, $month, $year), 'leader_abos' => $this->leaderAboOverview($lineBuckets), ]; } /** * @param array $overview * @return array */ private function withMeta(array $overview, string $source, ?string $calculatedAt = null): array { $overview['_meta'] = [ 'source' => $source, 'source_label' => $source === 'snapshot' ? 'Snapshot' : 'Live', 'calculated_at' => $calculatedAt, ]; 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 { $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 $lineBuckets; } /** * @return int[] */ public function lineUserIds(int $rootUserId, int $line): array { if ($line === 0) { return $this->teamUserIds($rootUserId); } if ($line < 1) { return []; } return ($this->lineBuckets($rootUserId)[$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 \Illuminate\Support\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); 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), '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'], '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, '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['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 \Illuminate\Support\Collection $users */ private function activeConsultants(Collection $users): int { return $users ->filter(fn (User $user) => $this->isActiveConsultant($user)) ->count(); } /** * @param \Illuminate\Support\Collection $users */ private function newPartners(Collection $users, int $month, int $year): int { $startDate = Carbon::create($year, $month, 1)->startOfMonth(); $endDate = Carbon::create($year, $month, 1)->endOfMonth(); return $users ->filter(function (User $user) use ($startDate, $endDate): bool { if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) { return false; } $activeDate = Carbon::parse($user->active_date); return $activeDate->betweenIncluded($startDate, $endDate); }) ->count(); } private function hasActivePaymentAccount(User $user): bool { return $user->payment_account !== null && Carbon::parse($user->payment_account)->isFuture(); } private function activeAboQuery(): Builder { return UserAbo::query() ->where('active', true) ->whereNotIn('status', [4, 5, 6]); } /** * @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(); } /** * @param int[] $userIds * @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_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(); $ownPoints = (float) ($summary->own_points ?? 0); $externalPoints = (float) ($summary->external_points ?? 0); return [ 'own_points' => $ownPoints, 'external_points' => $externalPoints, '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' => $ownPoints + $externalPoints, 'turnover_net' => (float) ($summary->turnover_net ?? 0), ]; } /** * 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; } $startDate = Carbon::create($year, $month, 1)->startOfMonth(); $endDate = Carbon::create($year, $month, 1)->endOfMonth(); return User::query() ->whereIn('id', $userIds) ->whereNotNull('m_level') ->whereNotNull('payment_account') ->where('payment_account', '>', now()) ->whereBetween('active_date', [$startDate, $endDate]) ->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. * Zählweise identisch zu team_customer_abos_new {@see newAboCount()}. * * @param array> $lineBuckets * @return array */ private function newCustomerAboRanking(array $lineBuckets, int $month, int $year): array { $usersByLine = $this->usersWithLine($lineBuckets); 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'], '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 und Kundenabos. * * @param array> $lineBuckets * @return array{level_name: string, total: int, total_own: int, total_customer: int, rows: array} */ private function leaderAboOverview(array $lineBuckets): array { $leaderMinPos = UserLevel::query() ->where('name', self::LEADER_LEVEL_NAME) ->value('pos'); $leadersByLine = $leaderMinPos === null ? [] : collect($this->usersWithLine($lineBuckets)) ->filter(fn (array $entry) => ($entry['user']->user_level?->pos ?? 0) >= $leaderMinPos) ->all(); if ($leadersByLine === []) { return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'rows' => []]; } $leaderIds = array_keys($leadersByLine); $ownCounts = $this->activeAboQuery() ->whereIn('user_id', $leaderIds) ->where('is_for', 'me') ->selectRaw('user_id, COUNT(*) as abo_count') ->groupBy('user_id') ->pluck('abo_count', 'user_id'); $customerCounts = $this->customerAboCounts($leaderIds); $rows = collect($leadersByLine) ->map(fn (array $entry, int $userId) => [ 'user_id' => $userId, 'name' => $this->userName($entry['user']), 'career_level' => $this->careerLevel($entry['user']), 'line' => $entry['line'], 'own_abo_count' => (int) $ownCounts->get($userId, 0), 'customer_abo_count' => (int) $customerCounts->get($userId, 0), ]) ->sortByDesc(fn (array $row) => $row['own_abo_count'] + $row['customer_abo_count']) ->values(); $totalOwn = (int) $rows->sum('own_abo_count'); $totalCustomer = (int) $rows->sum('customer_abo_count'); return [ 'level_name' => self::LEADER_LEVEL_NAME, 'total' => $totalOwn + $totalCustomer, 'total_own' => $totalOwn, 'total_customer' => $totalCustomer, 'rows' => $rows->all(), ]; } /** * Aktueller Bestand aktiver Kundenabos je Berater (member_id). * * @param int[] $memberIds * @return \Illuminate\Support\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'); } /** * @param array> $lineBuckets * @return array */ private function usersWithLine(array $lineBuckets): array { $usersByLine = []; 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); } private function careerLevel(User $user): string { return $user->user_level?->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(); } }