mivita/app/Services/BusinessPlan/GrowthBonusCalculator.php
2026-01-23 17:35:23 +01:00

381 lines
15 KiB
PHP

<?php
namespace App\Services\BusinessPlan;
use Illuminate\Support\Facades\Log;
/**
* Service für die Berechnung des Growth Bonus (Tiefenbonus)
* Implementiert die Differenz-Bonus-Logik ab Ebene 1
*/
class GrowthBonusCalculator
{
/**
* Berechnet den Growth Bonus für einen BusinessUserItemOptimized
*
* @param BusinessUserItemOptimized $userItem Der User, für den der Bonus berechnet wird
* @param object $qualUserLevel Das Qualifikations-Level-Objekt des Users
* @return float Der berechnete Bonus
*/
public function calculate(BusinessUserItemOptimized $userItem, $qualUserLevel): float
{
// Basis-Check: Hat der User überhaupt Anspruch auf Growth Bonus?
if (empty($qualUserLevel->growth_bonus) || $qualUserLevel->growth_bonus <= 0) {
return 0.0;
}
// Falls keine direkte Downline-Struktur geladen ist, kann kein Growth Bonus berechnet werden
if (empty($userItem->businessUserItems) && !empty($userItem->business_lines)) {
Log::warning("GrowthBonusCalculator: Growth Bonus calculation requires loaded child structure (businessUserItems is empty for user {$userItem->user_id})");
return 0.0;
}
return $this->calculateRecursive($userItem, $qualUserLevel);
}
/**
* Führt die eigentliche Berechnung basierend auf der Differenz-Logik durch
*/
private function calculateRecursive(BusinessUserItemOptimized $userItem, $qualUserLevel): float
{
$myGrowthPercent = (float) $qualUserLevel->growth_bonus;
$totalGrowthBonus = 0.0;
// Iteriere über alle direkten Beine (Firstlines)
foreach ($userItem->businessUserItems as $childItem) {
// Hole die Volumen-Verteilung aus diesem Bein
// Array-Format: ['0.0' => 1000, '1.5' => 5000]
// Bedeutung: 1000 Punkte sind mit 0% geschützt, 5000 Punkte mit 1.5%
$volumeDistribution = $this->getVolumeByProtectionLevel($childItem);
foreach ($volumeDistribution as $protectedPercentStr => $volume) {
$alreadyDistributedPercent = (float) $protectedPercentStr;
// Differenz berechnen: Mein Anspruch MINUS was schon verteilt wurde
$mySharePercent = max(0, $myGrowthPercent - $alreadyDistributedPercent);
if ($mySharePercent > 0) {
$commission = round($volume / 100 * $mySharePercent, 2);
$totalGrowthBonus += $commission;
}
}
}
return $totalGrowthBonus;
}
/**
* Liefert detaillierte Informationen zur Berechnung für die Anzeige
*
* @return array Detaillierte Aufschlüsselung pro Bein
*/
public function getCalculationDetails(BusinessUserItemOptimized $userItem, $qualUserLevel): array
{
$details = [];
if (empty($qualUserLevel->growth_bonus) || $qualUserLevel->growth_bonus <= 0) {
return $details;
}
$myGrowthPercent = (float) $qualUserLevel->growth_bonus;
// Iteriere über alle direkten Beine (Firstlines)
foreach ($userItem->businessUserItems as $childItem) {
$legDetails = [
'user_id' => $childItem->user_id,
'first_name' => $childItem->first_name,
'last_name' => $childItem->last_name,
'level_name' => $childItem->user_level_name,
'volume_distribution' => [],
'total_commission' => 0.0,
'total_volume' => 0.0
];
$volumeDistribution = $this->getVolumeByProtectionLevel($childItem);
foreach ($volumeDistribution as $protectedPercentStr => $volume) {
$alreadyDistributedPercent = (float) $protectedPercentStr;
$mySharePercent = max(0, $myGrowthPercent - $alreadyDistributedPercent);
$commission = 0.0;
if ($mySharePercent > 0) {
$commission = round($volume / 100 * $mySharePercent, 2);
}
$legDetails['volume_distribution'][] = [
'protected_percent' => $alreadyDistributedPercent,
'volume' => $volume,
'my_share_percent' => $mySharePercent,
'commission' => $commission
];
$legDetails['total_commission'] += $commission;
$legDetails['total_volume'] += $volume;
}
// Sortiere nach Protection Level
usort($legDetails['volume_distribution'], function ($a, $b) {
return $a['protected_percent'] <=> $b['protected_percent'];
});
if ($legDetails['total_volume'] > 0) {
$details[] = $legDetails;
}
}
// Sortiere Beine nach höchster Provision
usort($details, function ($a, $b) {
return $b['total_commission'] <=> $a['total_commission'];
});
return $details;
}
/**
* Liefert eine Matrix-Sicht für die detaillierte Darstellung
* Zeilen = Beine (Legs), Spalten = Ebenen (Levels)
*/
public function getMatrixDetails(BusinessUserItemOptimized $userItem, $qualUserLevel): array
{
$details = [];
if (empty($qualUserLevel->growth_bonus) || $qualUserLevel->growth_bonus <= 0) {
return $details;
}
$myGrowthPercent = (float) $qualUserLevel->growth_bonus;
foreach ($userItem->businessUserItems as $childItem) {
$legData = [
'user' => [
'id' => $childItem->user_id,
'name' => $childItem->first_name . ' ' . $childItem->last_name,
'level' => $childItem->user_level_name
],
'levels' => [],
'total_commission' => 0.0,
'total_volume' => 0.0
];
// Rekursiv die Ebenen dieses Beins einsammeln
// Start bei Ebene 1 (das ist das Kind selbst)
// Initial Protection ist 0 (vom Upline/Mir kommt kein Schutz, der relevant wäre, da ICH ja der Empfänger bin)
$this->collectLegLevels($childItem, 1, 0.0, $myGrowthPercent, $legData);
if (!empty($legData['levels'])) {
// Sortieren nach Ebenen-Index
ksort($legData['levels']);
$details[] = $legData;
}
}
// Sortieren nach Gesamt-Provision
usort($details, function ($a, $b) {
return $b['total_commission'] <=> $a['total_commission'];
});
return $details;
}
/**
* Rekursive Hilfsfunktion für Matrix-Daten
*/
private function collectLegLevels(BusinessUserItemOptimized $item, int $level, float $incomingProtection, float $myPercent, array &$legData)
{
// 1. Eigenen Status ermitteln (Schutz für Downline)
// WICHTIG: getQualifiedGrowthBonus() funktioniert sowohl für:
// - Live-berechnete Daten (qualificationCalculated = true)
// - Gespeicherte Daten aus DB (qual_user_level bereits vorhanden)
$userProtection = $item->getQualifiedGrowthBonus();
$userLevelName = '';
if ($userProtection > 0) {
$qual = $item->getQualUserLevel();
$userLevelName = is_array($qual) ? ($qual['name'] ?? '') : ($qual->name ?? '');
}
// Berechnung für diesen User (Ebene)
$volume = (float) ($item->sales_volume_points_TP_sum ?? 0);
// Auch User ohne Volumen in die Matrix aufnehmen, wenn sie einen Status haben (Blocker sichtbar machen)
// Aber wir brauchen Volumen für die Relevanz. Wenn Volumen 0, dann ist der Block hier (noch) egal,
// wirkt aber auf die Ebenen darunter.
if ($volume > 0 || $userProtection > 0) {
// Differenz: Mein Anspruch - Schutz von oben
$diffPercent = max(0, $myPercent - $incomingProtection);
$commission = round($volume / 100 * $diffPercent, 2);
if (!isset($legData['levels'][$level])) {
$legData['levels'][$level] = [
'volume' => 0.0,
'commission' => 0.0,
'details' => [],
'has_blocker' => false, // Flag für UI
'blocker_name' => ''
];
}
$legData['levels'][$level]['volume'] += $volume;
$legData['levels'][$level]['commission'] += $commission;
// Markiere Blocker
if ($userProtection > 0) {
$legData['levels'][$level]['has_blocker'] = true;
$legData['levels'][$level]['blocker_name'] = $userLevelName . ' (' . $userProtection . '%)';
}
// Detail-Information für Hover/Debug
$legData['levels'][$level]['details'][] = [
'u' => $item->user_id,
'n' => $item->first_name . ' ' . $item->last_name, // Name für Tooltip
'v' => $volume,
'p_in' => $incomingProtection,
'p_own' => $userProtection,
'pct' => $diffPercent
];
$legData['total_volume'] += $volume;
$legData['total_commission'] += $commission;
}
// Protection für nächste Ebene: Maximum aus was von oben kam und was dieser User beansprucht
$nextProtection = max($incomingProtection, $userProtection);
// Rekursion (Begrenzt auf 30 Ebenen für Anzeige)
if ($level < 30 && !empty($item->businessUserItems)) {
foreach ($item->businessUserItems as $child) {
$this->collectLegLevels($child, $level + 1, $nextProtection, $myPercent, $legData);
}
}
}
/**
* Liefert das Volumen der Downline eines Users gruppiert nach dem "bereits verteilten Prozentsatz" (Protection Level).
* Rekursive Funktion, die die "Differenz-Logik" vorbereitet.
*
* @param BusinessUserItemOptimized $item
* @return array<string, float> Key = Protected Percent, Value = Volume Points
*/
public function getVolumeByProtectionLevel(BusinessUserItemOptimized $item, int $depth = 0): array
{
// Schutz vor zu tiefer Rekursion (Performance)
$maxDepth = 20;
if ($depth > $maxDepth) {
Log::warning("GrowthBonusCalculator: Max recursion depth reached for user {$item->user_id}");
return [];
}
// Bei Live-Berechnung: Qualifikation berechnen falls nötig
// Bei gespeicherten Daten: qual_user_level ist bereits vorhanden
if (!$item->isQualificationCalculated() && !$item->isQualLevel()) {
$item->calcQualPP();
}
$volumes = [];
// 1. Eigenes Volumen (Unprotected / GAP)
// Man selbst schützt seinen eigenen Umsatz NICHT vor der Upline.
// Daher Start mit Protection Level 0.0
$ownVolume = (float) ($item->sales_volume_points_TP_sum ?? 0);
if ($ownVolume > 0) {
$key = '0.0';
$volumes[$key] = $ownVolume;
}
// 2. Mein Schutz-Level ermitteln (für das Volumen, das durch mich hindurch fließt)
// WICHTIG: getQualifiedGrowthBonus() funktioniert sowohl für:
// - Live-berechnete Daten (qualificationCalculated = true)
// - Gespeicherte Daten aus DB (qual_user_level bereits vorhanden)
$myProtectionPercent = $item->getQualifiedGrowthBonus();
// 3. Kinder laden falls nicht vorhanden (für gespeicherte Daten)
if (empty($item->businessUserItems) && $item->user_id) {
$this->loadChildrenFromDatabase($item, $depth);
}
// 4. Rekursive Aggregation der Kinder
if (!empty($item->businessUserItems)) {
foreach ($item->businessUserItems as $childItem) {
// Rekursiver Aufruf
$childVolumes = $this->getVolumeByProtectionLevel($childItem, $depth + 1);
// Schutz-Level anwenden und aggregieren
foreach ($childVolumes as $protectedPercentStr => $vol) {
$incomingProtection = (float) $protectedPercentStr;
// Das Volumen ist bereits mit $incomingProtection geschützt.
// Da es nun durch diesen User fließt, erhöht sich der Schutz auf dessen Level (falls höher).
$effectiveProtection = max($incomingProtection, $myProtectionPercent);
$newKey = (string) $effectiveProtection;
if (!isset($volumes[$newKey])) {
$volumes[$newKey] = 0.0;
}
$volumes[$newKey] += $vol;
}
}
}
return $volumes;
}
/**
* Lädt Kinder eines Users aus der Datenbank für die Growth Bonus Berechnung
*
* WICHTIG: Diese Methode wird nur aufgerufen, wenn businessUserItems leer ist
* (typischerweise bei gespeicherten Daten)
*/
private function loadChildrenFromDatabase(BusinessUserItemOptimized $item, int $depth): void
{
// Lade Sponsor-Beziehungen aus der User-Tabelle
$childIds = \App\User::where('m_sponsor', $item->user_id)
->where('deleted_at', null)
->pluck('id')
->toArray();
if (empty($childIds)) {
return;
}
// Hole das Date-Objekt vom Item
$date = $item->getDate();
if (!$date || !isset($date->month) || !isset($date->year)) {
Log::warning("GrowthBonusCalculator: No valid date for loading children of user {$item->user_id}");
return;
}
// Lade UserBusiness-Daten für alle Kinder
$childBusinesses = \App\Models\UserBusiness::whereIn('user_id', $childIds)
->where('month', $date->month)
->where('year', $date->year)
->get()
->keyBy('user_id');
foreach ($childIds as $childId) {
$childBusiness = $childBusinesses->get($childId);
// Nur Kinder mit Daten und Volumen berücksichtigen
if (!$childBusiness) {
continue;
}
$childTPSum = (float) ($childBusiness->sales_volume_points_TP_sum ?? 0);
// Nur relevante Kinder (mit Volumen oder qualifiziertem Level)
if ($childTPSum <= 0 && empty($childBusiness->qual_user_level)) {
continue;
}
// Erstelle ein BusinessUserItem aus den gespeicherten Daten
$childItem = new BusinessUserItemOptimized($date, null);
$childItem->makeUser($childId, false); // Aus DB laden
$childItem->addUserID();
$item->businessUserItems[] = $childItem;
}
}
}