>, * totals: array * } */ public function linesOverview(User $user, int $month, int $year): array { $lineBuckets = $this->dashboardService->lineBuckets($user->id); $lines = []; $totals = $this->emptyLine(0); $totals['label'] = __('abo.team_total'); foreach ($lineBuckets as $line => $users) { $row = $this->buildLineRow((int) $line, $users, $month, $year); $lines[$line] = $row; $totals = $this->addToTotals($totals, $row); } return [ 'month' => $month, 'year' => $year, 'lines' => $lines, 'totals' => $totals, ]; } /** * Leichtgewichtige Kennzahlen für das Dashboard (ohne Punkte-/Mitglieder-Details). * * Abo-Bestände sind monatsbezogen (Kundenwunsch 2026-08-19, N2): im laufenden * Monat der Live-Bestand, für vergangene Monate der Bestand zum Monatsende * ({@see aboStockCount()}); die VIP-Statistik bleibt bewusst tagesaktuell (F7/F3). * * @return array{team_total_abos: int, team_partner_abos: int, team_partner_abos_new: int, team_customer_abos: int, team_customer_abos_new: int, shop_1000: int, new_partners: int} */ public function teamKpis(User $user, int $month, int $year): array { $teamUserIds = $this->dashboardService->activeTeamUserIds($user->id); // 1000-Punkte-Shop: der Berater selbst zählt mit (Kundenwunsch), alle // übrigen Team-Kennzahlen bleiben ohne den Betrachter. $shop1000UserIds = array_merge([(int) $user->id], $teamUserIds); if ($teamUserIds === []) { return [ 'team_total_abos' => 0, 'team_partner_abos' => 0, 'team_partner_abos_new' => 0, 'team_customer_abos' => 0, 'team_customer_abos_new' => 0, 'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year), 'new_partners' => 0, ]; } $partnerAbos = $this->aboStockCount($teamUserIds, 'user_id', 'me', $month, $year); $customerAbos = $this->aboStockCount($teamUserIds, 'member_id', 'ot', $month, $year); return [ 'team_total_abos' => $partnerAbos + $customerAbos, 'team_partner_abos' => $partnerAbos, 'team_partner_abos_new' => $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year), 'team_customer_abos' => $customerAbos, 'team_customer_abos_new' => $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year), 'shop_1000' => $this->dashboardService->shop1000Count($shop1000UserIds, $month, $year), 'new_partners' => $this->dashboardService->newPartnersCount($teamUserIds, $month, $year), ]; } /** * Persönliche Abo-Kennzahlen des Beraters selbst (eigenes Abo + eigene Kundenabos), * konsistent mit der activeAbo-Definition. Inklusive der im Monat neu hinzugekommenen. * Bestände monatsbezogen wie bei {@see teamKpis()}. * * @return array{own_abos: int, own_abos_new: int, own_customer_abos: int, own_customer_abos_new: int} */ public function personalAboKpis(User $user, int $month, int $year): array { $userId = (int) $user->id; return [ 'own_abos' => $this->aboStockCount([$userId], 'user_id', 'me', $month, $year), 'own_abos_new' => $this->newAboCount([$userId], 'user_id', 'me', $month, $year), 'own_customer_abos' => $this->aboStockCount([$userId], 'member_id', 'ot', $month, $year), 'own_customer_abos_new' => $this->newAboCount([$userId], 'member_id', 'ot', $month, $year), ]; } /** * Abo-Bestand monatsbezogen (N2): Für den laufenden (oder einen zukünftigen) * Monat der heutige Live-Bestand; für vergangene Monate der rückgerechnete * Bestand zum Monatsende — Abo gestartet bis Monatsende und danach erst * beendet (heute noch aktiv, später gekündigt/gelöscht oder laut * Status-Historie erst später deaktiviert). * * Näherung für Monate vor Einführung der Status-Historie (07/2026): * Deaktivierungen ohne cancel_date sind dort nicht datierbar — solche Abos * fehlen im historischen Bestand. * * @param int[] $userIds */ public function aboStockCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int { return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year)->count(); } /** * Die Bestandsabfrage hinter {@see aboStockCount()} — öffentlich, damit jede * "+N neu"-Zählung exakt dieselbe Stichtags-Logik verwendet und nicht in * derselben Kachel eine andere Zählweise steht. Wer nur eine Gesamtzahl * braucht, nimmt {@see newAboCount()}; gruppierte Auswertungen * ({@see TopResultsService}) setzen direkt auf dieser Abfrage auf. * * @param int[] $userIds */ public function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder { $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); $query = UserAbo::query() ->where('is_for', $isFor) ->whereIn($userColumn, $userIds); if ($endOfMonth->gte(now()->endOfMonth())) { return $query->activeAbo(); } return $query ->withTrashed() ->where('start_date', '<=', $endOfMonth) // Bis zum Stichtag datierbar beendet? (Kündigung/Löschung vor Monatsende) ->where(fn ($q) => $q->whereNull('cancel_date')->orWhere('cancel_date', '>', $endOfMonth)) ->where(fn ($q) => $q->whereNull('deleted_at')->orWhere('deleted_at', '>', $endOfMonth)) // Laut Status-Historie zum Stichtag deaktiviert? (letztes Ereignis bis // Monatsende ist deaktivierend und wurde bis dahin nicht reaktiviert) ->whereNotExists(function ($deactivated) use ($endOfMonth) { $deactivated->from('user_abo_status_histories as h') ->whereColumn('h.user_abo_id', 'user_abos.id') ->whereIn('h.action', [UserAboStatusHistory::ACTION_CANCELLED, UserAboStatusHistory::ACTION_DEACTIVATED]) ->where('h.created_at', '<=', $endOfMonth) ->whereNotExists(function ($reactivated) use ($endOfMonth) { $reactivated->from('user_abo_status_histories as r') ->whereColumn('r.user_abo_id', 'h.user_abo_id') ->where('r.action', UserAboStatusHistory::ACTION_REACTIVATED) ->where('r.created_at', '<=', $endOfMonth) // Gleicher Zeitstempel kommt vor (created_at ist // sekundengenau): dann entscheidet die Reihenfolge der // Einträge, sonst gilt eine Reaktivierung in derselben // Sekunde nicht und das Abo fällt aus dem Bestand. ->where(function ($later) { $later ->whereColumn('r.created_at', '>', 'h.created_at') ->orWhere(fn ($sameSecond) => $sameSecond ->whereColumn('r.created_at', '=', 'h.created_at') ->whereColumn('r.id', '>', 'h.id') ); }); }); }) // Heute beendete Abos ohne datierbares Ende (z. B. Admin-Toggle vor // Einführung der Historie) zählen nur, wenn das Ende nach dem Stichtag lag. ->where(function ($outer) use ($endOfMonth) { $outer ->where(fn ($active) => $active->activeAbo()->whereNull('deleted_at')) ->orWhere('cancel_date', '>', $endOfMonth) ->orWhere('deleted_at', '>', $endOfMonth) ->orWhereHas('status_histories', function ($history) use ($endOfMonth) { $history->whereIn('action', [ UserAboStatusHistory::ACTION_CANCELLED, UserAboStatusHistory::ACTION_DEACTIVATED, ])->where('created_at', '>', $endOfMonth); }); }); } /** * Monatliches Abo-Wachstum des Teams für ein Jahr: je Monat die Anzahl neu * hinzugekommener (aktiver) Eigen- und Kunden-Abos anhand des Eingangsmonats * (start_date). Dient der separaten Wachstums-Tabelle unter der Bestandsübersicht. * * @return array{ * year: int, * months: array, * totals: array{own_new: int, customer_new: int, total_new: int} * } */ public function monthlyGrowth(User $user, int $year): array { $teamUserIds = $this->dashboardService->activeTeamUserIds($user->id); $monthNames = HTMLHelper::getTransMonths(); $months = []; $totals = ['own_new' => 0, 'customer_new' => 0, 'total_new' => 0]; for ($month = 1; $month <= 12; $month++) { // Gleiche Stichtags-Zählung wie die "+N neu"-Badges, damit die // Wachstumszahlen vergangener Monate nicht nachträglich sinken. $own = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year); $customer = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year); $months[] = [ 'month' => $month, 'label' => $monthNames[$month] ?? (string) $month, 'own_new' => $own, 'customer_new' => $customer, 'total_new' => $own + $customer, ]; $totals['own_new'] += $own; $totals['customer_new'] += $customer; $totals['total_new'] += $own + $customer; } return [ 'year' => $year, 'months' => $months, 'totals' => $totals, ]; } /** * Abo-Zusammenfassung eines einzelnen Team-Mitglieds für das Detail-Modal. * Liefert null, wenn das Mitglied nicht in der Downline des Owners liegt. * * @return array{ * name: string, * email: string, * own_abo: array|null, * customer_abos: array{count: int, total_points: float, items: array>} * }|null */ public function memberAboSummary(User $owner, int $memberId): ?array { if (! AboHelper::isUserInTeam($owner->id, $memberId)) { return null; } $member = User::with('account')->find($memberId); if (! $member) { return null; } $ownAbo = UserAbo::query() ->activeAbo() ->where('is_for', 'me') ->where('user_id', $memberId) ->with(['user_abo_items.product', 'user_abo_orders']) ->orderByDesc('id') ->first(); $customerAbos = UserAbo::query() ->activeAbo() ->where('is_for', 'ot') ->where('member_id', $memberId) ->with(['user_abo_items.product', 'user_abo_orders']) ->orderBy('start_date') ->get(); return [ 'name' => $this->memberName($member), 'email' => $member->email ?? '', 'own_abo' => $ownAbo ? $this->aboSummary($ownAbo) : null, 'customer_abos' => [ 'count' => $customerAbos->count(), 'total_points' => (float) $customerAbos->sum(fn (UserAbo $abo) => $abo->getTotalPoints()), 'items' => $customerAbos->map(fn (UserAbo $abo) => $this->aboSummary($abo))->all(), ], ]; } /** * @return array{points: float, start_date: string, next_date: string, executions: int, amount: string, interval: int} */ private function aboSummary(UserAbo $abo): array { return [ 'points' => (float) $abo->getTotalPoints(), 'start_date' => $abo->start_date, 'next_date' => $abo->next_date, 'executions' => $abo->getCountPaidOrders(), 'amount' => $abo->getFormattedAmount(), 'interval' => (int) $abo->abo_interval, ]; } /** * @param Collection $users * @return array */ private function buildLineRow(int $line, Collection $users, int $month, int $year): array { // Nur aktive Berater zählen/anzeigen (konsistent mit der Backoffice-Statistik). // Die Baum-Traversierung in lineBuckets() bleibt unberührt, damit auch die // Downline inaktiver Zwischen-Berater weiterhin erreicht wird. $users = $users->filter(fn (User $teamUser) => $this->dashboardService->isActiveConsultant($teamUser))->values(); $userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all(); if ($userIds === []) { return $this->emptyLine($line); } $users->loadMissing('user_level'); $ownAbos = $this->ownAbosByUser($userIds); $customerAbos = $this->customerAbosByMember($userIds); $members = []; $membersWithOwnAbo = 0; $ownAbosCount = 0; $ownAboPoints = 0.0; $customerAbosCount = 0; $customerAboPoints = 0.0; foreach ($users as $teamUser) { $uid = (int) $teamUser->id; $ownAbo = $ownAbos->get($uid); $custList = $customerAbos->get($uid, collect()); $hasOwnAbo = $ownAbo !== null; $memberOwnPoints = $hasOwnAbo ? (float) $ownAbo->getTotalPoints() : 0.0; $memberCustomerCount = $custList->count(); $memberCustomerPoints = (float) $custList->sum(fn (UserAbo $abo) => $abo->getTotalPoints()); if ($hasOwnAbo) { $membersWithOwnAbo++; $ownAbosCount++; $ownAboPoints += $memberOwnPoints; } $customerAbosCount += $memberCustomerCount; $customerAboPoints += $memberCustomerPoints; $members[] = [ 'user_id' => $uid, 'name' => $this->memberName($teamUser), 'career_level' => $teamUser->user_level?->getLang('name') ?? '', 'has_own_abo' => $hasOwnAbo, 'own_abo_points' => $memberOwnPoints, 'customer_abo_count' => $memberCustomerCount, 'customer_abo_points' => $memberCustomerPoints, 'total_abos' => ($hasOwnAbo ? 1 : 0) + $memberCustomerCount, ]; } $billedPoints = $this->aboBilledPoints($userIds, $month, $year); return [ 'line' => $line, 'label' => __('abo.team_line', ['line' => $line]), 'consultants' => $users->count(), 'members_total' => $users->count(), 'members_with_own_abo' => $membersWithOwnAbo, 'own_abos' => $ownAbosCount, 'own_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year), 'own_abo_points' => $ownAboPoints, 'customer_abos' => $customerAbosCount, 'customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year), 'customer_abo_points' => $customerAboPoints, 'abo_points_billed' => $billedPoints, 'abo_points_expected' => $billedPoints + $this->upcomingAboPointsUntilLastDeliveryDay($userIds, $month, $year), 'total_abos' => $ownAbosCount + $customerAbosCount, 'members' => $members, ]; } /** * Im Monat bereits abgerechnete Abo-Punkte (Eigen- und Kundenabos) der * übergebenen Berater — gleiche Zählweise wie der Payline-Ist-Wert * ({@see PaylineForecastService::aboActualPoints()}). * * @param int[] $userIds */ private function aboBilledPoints(array $userIds, int $month, int $year): float { if ($userIds === []) { return 0.0; } return (float) 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::aboPoints().' as points') ->value('points'); } /** * Abo-Punkte der noch offenen Ausführungen bis einschließlich zum letzten * Liefertag (25.) des Monats: aktive Abos der Berater, deren Ausführung noch * aussteht ({@see UserAbo::scopeOpenExecutionUntil()}). Für * vergangene/zukünftige Monate 0 — die Prognose ergibt nur für den laufenden * Monat Sinn. * * @param int[] $userIds */ private function upcomingAboPointsUntilLastDeliveryDay(array $userIds, int $month, int $year): float { $today = Carbon::today(); if ($userIds === [] || (int) $today->month !== $month || (int) $today->year !== $year) { return 0.0; } // Ticket A1: Liefertage sind frei (1-31), das Fenster reicht bis Monatsende. // A13: Am Liefertag selbst ist das Fenster noch offen, solange der // Ausführungsjob nicht gelaufen ist. $windowEnd = Carbon::create($year, $month, 1)->endOfMonth(); if ($windowEnd->lt($today->copy()->startOfDay())) { return 0.0; } return (float) 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) ->where('user_abos.status', 2) ->where('user_abo_items.comp', 0) ->openExecutionUntil($today, $windowEnd) ->where(function (Builder $query) use ($userIds) { $query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $userIds)) ->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $userIds)); }) ->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points') ->value('points'); } /** * Eigene (me) aktive Abos je Berater, indiziert nach user_id (ein Abo pro Berater). * * @param int[] $userIds * @return Collection */ private function ownAbosByUser(array $userIds): Collection { return UserAbo::query() ->activeAbo() ->where('is_for', 'me') ->whereIn('user_id', $userIds) ->with('user_abo_items.product') ->get() ->keyBy(fn (UserAbo $abo) => (int) $abo->user_id); } /** * Kundenabos (ot) je Berater, gruppiert nach member_id. * * @param int[] $userIds * @return Collection> */ private function customerAbosByMember(array $userIds): Collection { return UserAbo::query() ->activeAbo() ->where('is_for', 'ot') ->whereIn('member_id', $userIds) ->with('user_abo_items.product') ->get() ->groupBy(fn (UserAbo $abo) => (int) $abo->member_id); } /** * Im Monat hinzugekommene Abos — mit derselben Stichtags-Logik wie der * Bestand: für vergangene Monate zählt, was am Monatsende bestand, nicht * was heute noch aktiv ist. Sonst schrumpft das "+N neu"-Badge rückwirkend, * während der Bestand daneben stehen bleibt. * * @param int[] $userIds */ public function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int { if ($userIds === []) { return 0; } $startDate = Carbon::create($year, $month, 1)->startOfMonth(); return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year) ->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()]) ->count(); } private function memberName(User $user): string { if ($user->account) { $name = trim($user->account->first_name.' '.$user->account->last_name); if ($name !== '') { return $name; } } return $user->email ?? ('#'.$user->id); } /** * @return array */ private function emptyLine(int $line): array { return [ 'line' => $line, 'label' => $line > 0 ? __('abo.team_line', ['line' => $line]) : __('abo.team_total'), 'consultants' => 0, 'members_total' => 0, 'members_with_own_abo' => 0, 'own_abos' => 0, 'own_abos_new' => 0, 'own_abo_points' => 0.0, 'customer_abos' => 0, 'customer_abos_new' => 0, 'customer_abo_points' => 0.0, 'abo_points_billed' => 0.0, 'abo_points_expected' => 0.0, 'total_abos' => 0, 'members' => [], ]; } /** * @param array $totals * @param array $row * @return array */ private function addToTotals(array $totals, array $row): array { foreach (['consultants', 'members_total', 'members_with_own_abo', 'own_abos', 'own_abos_new', 'own_abo_points', 'customer_abos', 'customer_abos_new', 'customer_abo_points', 'abo_points_billed', 'abo_points_expected', 'total_abos'] as $key) { $totals[$key] += $row[$key]; } return $totals; } }