id}_{$month}_{$year}"; $cached = Cache::get($cacheKey); if (is_array($cached)) { // Union statt direkter Rueckgabe: Eintraege aus der Zeit vor dem // Karriere-Fallback kennen is_career_fallback noch nicht und wuerden // beim Leser sonst in eine undefinierte Schluesselwarnung laufen. return $cached + ['is_career_fallback' => false]; } [$context, $failed] = $this->buildPaylineContext($user, $month, $year); if (! $failed && $context['paylines'] > 0) { Cache::put($cacheKey, $context, now()->addHours(self::CACHE_HOURS)); } return $context; } /** * B8: Abo-Gesamtumsatz in der Payline — Soll und Ist, plus die Anzahl der * dahinterstehenden Abos (Berater- und Kundenabos, Kundenwunsch 2026-07-24). * * @return array{paylines: int, members: int, target: float, actual: float, team_abo_count: int, team_customer_abo_count: int} */ public function aboTurnover(User $user, int $month, int $year): array { $context = $this->paylineContext($user, $month, $year); $memberIds = $context['member_ids']; return [ 'paylines' => $context['paylines'], 'members' => count($memberIds), 'target' => $this->aboTargetPoints($memberIds), 'actual' => $this->aboActualPoints($memberIds, $month, $year), 'team_abo_count' => $this->aboCount($memberIds, 'me', 'user_id'), 'team_customer_abo_count' => $this->aboCount($memberIds, 'ot', 'member_id'), ]; } /** * Neue Abos der Payline-Mitglieder im Monat (Berater- + Kundenabos, * start_date im Monat) — das "+N"-Badge der "Abos in der Payline"-Kachel (N1). * * Zählt über {@see TeamAboOverviewService::newAboCount()} und damit über * exakt dieselbe Stichtags-Logik wie der Bestand der Kachel: für vergangene * Monate zählt, was am Monatsende bestand. Mit `activeAbo()` (heutiger Stand) * wäre das Badge kleiner als der Bestand daneben und würde mit jedem * beendeten Abo rückwirkend weiter schrumpfen. */ public function newPaylineAboCount(User $user, int $month, int $year): int { $memberIds = $this->paylineContext($user, $month, $year)['member_ids']; return $this->teamAboOverview->newAboCount($memberIds, 'user_id', 'me', $month, $year) + $this->teamAboOverview->newAboCount($memberIds, 'member_id', 'ot', $month, $year); } /** * Anzahl aktiver Abos der Payline-Mitglieder (gleiche Filter wie Soll). * * @param int[] $memberIds */ private function aboCount(array $memberIds, string $isFor, string $userColumn): int { if ($memberIds === []) { return 0; } return UserAbo::query() ->activeAbo() ->where('is_for', $isFor) ->whereIn($userColumn, $memberIds) ->count(); } /** * B10: Prognose je Ausführungstermin des LAUFENDEN Monats. Basis ist der * heutige Stand (Kundenumsatz + Payline-Punkte, vom Aufrufer übergeben); * dazu kommen kumuliert die Abo-Punkte aller noch offenen Ausführungen bis * zum jeweiligen Termin (im laufenden Monat, Abgrenzung siehe * {@see UserAbo::scopeOpenExecutionUntil()}). Bereits ausgeführte Abos * stecken im Ist-Umsatz und werden nicht doppelt gezählt. * * @return array{base: float, days: array} */ public function forecastDays(User $user, float $basePoints): array { $today = Carbon::today(); $context = $this->paylineContext($user, (int) $today->month, (int) $today->year); $upcomingAbos = $this->upcomingAboQuery($user, $context['member_ids'], $today) ->with('user_abo_items.product') ->get(); return [ 'base' => $basePoints, 'days' => $this->cumulateByDeliveryDay($upcomingAbos, $basePoints, $today), ]; } /** * Dieselbe Termin-Vorschau, aber ausschließlich über die EIGENEN Kundenabos * des Beraters (Kundenwunsch 2026-09-07: „wieviel Volumenpunkte habe ich am * 5. / 10. / 20. / 25. — nur mit meinen Kundenabos"). * * Ohne das eigene Abo und ohne Payline, aber MIT dem, was im Monat aus den * Kundenabos schon abgerechnet ist — und zwar taggenau: jeder Termin zeigt * den Stand, den er an diesem Tag tatsaechlich hatte, plus die bis dahin noch * offenen Lieferungen. Damit laesst sich die Kachelreihe gegen die Liste * darunter nachrechnen, was mit einer pauschalen Monatssumme als Basis nicht * ging: ein vergangener Termin haette dann auch spaetere Lieferungen enthalten. * * Anders als {@see forecastDays()} auf dem Dashboard, wo die Basis ein * einzelner Tagesstand (Kundenumsatz + Payline-Punkte) ist und fuer alle * Termine gleich bleibt. * * @return array{base: float, days: array} */ public function personalCustomerAboForecast(User $user): array { $today = Carbon::today(); $abos = UserAbo::query() ->where('active', true) ->where('status', 2) ->where('is_for', 'ot') ->where('member_id', $user->id) ->openExecutionUntil($today, $today->copy()->endOfMonth()) ->with('user_abo_items.product') ->get(); $billedByDay = $this->billedCustomerAboPointsByDay($user, (int) $today->month, (int) $today->year); return [ 'base' => (float) array_sum($billedByDay), 'days' => $this->cumulateByDeliveryDay($abos, 0.0, $today, $billedByDay), ]; } /** * Grundabfrage fuer die im Monat abgerechneten Punkte aus den EIGENEN * Kundenabos des Beraters. * * Abgrenzung wie in der Backoffice-Statistik * ({@see SalesVolumePoints::shopPredicate()}): Shop-/Kundenbuchungen aus * Abo-Bestellungen, Stornos vorzeichengetreu. Das eigene Abo des Beraters * faellt heraus, es laeuft als Beraterbestellung (Status 1) nicht ueber * dieses Praedikat. * * Gefiltert statt als SUM(CASE ...), damit dieselbe Menge sowohl je Liefertag * als auch je Abo gruppiert werden kann — die Vorschau und die Liste darunter * muessen auf dieselbe Summe kommen. */ private function billedCustomerAboQuery(User $user, int $month, int $year): Builder { return UserSalesVolume::query() ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') ->where('user_sales_volumes.user_id', $user->id) ->where('user_sales_volumes.month', $month) ->where('user_sales_volumes.year', $year) ->where('shopping_orders.is_abo', 1) ->whereRaw(SalesVolumePoints::shopPredicate()); } /** * Im Monat abgerechnete Kundenabo-Punkte, nach dem Kalendertag der Buchung * gruppiert. Gruppiert wird in PHP: es sind wenige Zeilen je Berater und * Monat, und eine Datumsfunktion in SQL waere zwischen MySQL und der * SQLite-Testdatenbank nicht dieselbe. * * @return array Tag im Monat => Punkte */ private function billedCustomerAboPointsByDay(User $user, int $month, int $year): array { $byDay = []; foreach ($this->billedCustomerAboQuery($user, $month, $year) ->select('user_sales_volumes.points', 'user_sales_volumes.created_at') ->get() as $row) { $day = $row->created_at ? (int) $row->created_at->day : 1; $byDay[$day] = ($byDay[$day] ?? 0.0) + (float) $row->points; } return $byDay; } /** * Im Monat abgerechnete Kundenabo-Punkte je Abo — fuer die Spalte „diesen * Monat abgerechnet" in der Kundenabo-Liste. Enthaelt auch Einmal-Artikel und * sonstige Zubuchungen der jeweiligen Lieferung; die werden nach der * Ausfuehrung aus dem Abo entfernt und stehen deshalb nicht mehr im * wiederkehrenden Aboinhalt. * * @return Collection user_abo_id => Punkte */ public function billedCustomerAboPointsByAbo(User $user, int $month, int $year): Collection { return $this->billedCustomerAboQuery($user, $month, $year) ->join('user_abo_orders', 'user_abo_orders.shopping_order_id', '=', 'user_sales_volumes.shopping_order_id') ->groupBy('user_abo_orders.user_abo_id') ->select('user_abo_orders.user_abo_id') ->selectRaw('COALESCE(SUM(user_sales_volumes.points), 0) as points') ->pluck('points', 'user_abo_id') ->map(fn ($points) => (float) $points); } /** * Monatssumme der abgerechneten Kundenabo-Punkte. Massgeblich fuer die * Vorschau; {@see billedCustomerAboPointsByAbo()} kann davon abweichen, wenn * eine Buchung keiner Abo-Ausfuehrung zugeordnet ist — die Liste weist die * Differenz dann gesondert aus, damit die Addition aufgeht. */ public function billedCustomerAboPoints(User $user, int $month, int $year): float { return (float) array_sum($this->billedCustomerAboPointsByDay($user, $month, $year)); } /** * Offene Ausführungen auf die vier Liefertermine kumulieren: je Termin die * Punkte aller Abos, deren nächste Ausführung bis einschließlich zu diesem * Tag fällt. Eine Stelle für beide Prognosen, damit Payline- und * Kundenabo-Sicht nicht mit unterschiedlichen Terminregeln rechnen. * * @param Collection $abos * @return array */ private function cumulateByDeliveryDay($abos, float $basePoints, Carbon $today, ?array $billedByDay = null): array { $pointsByDay = []; foreach ($abos as $abo) { $day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day; $pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints(); } $lastDeliveryDay = (int) $today->daysInMonth; $deliveryDays = array_values(array_unique([...self::FORECAST_DAYS, $lastDeliveryDay])); $days = []; foreach ($deliveryDays as $deliveryDay) { $upcoming = 0.0; foreach ($pointsByDay as $day => $points) { if ($day <= $deliveryDay) { $upcoming += $points; } } if ($billedByDay === null) { $base = $basePoints; } else { $base = 0.0; foreach ($billedByDay as $day => $points) { // Buchungen nach dem letzten Liefertag (Nachzuegler, manuelle // Gutschriften) haengen sich an den letzten Termin, damit die // letzte Kachel immer den Monatsstand zeigt. if ($day <= $deliveryDay || ($deliveryDay === $lastDeliveryDay && $day > $lastDeliveryDay)) { $base += $points; } } } $days[] = [ 'day' => $deliveryDay, 'date' => $today->copy()->startOfMonth()->addDays($deliveryDay - 1)->format(\Util::formatDateDB()), 'billed' => $base, 'upcoming' => $upcoming, 'total' => $base + $upcoming, 'is_past' => $deliveryDay < (int) $today->day, 'is_month_end' => $deliveryDay === $lastDeliveryDay, ]; } return $days; } /** * N4: Level- und Provisions-Prognose zum letzten Liefertag des laufenden * Monats (Kundenentscheid F5: gekennzeichnete Schätzung). * * - Prognose-Basis: heutige Punkte je Payline-Ebene (TP-Laufstände) plus die * noch offenen Abo-Ausführungen, ebenengenau zugeordnet; eigene Abos und * eigene Kundenabos zählen auf das persönliche Volumen. * - Level-Prognose: erreicht die prognostizierte Payline-Punktzahl (inkl. * KP-Überschuss, Logik wie {@see BusinessUserItemOptimized::setNextUserLevel()}) * das nächste Level — und falls nicht, wie viel fehlt laut Prognose? * - Provisions-Schätzung: Ebenen-Punkte × Provisionssätze des laut Prognose * erreichten Levels; Shop-/Wachstumsanteile werden mit dem heutigen * Live-Wert fortgeschrieben. Rangverschiebungen im Team bleiben außen * vor — deshalb im UI als unverbindlich kennzeichnen. * * @return array{next_name: string|null, reached: bool, missing_pp: float, missing_personal: float, personal_required: float, personal_forecast: float, estimated_commission: float, estimated_level_name: string, is_live: bool}|null */ public function levelAndCommissionForecast(User $user, int $month, int $year): ?array { $cacheKey = "level_forecast_{$user->id}_{$month}_{$year}"; $cached = Cache::get($cacheKey); if (is_array($cached)) { return $cached; } $forecast = $this->buildLevelAndCommissionForecast($user, $month, $year); if ($forecast !== null) { Cache::put($cacheKey, $forecast, now()->addHours(self::CACHE_HOURS)); } return $forecast; } /** * @return array{next_name: string|null, reached: bool, missing_pp: float, missing_personal: float, personal_required: float, personal_forecast: float, estimated_commission: float, estimated_level_name: string, is_live: bool}|null */ private function buildLevelAndCommissionForecast(User $user, int $month, int $year): ?array { $snapshot = $this->commissionService->snapshot($user, $month, $year); if (! empty($snapshot['failed'])) { return null; // echter Berechnungsfehler — dafuer gibt es keine Prognose } $qualLevel = is_array($snapshot['qual_level']) ? $snapshot['qual_level'] : null; $nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null; // "Noch nicht qualifiziert" ist ein gueltiger Ausgangszustand: wer im Monat // noch keine Stufe erreicht hat, kann die erste durch kommende eigene // Abo-Punkte trotzdem erreichen. Nur wenn es weder eine erreichte noch eine // naechste Stufe gibt, fehlt der Prognose jeder Bezugspunkt. if ($qualLevel === null && $nextLevel === null) { return null; } $today = Carbon::today(); $lineByUserId = $this->lineByUserId($user, $month, $year); $upcoming = $this->upcomingPointsByLine($user, $lineByUserId, $today, $month, $year); $linePointsForecast = $this->currentLinePoints($lineByUserId, $month, $year); foreach ($upcoming['by_line'] as $line => $points) { $linePointsForecast[$line] = ($linePointsForecast[$line] ?? 0.0) + $points; } $personalForecast = (float) $snapshot['personal_points'] + $upcoming['personal']; $reached = false; $missingPp = 0.0; $missingPersonal = 0.0; if ($nextLevel !== null) { $restKp = max(0.0, $personalForecast - (float) ($nextLevel['qual_kp'] ?? 0)); $paylineForecast = $this->sumLinePoints($linePointsForecast, (int) ($nextLevel['paylines'] ?? 0)) + $restKp; $missingPp = max(0.0, (float) ($nextLevel['qual_pp'] ?? 0) - $paylineForecast); $missingPersonal = max(0.0, (float) ($nextLevel['qual_kp'] ?? 0) - $personalForecast); $reached = $missingPp <= 0.0 && $missingPersonal <= 0.0; } // Ohne erreichte Stufe und ohne prognostizierten Aufstieg gibt es keine // Provisionssaetze — die PP-Provision bleibt dann 0, Shop und Wachstum zaehlen // trotzdem. $estimationLevel = ($reached && $nextLevel !== null) ? $nextLevel : $qualLevel; $commissionPp = 0.0; for ($line = 1; $line <= (int) ($estimationLevel['paylines'] ?? 0); $line++) { $rate = (float) ($estimationLevel['pr_line_'.$line] ?? 0); $commissionPp += ($linePointsForecast[$line] ?? 0.0) * $rate / 100; } return [ 'next_name' => $nextLevel !== null ? (string) ($nextLevel['name'] ?? '') : null, 'reached' => $reached, 'missing_pp' => round($missingPp, 2), 'missing_personal' => round($missingPersonal, 2), 'personal_required' => (float) ($nextLevel['qual_kp'] ?? 0), 'personal_forecast' => round($personalForecast, 2), 'estimated_commission' => round($commissionPp + (float) $snapshot['shop'] + (float) $snapshot['growth'], 2), 'estimated_level_name' => (string) ($estimationLevel['name'] ?? ''), 'is_live' => (bool) $snapshot['is_live'], ]; } /** * Ebenen-Zuordnung der Downline (Linie je User-ID) — beschränkt auf die * Mitglieder, die auch im Provisionsbaum stehen. * * Das ist entscheidend für die Vergleichbarkeit: Die Level-Kachel zeigt die * payline_points aus dem TreeCalcBot, dieser Forecast rechnet die Ebenen aus * dem Provisionsbaum der Statistik nach * ({@see BackofficeDashboardService::commissionLineBuckets()} — dieselbe * Filter- und Kapp-Regel wie {@see BusinessUserItemOptimized::readParentsBusinessUsers()}). * Ohne denselben Filter stünden auf einer Seite zwei verschiedene * "fehlen noch X Punkte". * * @return array */ private function lineByUserId(User $user, int $month, int $year): array { $map = []; foreach ($this->dashboardService->commissionLineBuckets($user->id, $month, $year) as $line => $users) { foreach ($users as $member) { $map[(int) $member->id] = (int) $line; } } return $map; } /** * Offene Abo-Ausführungen des Monats, aufgeteilt in persönliches Volumen * (eigene Abos + eigene Kundenabos) und Team-Punkte je Ebene. * * @param array $lineByUserId * @return array{personal: float, by_line: array} */ private function upcomingPointsByLine(User $user, array $lineByUserId, Carbon $today, int $month, int $year): array { if ((int) $today->month !== $month || (int) $today->year !== $year) { return ['personal' => 0.0, 'by_line' => []]; } $memberIds = array_keys($lineByUserId); $personal = 0.0; $byLine = []; $abos = $this->upcomingAboQuery($user, $memberIds, $today) ->with('user_abo_items.product') ->get(); foreach ($abos as $abo) { $ownerId = $abo->is_for === 'me' ? (int) $abo->user_id : (int) $abo->member_id; $points = (float) $abo->getTotalPoints(); if ($ownerId === (int) $user->id) { $personal += $points; } elseif (isset($lineByUserId[$ownerId])) { $byLine[$lineByUserId[$ownerId]] = ($byLine[$lineByUserId[$ownerId]] ?? 0.0) + $points; } } return ['personal' => $personal, 'by_line' => $byLine]; } /** * Heutige Team-Punkte je Ebene: TP + Shop-Laufstand (letzte * user_sales_volumes-Zeile je User) — dieselbe Punktbasis wie die * business_lines des TreeCalcBot ({@see UserSalesVolume::getPointsTPSum()}). * * @param array $lineByUserId * @return array */ private function currentLinePoints(array $lineByUserId, int $month, int $year): array { if ($lineByUserId === []) { return []; } $rows = UserSalesVolume::query() ->whereIn('user_id', array_keys($lineByUserId)) ->where('month', $month) ->where('year', $year) ->whereIn('id', function ($sub) use ($month, $year) { $sub->selectRaw('MAX(id)') ->from('user_sales_volumes') ->where('month', $month) ->where('year', $year) ->groupBy('user_id'); }) ->get(['user_id', 'month_TP_points', 'month_shop_points']); $byLine = []; foreach ($rows as $row) { $line = $lineByUserId[(int) $row->user_id] ?? null; if ($line !== null) { $byLine[$line] = ($byLine[$line] ?? 0.0) + (float) $row->month_TP_points + (float) $row->month_shop_points; } } return $byLine; } /** * @param array $linePoints */ private function sumLinePoints(array $linePoints, int $maxLine): float { $sum = 0.0; foreach ($linePoints as $line => $points) { if ($line <= $maxLine) { $sum += $points; } } return $sum; } /** * Payline-Tiefe bestimmen und die Mitglieder der bezahlten Ebenen einsammeln. * * Die Tiefe kommt aus der MONATLICHEN Qualifikationsstufe. Die wird jeden * Monat neu erarbeitet und ist am 1. für jeden Berater noch leer * ({@see BusinessUserItemOptimized::calcuQualLevel()} findet erst ab der * qual_kp des niedrigsten Levels ein Ergebnis) — die Struktur-Kacheln * "Teampartner in der Payline" und "Abos in der Payline" stünden dann auf 0, * obwohl sich an der Teamstruktur nichts geändert hat. * * Solange die Monatsquali aussteht, gilt deshalb die Ebenenzahl des * Karriere-Levels (Kundenentscheid 2026-09-01). Punkte- und Provisionswerte * bleiben davon unberührt: die kommen direkt aus dem Snapshot und sind ohne * Qualifikation korrekt 0. * * @return array{0: array{paylines: int, member_ids: int[], is_career_fallback: bool}, 1: bool} [Kontext, Lauf fehlgeschlagen] */ private function buildPaylineContext(User $user, int $month, int $year): array { $snapshot = $this->commissionService->snapshot($user, $month, $year); $failed = (bool) ($snapshot['failed'] ?? false); $paylines = $snapshot['paylines']; $isCareerFallback = false; if ($paylines <= 0) { $paylines = (int) ($user->user_level?->paylines ?? 0); $isCareerFallback = $paylines > 0; } if ($paylines <= 0) { return [['paylines' => 0, 'member_ids' => [], 'is_career_fallback' => false], $failed]; } // Mitglieder aus dem Provisionsbaum, wie lineByUserId() und die Team-Statistik // (Audit A07): Wer dort ausgefiltert oder gekappt ist, zaehlt fuer die Payline nicht. $memberIds = collect($this->dashboardService->commissionLineBuckets($user->id, $month, $year)) ->filter(fn ($users, int $line) => $line <= $paylines) ->flatMap(fn ($users) => $users->pluck('id')) ->map(fn ($id) => (int) $id) ->values() ->all(); return [['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => $isCareerFallback], $failed]; } /** * Soll: Abo-Punkte-Bestand (Produktpunkte × Menge, Hauptartikel comp = 0) * aller aktiven Abos der Payline-Mitglieder — Berater-Abos und Kundenabos. * * @param int[] $memberIds */ private function aboTargetPoints(array $memberIds): float { if ($memberIds === []) { 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) ->whereNotIn('user_abos.status', [4, 5, 6]) ->where('user_abo_items.comp', 0) ->where(function (Builder $query) use ($memberIds) { $query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $memberIds)) ->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $memberIds)); }) ->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points') ->value('points'); } /** * Ist: im Monat abgerechnete Abo-Punkte der Payline-Mitglieder. * * @param int[] $memberIds */ private function aboActualPoints(array $memberIds, int $month, int $year): float { if ($memberIds === []) { return 0.0; } return (float) UserSalesVolume::query() ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') ->whereIn('user_id', $memberIds) ->where('month', $month) ->where('year', $year) ->selectRaw(SalesVolumePoints::aboPoints().' as points') ->value('points'); } /** * Noch offene Abo-Ausführungen des laufenden Monats: aktive Abos der * Payline-Mitglieder plus — je nach {@see INCLUDE_OWN_ABOS} — die eigenen * Abos/Kundenabos des Betrachters. * * @param int[] $memberIds */ private function upcomingAboQuery(User $user, array $memberIds, Carbon $today): Builder { $aboUserIds = self::INCLUDE_OWN_ABOS ? array_values(array_unique(array_merge([(int) $user->id], $memberIds))) : $memberIds; return UserAbo::query() ->where('active', true) ->where('status', 2) ->openExecutionUntil($today, $today->copy()->endOfMonth()) ->where(function (Builder $query) use ($aboUserIds) { $query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $aboUserIds)) ->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $aboUserIds)); }); } }