mivita/app/Services/Abo/PaylineForecastService.php
Kevin 6cd00a60ec Payline (A07): Home-Kacheln und Prognose zaehlen die Mitglieder des Provisionsbaums
paylineContext() nahm die gesamte Downline, lineByUserId() und die
Team-Statistik den Provisionsbaum. "Teampartner/Abos in der Payline" und
die Payline-Prognose zaehlten damit auch Berater ohne Mitgliedsebene oder
Abrechnungskonto samt ihrem Unterbaum, deren Abo-Punkte nie in die
Provision eingehen. An der Live-Kopie betrifft das 102 von 162 Beratern
mit Payline. Nach dem Deploy cache:clear (payline_context_*, 6 h).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:44:24 +00:00

674 lines
28 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Abo;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\SalesVolumePoints;
use App\Services\BusinessPlan\LiveCommissionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Payline-bezogene Abo-Kennzahlen (Konzept B8 + B10):
*
* - Payline = die ersten N Ebenen der Downline; N ("paylines") kommt aus dem
* Provisionslevel des Users (Live-TreeCalcBot bzw. UserBusiness-Snapshot).
* - B8 aboTurnover(): Abo-Gesamtumsatz der Payline als Soll (Abo-Punkte-Bestand
* pro Zyklus) und Ist (im Monat abgerechnete Abo-Punkte, F10).
* - B10 forecastDays(): voraussichtlicher Payline-Gesamtumsatz zu den festen
* Ausführungsterminen 5./10./20./25. — Basis (Kundenumsatz + Payline-Punkte
* Stand heute) plus die bis zum jeweiligen Termin noch anstehenden
* Abo-Punkte, kumuliert (F11).
*/
class PaylineForecastService
{
/**
* F12 (dokumentierte Annahme, Kunde entscheidet nach Umsetzung): Die eigenen
* Abos und eigenen Kundenabos des Beraters zählen in der Prognose MIT, weil
* die Basis der Formel den aktuellen Kundenumsatz enthält und kommende
* eigene Ausführungen genau diesen Anteil erhöhen. Über diesen Schalter
* ohne Umbau umkehrbar.
*/
public const INCLUDE_OWN_ABOS = true;
private const CACHE_HOURS = 6;
/**
* Stichtage der Prognose-Kacheln. Seit Ticket A1 sind Liefertage frei (1-31);
* die vier klassischen Termine bleiben als Orientierung, dazu kommt das
* Monatsende, damit Abos nach dem 25. nicht aus der letzten Kachel fallen.
*/
public const FORECAST_DAYS = [5, 10, 20, 25];
public function __construct(
private BackofficeDashboardService $dashboardService,
private LiveCommissionService $commissionService,
private TeamAboOverviewService $teamAboOverview,
) {}
/**
* Payline-Kontext (Anzahl bezahlter Ebenen + Mitglieder-IDs), 6 h gecacht —
* die Payline-Tiefe kommt aus dem gemeinsamen Live-Lauf, die Abo-Summen
* bleiben live. Ein fehlgeschlagener Live-Lauf wird nicht gecacht, damit
* ein transienter Fehler die Karte nicht 6 h lang leert.
*
* Ebenso wenig gecacht wird eine Tiefe von 0: dieser Zustand tritt am
* Monatsanfang bei jedem Berater ohne Karriere-Level auf und löst sich mit
* den ersten Punkten des Monats von selbst auf — ein 6-h-Cache würde die
* leeren Kacheln bis in den Nachmittag festhalten.
*
* @return array{paylines: int, member_ids: int[], is_career_fallback: bool}
*/
public function paylineContext(User $user, int $month, int $year): array
{
$cacheKey = "payline_context_{$user->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<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
*/
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<int, array{day: int, date: string, billed: float, upcoming: float, total: float, is_past: bool}>}
*/
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<int, float> 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<int, float> 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<int, UserAbo> $abos
* @return array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>
*/
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<int, int>
*/
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<int, int> $lineByUserId
* @return array{personal: float, by_line: array<int, float>}
*/
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<int, int> $lineByUserId
* @return array<int, float>
*/
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<int, float> $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));
});
}
}