23-01-2026

This commit is contained in:
Kevin Adametz 2026-01-23 17:35:23 +01:00
parent a939cd51ef
commit a8b395e20d
248 changed files with 29342 additions and 4805 deletions

View file

@ -8,9 +8,7 @@ use Carbon\Carbon;
use App\Models\UserLevel;
use App\Models\UserBusiness;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\Services\TranslationHelper;
use App\Models\UserBusinessStructure;
use Illuminate\Support\Facades\Log;
/**
@ -31,6 +29,7 @@ class BusinessUserItemOptimized
private ?TreeCalcBotOptimized $treeCalcBot = null;
private $user_level_active_pos;
private $needsQualificationRecalculation = false;
private $qualificationCalculated = false;
public function __construct($date, ?TreeCalcBotOptimized $treeCalcBot = null)
{
@ -40,6 +39,12 @@ class BusinessUserItemOptimized
return $this;
}
public function isQualificationCalculated(): bool
{
return $this->qualificationCalculated;
}
/**
* Erstellt BusinessUser aus User-ID (Original-Methode für Rückwärtskompatibilität)
*
@ -107,6 +112,7 @@ class BusinessUserItemOptimized
*/
public function makeUserFromModel(User $user, bool $forceLiveCalculation = false): void
{
\Log::debug("BusinessUserItemOptimized: makeUserFromModel for user {$user->id} ({$this->date->month}/{$this->date->year})");
try {
if (!$user || !$user->id) {
throw new \InvalidArgumentException('Invalid user model provided');
@ -121,7 +127,6 @@ class BusinessUserItemOptimized
if ($this->b_user !== null) {
\Log::debug("BusinessUserItem: Using stored data for user {$user->id} ({$this->date->month}/{$this->date->year})");
// WICHTIG: Auch bei gespeicherten Daten User-Grunddaten anreichern
$this->enrichStoredDataWithUserModel($user);
@ -134,19 +139,18 @@ class BusinessUserItemOptimized
return; // Bereits berechnete Daten verwenden
}
} else {
\Log::debug("BusinessUserItem: Force live calculation for user {$user->id} ({$this->date->month}/{$this->date->year})");
\Log::debug("BusinessUserItemOptimized: Force live calculation for user {$user->id} ({$this->date->month}/{$this->date->year})");
}
// Erstelle neuen User und führe Live-Berechnung durch
$this->initializeFromUserModel($user);
// WICHTIG: Bei Live-Berechnung auch Level-Qualifikationsdaten berechnen
// (nicht bei forceLiveCalculation=false, da dort gespeicherte Daten bevorzugt werden)
if ($forceLiveCalculation) {
$this->calcQualPP();
//$this->calcQualPP();
}
} catch (\Exception $e) {
\Log::error("BusinessUserItem: Error creating user from model {$user->id}: " . $e->getMessage());
\Log::error("BusinessUserItemOptimized: Error creating user from model {$user->id}: " . $e->getMessage());
throw $e;
}
}
@ -204,6 +208,9 @@ class BusinessUserItemOptimized
'qual_kp' => $user_level_active ? max(0, $user_level_active->qual_kp) : 0,
'qual_pp' => $user_level_active ? max(0, $user_level_active->qual_pp) : 0,
'active_growth_bonus' => $user_level_active ? (float)$user_level_active->growth_bonus : 0,
'growth_bonus_details' => null,
// Initialisierung
'payline_points' => 0,
'commission_pp_total' => 0,
@ -223,6 +230,7 @@ class BusinessUserItemOptimized
$this->b_user->commission_shop_sales = $calculatedCommission;
\Log::debug("BusinessUserItem: Created optimized user {$user->id} for {$this->date->month}/{$this->date->year} - Shop commission: {$calculatedCommission} (Volume: {$shopVolume}, Margin: {$shopMargin}%)");
\Log::debug("BusinessUserItemOptimized: b_user: " . json_encode($this->b_user));
}
/**
@ -428,6 +436,24 @@ class BusinessUserItemOptimized
$this->b_user->business_lines[$line] = $obj;
}
/**
* Initialisiert leere business_lines für diesen User
*/
public function initBusinessLines(): void
{
if (!isset($this->b_user->business_lines) || !is_array($this->b_user->business_lines)) {
$this->b_user->business_lines = [];
}
}
/**
* Prüft ob eine business_line existiert
*/
public function hasBusinessLine(int $line): bool
{
return isset($this->b_user->business_lines[$line]);
}
public function addBusinessLinePoints($line, $points)
{
if (!isset($this->b_user->business_lines[$line])) {
@ -451,6 +477,78 @@ class BusinessUserItemOptimized
$this->b_user->business_lines[$line] = $obj;
}
/**
* Gibt Details zur Growth Bonus Berechnung zurück (für die View)
* Nur für Monate ab November 2025 verfügbar (neue Logik)
*/
public function getGrowthBonusBreakdown(): array
{
// Prüfe ob Legacy-Monat (vor November 2025)
$isLegacy = ($this->date->year < 2025) || ($this->date->year == 2025 && $this->date->month < 11);
if ($isLegacy) {
return [];
}
if (!$this->isQualLevel() || empty($this->b_user->qual_user_level['growth_bonus'])) {
return [];
}
try {
$calculator = new GrowthBonusCalculator();
// Array zu Object konvertieren für Calculator
$qualData = (object) $this->b_user->qual_user_level;
return $calculator->getCalculationDetails($this, $qualData);
} catch (\Exception $e) {
\Log::error("BusinessUserItem: Error getting growth bonus breakdown: " . $e->getMessage());
return [];
}
}
/**
* Gibt Matrix-Details zur Growth Bonus Berechnung zurück (für die View)
* Nur für Monate ab November 2025 verfügbar (neue Logik)
*/
public function getGrowthBonusMatrix(): array
{
// Prüfe ob Legacy-Monat (vor November 2025)
$isLegacy = ($this->date->year < 2025) || ($this->date->year == 2025 && $this->date->month < 11);
if ($isLegacy) {
return [];
}
if (!$this->isQualLevel() || empty($this->b_user->qual_user_level['growth_bonus'])) {
return [];
}
// Use stored details if available (avoid recalculation)
if (!empty($this->b_user->growth_bonus_details)) {
if (is_object($this->b_user->growth_bonus_details) && method_exists($this->b_user->growth_bonus_details, 'toArray')) {
return $this->b_user->growth_bonus_details->toArray();
}
if (is_array($this->b_user->growth_bonus_details)) {
return $this->b_user->growth_bonus_details;
}
// Fallback for standard object
if (is_object($this->b_user->growth_bonus_details)) {
return json_decode(json_encode($this->b_user->growth_bonus_details), true);
}
}
try {
$calculator = new GrowthBonusCalculator();
// Array zu Object konvertieren für Calculator
$qualData = (object) $this->b_user->qual_user_level;
return $calculator->getMatrixDetails($this, $qualData);
} catch (\Exception $e) {
\Log::error("BusinessUserItem: Error getting growth bonus matrix: " . $e->getMessage());
return [];
}
}
public function addTotalTP($points)
{
$this->b_user->total_pp += (float) $points; // Type-Safety
@ -466,6 +564,67 @@ class BusinessUserItemOptimized
return !empty($this->b_user->qual_user_level);
}
/**
* Methode für Zugriff auf qual_user_level (auch für GrowthBonusCalculator)
*/
public function getQualUserLevel()
{
return $this->b_user->qual_user_level ?? null;
}
public function getActiveGrowthBonus()
{
return $this->active_growth_bonus;
}
/**
* Gibt das Date-Objekt zurück (für GrowthBonusCalculator)
*/
public function getDate()
{
return $this->date;
}
/**
* Gibt den Growth Bonus basierend auf dem ERREICHTEN Qualifikations-Level zurück.
*
* WICHTIG: Diese Methode gibt den Growth Bonus nur zurück, wenn der Partner
* in dem Monat tatsächlich das entsprechende Level qualifiziert hat.
* Das ist entscheidend für die korrekte Differenz-Berechnung im GrowthBonusCalculator.
*
* Die Methode funktioniert sowohl für:
* - Live-berechnete Daten (qualificationCalculated = true)
* - Gespeicherte/geladene Daten aus UserBusiness (qual_user_level bereits vorhanden)
*
* @return float Der Growth Bonus des erreichten Qualifikations-Levels (0 wenn nicht qualifiziert)
*/
public function getQualifiedGrowthBonus(): float
{
// Prüfen ob b_user existiert
if (empty($this->b_user)) {
return 0.0;
}
// Prüfen ob ein Qualifikations-Level erreicht wurde
// Dies funktioniert sowohl für live-berechnete als auch für gespeicherte Daten
if (empty($this->b_user->qual_user_level)) {
return 0.0;
}
// Handle array und object Zugriff (JSON-Deserialisierung kann beides liefern)
$qualLevel = $this->b_user->qual_user_level;
if (is_array($qualLevel)) {
return (float) ($qualLevel['growth_bonus'] ?? 0.0);
}
if (is_object($qualLevel)) {
return (float) ($qualLevel->growth_bonus ?? 0.0);
}
return 0.0;
}
public function isQualEqualLevel(): bool
{
if (!$this->b_user->qual_user_level) {
@ -502,11 +661,26 @@ class BusinessUserItemOptimized
public function calcQualPP($force = false): void
{
if ($this->qualificationCalculated && !$force) {
return;
}
// Mark as calculated immediately to prevent potential recursion loops
$this->qualificationCalculated = true;
try {
$qualUserLevel = $this->calcuQualLevel();
\Log::debug("BusinessUserItemOptimized: calcQualPP for user {$this->b_user->user_id}: " . json_encode($qualUserLevel));
if ($qualUserLevel !== null) {
//das erreichte level setzen
$this->b_user->qual_user_level = $qualUserLevel->toArray();
// Wichtig: Setze die qual_kp und qual_pp des erreichten Levels im b_user Objekt
// Diese Werte ändern sich je nach erreichtem Level und müssen hier aktualisiert werden
$this->b_user->qual_kp = $qualUserLevel->qual_kp;
$this->b_user->qual_pp = $qualUserLevel->qual_pp;
\Log::debug("BusinessUserItemOptimized: Set qual_kp={$qualUserLevel->qual_kp}, qual_pp={$qualUserLevel->qual_pp} for user {$this->b_user->user_id}");
//next_qual_user_level nächster qualifizierten level
$this->setNextUserLevel($force);
//qual_user_level_next nächste Provisions-Stufe,
@ -557,30 +731,31 @@ class BusinessUserItemOptimized
// Growth Bonus
if (!empty($qualUserLevel->growth_bonus)) {
$payline = (int) $this->b_user->qual_user_level['paylines'] + 1;
$maxlines = count($this->b_user->business_lines) + 1;
$growth_bonus = (float) $this->b_user->qual_user_level['growth_bonus'];
// Fallback für alte Monate (vor November 2025)
// Stichtag: 01.11.2025 - Alles davor nutzt die Legacy-Berechnung
$isLegacy = ($this->date->year < 2025) || ($this->date->year == 2025 && $this->date->month < 11);
for ($i = $payline; $i <= $maxlines; $i++) {
if (isset($this->b_user->business_lines[$i])) {
$object = $this->b_user->business_lines[$i];
if ($isLegacy) {
$commission_growth_total = $this->calculateLegacyGrowthBonus($qualUserLevel);
\Log::debug("BusinessUserItem: Used LEGACY growth bonus calculation for user {$this->b_user->user_id} ({$this->date->month}/{$this->date->year})");
} else {
// Neue Logik ab Dezember 2025 - delegated to new Calculator service
try {
$growthCalculator = new GrowthBonusCalculator();
$commission_growth_total = $growthCalculator->calculate($this, $qualUserLevel);
// Handle both array and object types (JSON deserialization inconsistency)
if (is_array($object)) {
$points = (float) ($object['points'] ?? 0);
$object['margin'] = $growth_bonus;
$object['commission'] = round($points / 100 * $growth_bonus, 2);
$object['growth_bonus'] = true;
$commission_growth_total += $object['commission'];
} else {
$points = (float) ($object->points ?? 0);
$object->margin = $growth_bonus;
$object->commission = round($points / 100 * $growth_bonus, 2);
$object->growth_bonus = true;
$commission_growth_total += $object->commission;
}
$this->b_user->business_lines[$i] = $object;
// Calculate matrix details for storage and total sum
// This ensures that the stored details match the calculated total exactly
$matrixDetails = $growthCalculator->getMatrixDetails($this, $qualUserLevel);
// Store details in the model so they can be retrieved later without recalculation
$this->b_user->growth_bonus_details = $matrixDetails;
} catch (\Exception $e) {
\Log::error("BusinessUserItem: Error calculating growth bonus for user {$this->b_user->user_id}: " . $e->getMessage());
// Fallback to 0 if calculation fails
$commission_growth_total = 0;
$this->b_user->growth_bonus_details = null;
}
}
}
@ -589,30 +764,96 @@ class BusinessUserItemOptimized
$this->b_user->commission_growth_total = $commission_growth_total;
}
/**
* Alte Berechnungsmethode für Growth Bonus (Kompatibilität für vergangene Monate)
* Berechnet pauschal ab einer bestimmten Ebene ohne Differenz-Prüfung
*/
private function calculateLegacyGrowthBonus($qualUserLevel): float
{
$commission_growth_total = 0;
// Payline aus Level-Daten + 1 (Start des Bonus)
$payline = (int) ($this->b_user->qual_user_level['paylines'] ?? 0) + 1;
$maxlines = count($this->b_user->business_lines ?? []) + 1;
$growth_bonus = (float) ($this->b_user->qual_user_level['growth_bonus'] ?? 0);
for ($i = $payline; $i <= $maxlines; $i++) {
if (isset($this->b_user->business_lines[$i])) {
$object = $this->b_user->business_lines[$i];
// Handle both array and object types
if (is_array($object)) {
$points = (float) ($object['points'] ?? 0);
$object['margin'] = $growth_bonus;
$object['commission'] = round($points / 100 * $growth_bonus, 2);
$object['growth_bonus'] = true;
$commission_growth_total += $object['commission'];
} else {
if (!is_object($object)) {
$object = (object) $object;
}
$points = (float) ($object->points ?? 0);
$object->margin = $growth_bonus;
$object->commission = round($points / 100 * $growth_bonus, 2);
$object->growth_bonus = true;
$commission_growth_total += $object->commission;
}
$this->b_user->business_lines[$i] = $object;
}
}
return $commission_growth_total;
}
// ===== WEITERE ORIGINAL-METHODEN (gekürzt, vollständige Implementation in Original) =====
//aktuelles level berechnen, max das eigene level, wenn weniger Points dann darunter
/**
* Berechnet das aktuell erreichte Level
* Durchläuft alle möglichen Levels (max. bis zur eigenen User-Level-Position)
* und prüft dynamisch die Qualifikation basierend auf den spezifischen qual_kp und qual_pp des jeweiligen Levels
*/
public function calcuQualLevel()
{
\Log::debug("BusinessUserItemOptimized: calcuQualLevel for user {$this->b_user->user_id} ({$this->date->month}/{$this->date->year})");
// Hole alle möglichen Levels bis zur eigenen Position, sortiert nach Position absteigend
// um vom höchsten zum niedrigsten zu prüfen
$qualUserLevels = UserLevel::where('qual_kp', '<=', $this->b_user->sales_volume_points_KP_sum)
->where('pos', '<=', $this->user_level_active_pos)
->orderBy('qual_pp', 'desc')
->orderBy('pos', 'desc') // Sortiere nach Position DESC, um das höchste Level zuerst zu prüfen
->get();
foreach ($qualUserLevels as $qualUserLevel) {
// Berechne die Payline-Punkte für die spezifischen Paylines dieses Levels
$payline_points = $this->getPointsforPayline($qualUserLevel->paylines);
$payline_points_qual_kp = $payline_points + $this->getRestQualKP();
\Log::debug("BusinessUserItemOptimized: payline_points: " . $payline_points);
// WICHTIG: Berechne die Rest-KP basierend auf der qual_kp DES AKTUELL GEPRÜFTEN LEVELS
// nicht der qual_kp des bereits gesetzten Levels (das war der Fehler!)
$rest_kp = max(0, $this->b_user->sales_volume_points_KP_sum - $qualUserLevel->qual_kp);
$payline_points_qual_kp = $payline_points + $rest_kp;
// Prüfe ob die Qualifikation für diesen spezifischen Level erfüllt ist
if ($payline_points_qual_kp >= $qualUserLevel->qual_pp) {
// Setze die berechneten Werte
$this->b_user->calc_qual_kp = $rest_kp > 0 ? $qualUserLevel->qual_kp : $this->b_user->sales_volume_points_KP_sum;
$this->b_user->payline_points = $payline_points;
$this->b_user->payline_points_qual_kp = $payline_points_qual_kp;
$qualUserLevel->_calculated_qual_kp = $rest_kp > 0 ? $qualUserLevel->qual_kp : $this->b_user->sales_volume_points_KP_sum;
$qualUserLevel->_calculated_payline_points = $payline_points;
$qualUserLevel->_calculated_payline_points_qual_kp = $payline_points_qual_kp;
\Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} qualifies for level {$qualUserLevel->name} (pos: {$qualUserLevel->pos}) - Payline Points: {$payline_points}, Rest KP: {$rest_kp}, Total: {$payline_points_qual_kp} >= {$qualUserLevel->qual_pp}");
return $qualUserLevel;
}
}
\Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} does not qualify for any level");
return null;
}
private function getPointsforPayline($paylines): float
{
\Log::debug("BusinessUserItemOptimized: getPointsforPayline for user {$this->b_user->user_id} ({$this->date->month}/{$this->date->year}) with paylines: " . $paylines . " and business_lines: " . json_encode($this->b_user->business_lines));
$payline_points = 0;
for ($i = 1; $i <= $paylines; $i++) {
if (isset($this->b_user->business_lines[$i])) {
@ -642,7 +883,18 @@ class BusinessUserItemOptimized
->orderBy('qual_pp', 'asc')
->first();
if ($qualUserLevelNext) {
$this->b_user->qual_user_level_next = $qualUserLevelNext->toArray();
// Berechne die spezifischen Werte für diesen Level
$payline_points = $this->getPointsforPayline($qualUserLevelNext->paylines);
$rest_kp = max(0, $this->b_user->sales_volume_points_KP_sum - $qualUserLevelNext->qual_kp);
$payline_points_qual_kp = $payline_points + $rest_kp;
// Speichere Level-Daten mit berechneten Werten
$levelData = $qualUserLevelNext->toArray();
$levelData['_calculated_qual_kp'] = $rest_kp > 0 ? $qualUserLevelNext->qual_kp : $this->b_user->sales_volume_points_KP_sum;
$levelData['_calculated_payline_points'] = $payline_points;
$levelData['_calculated_payline_points_qual_kp'] = $payline_points_qual_kp;
$this->b_user->qual_user_level_next = $levelData;
} else {
$this->b_user->qual_user_level_next = null;
}
@ -653,24 +905,50 @@ class BusinessUserItemOptimized
private function setNextUserLevel($force = false): void
{
//sucht den nächsten level, der mehr points hat als das aktuelle level
$nextQualUserLevel = UserLevel::where('qual_pp', '<=', $this->b_user->payline_points_qual_kp)
->where('pos', '>', $this->user_level_active_pos)
->orderBy('qual_pp', 'desc')
// Hole nur den direkt nächsten Level (keine Level überspringen!)
$nextLevel = UserLevel::where('pos', '=', $this->user_level_active_pos + 1)
->first();
//wenn der nächste level qualifiziert ist und die KP-Qualifikation erfüllt ist, dann setzt es den nächsten level
if ($nextQualUserLevel && $this->isQualKP()) {
$this->b_user->next_qual_user_level = $nextQualUserLevel->toArray();
$this->b_user->next_can_user_level = null;
} else {
//wenn der nächste level nicht qualifiziert ist, dann sucht es den nächsten level, nach pos
$nextCanUserLevel = UserLevel::where('pos', '>', $this->user_level_active_pos)
->orderBy('qual_pp', 'asc')
->first();
if ($nextCanUserLevel) {
$this->b_user->next_can_user_level = $nextCanUserLevel->toArray();
}
// Wenn kein nächster Level existiert, beende
if (!$nextLevel) {
$this->b_user->next_qual_user_level = null;
$this->b_user->next_can_user_level = null;
\Log::debug("BusinessUserItemOptimized: No next level found for user {$this->b_user->user_id} (already at highest level)");
return;
}
// Berechne die Payline-Punkte für die spezifischen Paylines des nächsten Levels
$payline_points = $this->getPointsforPayline($nextLevel->paylines);
// Berechne die Rest-KP basierend auf dem nächsten Level
$rest_kp = max(0, $this->b_user->sales_volume_points_KP_sum - $nextLevel->qual_kp);
$payline_points_qual_kp = $payline_points + $rest_kp;
// Erstelle Level-Daten mit berechneten Werten
$levelData = $nextLevel->toArray();
$levelData['_calculated_qual_kp'] = $rest_kp > 0 ? $nextLevel->qual_kp : $this->b_user->sales_volume_points_KP_sum;
$levelData['_calculated_payline_points'] = $payline_points;
$levelData['_calculated_payline_points_qual_kp'] = $payline_points_qual_kp;
// Prüfe die KP-Qualifikation für den nächsten Level
if ($this->b_user->sales_volume_points_KP_sum < $nextLevel->qual_kp) {
// KP-Qualifikation nicht erfüllt - zeige als "next_can_user_level"
$this->b_user->next_can_user_level = $levelData;
$this->b_user->next_qual_user_level = null;
\Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} does not meet KP requirement for next level {$nextLevel->name} ({$this->b_user->sales_volume_points_KP_sum} < {$nextLevel->qual_kp})");
return;
}
// Prüfe ob die PP-Qualifikation erfüllt ist
if ($payline_points_qual_kp >= $nextLevel->qual_pp) {
// Qualifiziert für den nächsten Level
$this->b_user->next_qual_user_level = $levelData;
$this->b_user->next_can_user_level = null;
\Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} qualifies for next level {$nextLevel->name} (Payline Points: {$payline_points}, Rest KP: {$rest_kp}, Total: {$payline_points_qual_kp} >= {$nextLevel->qual_pp})");
} else {
// PP-Qualifikation nicht erfüllt - zeige als "next_can_user_level"
$this->b_user->next_can_user_level = $levelData;
$this->b_user->next_qual_user_level = null;
\Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} does not meet PP requirement for next level {$nextLevel->name} ({$payline_points_qual_kp} < {$nextLevel->qual_pp})");
}
}
@ -680,7 +958,15 @@ class BusinessUserItemOptimized
->orderBy('qual_pp', 'asc')
->first();
if ($qualUserLevelNext) {
$this->b_user->qual_user_level_next = $qualUserLevelNext->toArray();
$payline_points = $this->getPointsforPayline($qualUserLevelNext->paylines);
// Berechne die Rest-KP basierend auf dem nächsten Level
$rest_kp = max(0, $this->b_user->sales_volume_points_KP_sum - $qualUserLevelNext->qual_kp);
$payline_points_qual_kp = $payline_points + $rest_kp;
$levelData = $qualUserLevelNext->toArray();
$levelData['_calculated_qual_kp'] = $rest_kp > 0 ? $qualUserLevelNext->qual_kp : $this->b_user->sales_volume_points_KP_sum;
$levelData['_calculated_payline_points'] = $payline_points;
$levelData['_calculated_payline_points_qual_kp'] = $payline_points_qual_kp;
$this->b_user->qual_user_level_next = $levelData;
}
}

View file

@ -0,0 +1,136 @@
# Ergänzung: Block-Status Erkennung
Das Problem ist aktuell, dass wir zwar den `growth_bonus` aus dem Qualifikations-Level auslesen, aber nicht explizit wissen, ob dieser User für den Upline-Partner als "Blocker" gilt oder nicht.
## Die Logik:
Ein User gilt als "Blocker" (bzw. er beansprucht Schutz), wenn:
1. Er ein Level erreicht hat (`isQualLevel()`).
2. Dieses Level einen `growth_bonus > 0` hat.
Das ist bereits in `GrowthBonusCalculator::getVolumeByProtectionLevel` und `collectLegLevels` implementiert:
```php
if ($item->isQualLevel()) {
$qual = $item->qual_user_level;
$growthBonus = is_array($qual) ? ($qual['growth_bonus'] ?? 0) : ($qual->growth_bonus ?? 0);
if ($growthBonus > 0) {
$myProtectionPercent = (float) $growthBonus;
}
}
```
## Was fehlt / optimiert werden muss:
Wenn du sagst "User die einen Level haben wo tiefebonus und der level auch erreicht ist ... wird der bonus geblockt oder geteilt", dann meinst du wahrscheinlich, dass wir sicherstellen müssen, dass `qual_user_level` korrekt gesetzt ist **UND** wir diesen Status auch in der Matrix-Ansicht korrekt als "Block" visualisieren.
Aktuell zeigt die Matrix "Block (0%)" nur an, wenn die rechnerische Differenz <= 0 ist.
Es wäre hilfreich, in der Matrix anzuzeigen, **WARUM** blockiert wird (z.B. "Blocked by Silver").
### Anpassung in `GrowthBonusCalculator::getMatrixDetails` (bzw. `collectLegLevels`)
Wir erweitern die Detail-Informationen in der Matrix um den Status des Users, der den Schutz erhöht hat.
In `collectLegLevels`:
Wir müssen erkennen, ob der aktuelle User den Schutz erhöht hat.
```php
// Protection Check
$userProtection = 0.0;
$blockerLevelName = null;
if ($item->isQualLevel()) {
$qual = $item->qual_user_level;
$growth = is_array($qual) ? ($qual['growth_bonus'] ?? 0) : ($qual->growth_bonus ?? 0);
if ($growth > 0) {
$userProtection = (float) $growth;
$blockerLevelName = is_array($qual) ? ($qual['name'] ?? '') : ($qual->name ?? '');
}
}
// Hat dieser User den Schutz erhöht?
$isBlocker = $userProtection > $incomingProtection;
$effectiveProtection = max($incomingProtection, $userProtection);
```
Diese Info speichern wir in `$legData['levels'][$level]`.
## Code-Anpassung
Ich werde `GrowthBonusCalculator.php` anpassen, um diese Meta-Daten zu erfassen, damit wir sie in der View anzeigen können.
### Schritt 1: `GrowthBonusCalculator.php` - `collectLegLevels` erweitern
```php
private function collectLegLevels(BusinessUserItemOptimized $item, int $level, float $incomingProtection, float $myPercent, array &$legData)
{
// 1. Eigenen Status ermitteln
$userProtection = 0.0;
$userLevelName = '';
if ($item->isQualLevel()) {
$qual = $item->qual_user_level;
$growth = is_array($qual) ? ($qual['growth_bonus'] ?? 0) : ($qual->growth_bonus ?? 0);
if ($growth > 0) {
$userProtection = (float) $growth;
$userLevelName = is_array($qual) ? ($qual['name'] ?? '') : ($qual->name ?? '');
}
}
// Berechnung
$volume = (float) ($item->sales_volume_points_TP_sum ?? 0);
if ($volume > 0) {
$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' => []
];
}
$legData['levels'][$level]['volume'] += $volume;
$legData['levels'][$level]['commission'] += $commission;
// Erweiterte Details speichern
$legData['levels'][$level]['details'][] = [
'u' => $item->user_id,
'name' => $item->first_name . ' ' . $item->last_name,
'level' => $userLevelName, // Welchen Status hat dieser User?
'prot_own' => $userProtection, // Welchen Schutz baut er selbst auf?
'prot_in' => $incomingProtection, // Welcher Schutz kam von oben?
'percent' => $diffPercent
];
$legData['total_volume'] += $volume;
$legData['total_commission'] += $commission;
}
// Protection für nächste Ebene
$nextProtection = max($incomingProtection, $userProtection);
// Rekursion ...
}
```
### Schritt 2: View Update
In der View können wir nun anzeigen, wenn ein User auf einer Ebene einen Status hat (Tooltip oder Icon).
Da wir in der Matrix pro Ebene aggregieren (falls ein Bein sich verzweigt, was hier in `collectLegLevels` durch die Rekursion über `businessUserItems` passiert - Moment, ein "Bein" ist hier linear in der Tiefe, aber in jeder Tiefe kann es Breite geben).
Warte, `collectLegLevels` geht `foreach ($item->businessUserItems as $child)`.
Das bedeutet, ein "Leg" in der Matrix ist eigentlich ein ganzer Unterbaum.
In Ebene 2 können also mehrere User sein (alle Enkel in diesem Bein).
Wenn wir in der Matrix nur EINE Zelle pro Ebene haben, müssen wir entscheiden, was wir anzeigen.
Die Summe (€/Volumen) ist korrekt.
Aber der Status ("Block") kann unterschiedlich sein (ein Enkel ist Silber, der andere nicht).
Lösung: Wir markieren die Zelle als "Teilweise Blockiert" oder zeigen Details im Tooltip an.
Ich passe zuerst den `GrowthBonusCalculator` an, um diese Daten bereitzustellen.

View file

@ -0,0 +1,193 @@
# Konzept: Erweiterte Detailansicht für Growth Bonus (Matrix-View)
Die Anforderung ist, eine **Matrix-Ansicht** zu erstellen, bei der die Ebenen (Level 1, 2, 3...) als Spalten und die einzelnen Linien (Legs/Beine) als Zeilen dargestellt werden. Dies soll auch dann geschehen, wenn der Bonus gekappt ist, um volle Transparenz zu gewährleisten.
## 1. Datenstruktur-Erweiterung (`GrowthBonusCalculator`)
Die bisherige Aggregation (`getVolumeByProtectionLevel`) gruppiert Volumen nach "Schutz-Level". Das ist gut für die Berechnung, aber für die Visualisierung "Ebene für Ebene" brauchen wir die Rohdaten pro Ebene.
Wir benötigen eine neue Methode `getMatrixDetails`, die rekursiv die Struktur traversiert und für jedes Bein eine flache Liste von Ebenen-Volumen zurückgibt, angereichert mit Status-Informationen.
### Struktur des Ergebnis-Arrays:
```php
[
// Ein Eintrag pro Firstline (Bein)
[
'user' => [ 'id' => 123, 'name' => 'Max Mustermann', 'level' => 'Gold' ],
'levels' => [
1 => [ // Ebene 1 (relativ zu mir, also der Firstline-User selbst)
'volume' => 500,
'user_level' => 'Gold',
'protection_percent' => 2.0, // Was der User für sich beansprucht
'my_percent' => 2.5, // Mein Anspruch
'diff_percent' => 0.5, // Resultierende Provision
'commission' => 2.50,
'is_blocked' => false
],
2 => [ // Ebene 2 (User unter Max)
'volume' => 1000,
'user_level' => 'Silver',
'protection_percent' => 1.5,
'my_percent' => 2.5,
'diff_percent' => 1.0,
'commission' => 10.00,
'is_blocked' => false
],
// ... weitere Ebenen bis max Tiefe oder Abbruchbedingung
],
'totals' => [ 'volume' => 1500, 'commission' => 12.50 ]
],
// ... weitere Beine
]
```
## 2. Implementierungsschritte
1. **`GrowthBonusCalculator.php`**: Methode `getMatrixDetails` hinzufügen.
* Muss rekursiv durch die `businessUserItems` laufen.
* Muss tracken, welcher "Schutz-Level" von oben kommt (rekursiv weitergegeben).
* Muss aber `protection_percent` lokal pro User neu bewerten (max(incoming, own)).
2. **`BusinessUserItemOptimized.php`**: Aufruf in `getGrowthBonusBreakdown` anpassen oder neue Methode `getGrowthBonusMatrix` hinzufügen.
3. **View `_user_detail_in.blade.php`**: Umbau der Tabelle zu einer Matrix.
### Herausforderung: Tiefe und Breite
Eine komplette Matrix kann sehr breit und lang werden.
* **Begrenzung:** Wir sollten die Tiefe standardmäßig begrenzen (z.B. 10-20 Ebenen) oder nur relevante Ebenen (wo Volumen > 0) anzeigen.
* **Breite:** In der Tabelle werden die Spalten "Ebene 1", "Ebene 2", ... sein.
## 3. Code-Anpassung `GrowthBonusCalculator.php`
```php
/**
* 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;
}
private function collectLegLevels(BusinessUserItemOptimized $item, int $level, float $incomingProtection, float $myPercent, array &$legData)
{
// 1. Eigenen Status ermitteln (Schutz für Downline)
$myProtection = 0.0;
if ($item->isQualLevel()) {
$qual = $item->qual_user_level;
$growth = is_array($qual) ? ($qual['growth_bonus'] ?? 0) : ($qual->growth_bonus ?? 0);
if ($growth > 0) {
$myProtection = (float) $growth;
}
}
// Der effektive Schutz, der AUF diesen User wirkt (von oben kommend + sein eigener Anspruch)
// WICHTIG: Für die Provision auf DIESEN User zählt der $incomingProtection (Schutz von oben).
// Für die Weitergabe nach unten zählt max($incoming, $myProtection).
// Berechnung für diesen User (Ebene)
$volume = (float) ($item->sales_volume_points_TP_sum ?? 0);
if ($volume > 0) {
// Differenz: Mein Anspruch - Schutz von oben
$diffPercent = max(0, $myPercent - $incomingProtection);
$commission = round($volume / 100 * $diffPercent, 2);
// Speichern in Matrix
// Wir summieren Volumen pro Ebene (falls durch parallele Zweige im Bein mehrere User auf gleicher Ebene sind - hier aber linearer Abstieg)
// Moment, businessUserItems ist ein Baum. Ein Bein kann breit werden.
// Wir müssen pro Ebene summieren.
if (!isset($legData['levels'][$level])) {
$legData['levels'][$level] = [
'volume' => 0.0,
'commission' => 0.0,
'details' => [] // Optional für Hover
];
}
$legData['levels'][$level]['volume'] += $volume;
$legData['levels'][$level]['commission'] += $commission;
// Metadaten für Anzeige (nur beim ersten Eintrag pro Ebene oder aggregiert?)
// Bei Matrix-View (Spalten=Ebenen) summieren wir alles auf Ebene X in diesem Bein.
// Das "Problem": In Ebene X können User mit unterschiedlichem Schutz-Status sein.
// Daher ist eine einfache Summe evtl. irreführend bei der %-Anzeige.
// Alternative: Wir zeigen pro Ebene den "dominanten" Status oder listen auf.
// Für die Tabelle ist eine Zelle pro Ebene vorgesehen.
// Wir speichern Detail-Infos für Tooltip.
$legData['levels'][$level]['details'][] = [
'u' => $item->user_id,
'v' => $volume,
'p' => $incomingProtection, // Protected by
'd' => $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, $myProtection);
// Rekursion
// Max Tiefe z.B. 20
if ($level < 20 && !empty($item->businessUserItems)) {
foreach ($item->businessUserItems as $child) {
$this->collectLegLevels($child, $level + 1, $nextProtection, $myPercent, $legData);
}
}
}
```
## 4. Design der Tabelle (Blade)
Spalten: Leg (Partner) | Ebene 1 | Ebene 2 | Ebene 3 | ... | Ebene 10 | Total
Zeilen: Partner A | ... | ... | ...
Zellen-Inhalt:
* Oben: Provision (€)
* Unten: Volumen (Pkt)
* Farbe: Grün (Volle %), Gelb (Teil %), Rot (0% / Block)
Da die Ebenen dynamisch sind, ermitteln wir `max_level` über alle Legs.

View file

@ -0,0 +1,399 @@
# Funktionsweise: Tiefenbonus (Growth Bonus)
## ⚠️ WICHTIG: Bug-Fix November 2025
### Das Problem (vor November 2025)
Die Payline-Prozentsätze (`pr_line_1` bis `pr_line_6`) in der Datenbank enthielten **bereits den Growth Bonus**.
**Beispiel Gold Member (falsche Berechnung):**
| Ebene | Wert in DB (`pr_line_X`) | Was ausgezahlt wurde | Was korrekt gewesen wäre |
| ------- | ------------------------ | -------------------- | ------------------------------ |
| Ebene 1 | 9% | 9% | 7% Payline + 2% Growth = 9% |
| Ebene 2 | 9% | 9% | 7% Payline + 2% Growth = 9% |
| Ebene 3 | 9% | 9% | 7% Payline + 2% Growth = 9% |
| Ebene 4 | 6% | 6% | 4% Payline + 2% Growth = 6% |
| Ebene 5 | 4% | 4% | 2% Payline + 2% Growth = 4% |
| Ebene 6 | 4% | 4% | 2% Payline + 2% Growth = 4% |
| Ebene 7 | - | 2% (Growth nochmal!) | 2% Growth (nur mit Differenz!) |
**Problem:** Der Growth Bonus wurde **doppelt gezählt**:
1. Einmal IN den Payline-Prozentsätzen (pr_line_1 = 9% statt 7%)
2. Nochmal SEPARAT auf Ebenen ab 7+ (Legacy-Berechnung)
### Die Lösung (ab November 2025)
1. **Payline-Prozentsätze korrigiert:** `pr_line_X` enthält NUR den Payline-Anteil
2. **Growth Bonus separat:** Wird mit Differenz-Logik berechnet
3. **Einmal pro Bein:** Growth Bonus wird nur EINMAL pro Firstline-Zweig ausgezahlt
**Beispiel Gold Member (korrekte Berechnung):**
| Ebene | Payline (`pr_line_X`) | Growth Bonus (separat) | Gesamt |
| -------- | --------------------- | ---------------------- | ------ |
| Ebene 1 | 7% | +2% (Differenz-Logik) | 9% |
| Ebene 2 | 7% | +2% | 9% |
| Ebene 3 | 7% | +2% | 9% |
| Ebene 4 | 4% | +2% | 6% |
| Ebene 5 | 2% | +2% | 4% |
| Ebene 6 | 2% | +2% | 4% |
| Ebene 7+ | - | +2% (Differenz-Logik) | 2% |
**Wichtig:** Der Growth Bonus wird NUR ausgezahlt, wenn kein gleichrangiger oder höherer Partner in der Downline ist (Differenz-Berechnung)!
---
## Differenz-Logik (ab November 2025)
Der Tiefenbonus ist ein **Differenz-Bonus**, der **sofort ab der 1. Ebene** beginnt.
Es gilt das Prinzip: **"Jeder Partner schützt sein eigenes Team-Volumen."**
### 1. Die Grundregel
- **Start:** Der Bonus berechnet sich auf Points ab der **1. Ebene** (direkte Downline).
- **Anspruch:** Ein Partner erhält seinen Status-Prozentsatz auf alle Points in seiner Linie, **bis** er auf einen Partner trifft, der selbst einen Status-Anspruch hat.
- **Blockade:** Sobald ein Partner in der Downline einen Anspruch hat, zieht er diesen von der Upline ab (Differenz-Rechnung).
- **⚠️ WICHTIG - Erreichtes Qualifikations-Level:** Die Blockade erfolgt NUR basierend auf dem **in dem Monat tatsächlich erreichten Level** (`qual_user_level`), NICHT auf dem aktuellen Karriere-Level des Partners!
### 1.1 Erreichte Qualifikation vs. Aktuelles Level
Ein Partner kann ein bestimmtes Karriere-Level (z.B. Gold) haben, aber in einem Monat die Qualifikationsvoraussetzungen nicht erfüllen. In diesem Fall:
| Situation | Aktuelles Level | Erreicht in Monat | Blockiert mit |
| --------- | --------------- | ----------------- | ------------- |
| Fall A | Gold (2%) | Gold qualifiziert | 2% ✅ |
| Fall B | Gold (2%) | Team Leader (0%) | 0% ❌ |
| Fall C | Team Leader | Silber (1.5%) | 1.5% ✅ |
**Technische Umsetzung:**
- Die Methode `getQualifiedGrowthBonus()` in `BusinessUserItemOptimized` gibt den Growth Bonus basierend auf dem **erreichten Qualifikations-Level** (`qual_user_level`) zurück.
- Die alte Methode `getActiveGrowthBonus()` gibt den Growth Bonus basierend auf dem **aktuellen Karriere-Level** zurück (NUR für Legacy-Berechnungen!).
- Der `GrowthBonusCalculator` verwendet ab November 2025 ausschließlich `getQualifiedGrowthBonus()`.
---
### 2. Die Differenz (Der Normalfall)
Points entstehen irgendwo im Team von **Partner B** (egal ob in B's Ebene 1 oder B's Ebene 50).
**Die Verteilung:**
1. **Sicht Partner B (Silber):**
- Er hat Anspruch auf **1,5 %** auf sein gesamtes Team.
- Da unter ihm (Partner C) niemand einen Status hat, der etwas wegnehmen könnte, erhält B die vollen **1,5 %**.
- Damit sind 1,5 % des "Kuchens" verteilt.
2. **Sicht Partner A (Diamant):**
- Du hast Anspruch auf **2,5 %**.
- Du schaust auf die Linie von Partner B.
- Partner B hat den Status Silber und beansprucht damit **1,5 %** für sich und sein ganzes Team.
- **Deine Rechnung:** 2,5 % (Dein Anspruch) - 1,5 % (Anspruch B) = **1,0 %**.
- **Ergebnis:** Du erhältst auf das gesamte Volumen unter Partner B exakt **1,0 %**.
---
### 2. Das "GAP" (Die direkte Ebene)
Da der Bonus ab Ebene 1 beginnt, entsteht das GAP (die Auszahlung trotz gleichem Rang) immer am **Eigenumsatz des Partners**:
- **Partner A** (Diamant, 2,5 %) ist Sponsor von **Partner B** (Diamant, 2,5 %).
- **Punkte von B (Eigenbestellung/Kunden):**
- Partner B erhält darauf _keinen_ Tiefenbonus (man kriegt keinen Tiefenbonus auf sich selbst).
- Partner B zieht also **0 %** vom Topf ab.
- **Partner A erhält die vollen 2,5 % auf die Punkte von B.**
- **Punkte UNTER B (Team von B):**
- Partner B greift hier zu (Start ab Ebene 1) und nimmt sich **2,5 %**.
- Partner A rechnet: 2,5 % - 2,5 % = **0 %**.
- **Partner A ist hier blockiert.**
> Fazit: Bei gleichem Rang verdient man nur an den direkten Points des Partners (GAP), aber nicht mehr an dessen Team.
---
### 3. Das Szenario (A -> B -> F)
Wir schauen uns deine Struktur mit 3 Diamanten in einer Linie an. Alle haben Anspruch auf **2,5 %**.
- **Partner A** (Ebene 1)
- **Partner B** (Ebene 2, direkt unter A)
- ... dazwischen Berater ohne Status ...
- **Partner F** (Ebene 6, unter B)
- ... Punkte entstehen unter F ...
### Bereich 1: Punkte von Partner B
- Das ist für **A** die Ebene 1.
- B blockiert nicht (da Eigenumsatz).
- **Ergebnis:** **A erhält 2,5 %**.
### Bereich 2: Punkte ZWISCHEN B und F (Ebene 3 bis 6)
- Hier entstehen Punkte im Team von B.
- **Sicht B:** Er ist qualifiziert (Start ab Ebene 1). Er nimmt sich **2,5 %**.
- **Sicht A:** Er hat Anspruch auf 2,5 %. B hat aber schon 2,5 % genommen. Differenz = 0 %.
- **Ergebnis:** **B erhält 2,5 %**. A geht leer aus.
### Bereich 3: Punkte von Partner F
- Das ist für **B** eine Ebene in seiner Downline.
- F blockiert hier noch nicht (Eigenumsatz).
- **Ergebnis:** **B erhält 2,5 %** auf die Punkte von F.
### Bereich 4: Punkte UNTER F (ab Ebene 7)
- Hier entstehen Punkte im Team von F.
- **Sicht F:** Er ist qualifiziert (Start ab Ebene 1). Er nimmt sich **2,5 %**.
- **Sicht B:** Anspruch 2,5 %. F hat schon 2,5 % genommen. Differenz = 0 %.
- **Sicht A:** Anspruch 2,5 %. B (und F) haben alles genommen. Differenz = 0 %.
- **Ergebnis:** **F erhält 2,5 %**. B und A gehen leer aus.
---
### 4. Zusammenfassung für die IT-Logik
1. **Trigger:** Ein Umsatz (Points) entsteht bei User X.
2. **Schleife:** Gehe die Upline hoch (Sponsor -> Sponsor...).
3. **Prüfung:**
- Hat der Upline-Partner einen Status? (z.B. Diamant).
- (Keine Prüfung auf Ebene mehr nötig, da Start immer ab Ebene 1).
4. **Rechnung:**
- Auszahlung = Mein %-Satz - Bereits verteilter %-Satz.
- Wenn Auszahlung > 0: Speichern.
- Setze `Bereits verteilter %-Satz` auf den neuen Wert (also `Mein %-Satz`).
---
## Code-Implementierung
Diese Implementierung nutzt eine **rekursive Aggregation von Volumen nach "Schutz-Level"**.
Anstatt für jede Transaktion die Upline hochzulaufen ("Push"), holt sich der User die aggregierten Volumina seiner Downline gruppiert nach dem bereits beanspruchten Prozentsatz ("Pull").
### A. Neue Methode `getVolumeByProtectionLevel()`
Diese Methode liefert ein Array zurück, das das Volumen nach "bereits verteiltem Prozentsatz" gruppiert.
Format: `['0.0' => 1000, '1.5' => 5000, ...]`
```php
/**
* Liefert das Volumen der Downline gruppiert nach dem "bereits verteilten Prozentsatz" (Protection Level).
* Rekursive Funktion, die die "Differenz-Logik" vorbereitet.
*
* @return array<string, float> Key = Protected Percent, Value = Volume Points
*/
public function getVolumeByProtectionLevel(): array
{
$volumes = [];
// 1. Eigenes Volumen (Unprotected / GAP)
// Man selbst schützt seinen eigenen Umsatz NICHT vor der Upline.
// Daher Start mit Protection Level 0.0 (oder dem was von unten kommt, aber hier ist es ja Eigenumsatz)
// WICHTIG: Wir nutzen das Feld, das auch TreeCalcBot für die Punkte nutzt
// sales_volume_points_TP_sum scheint in der DB/Model Logik für das relevante Volumen zu stehen
$ownVolume = (float) ($this->b_user->sales_volume_points_TP_sum ?? 0);
if ($ownVolume > 0) {
$key = '0.0';
if (!isset($volumes[$key])) $volumes[$key] = 0.0;
$volumes[$key] += $ownVolume;
}
// 2. Mein Schutz-Level ermitteln
// Das ist der Prozentsatz, den ICH auf mein Team beanspruche.
// Alles Volumen, das durch MICH hindurch zur Upline fließt, hat mindestens diesen Schutz-Level.
$myProtectionPercent = 0.0;
if ($this->isQualLevel()) {
$qual = $this->b_user->qual_user_level;
if (!empty($qual['growth_bonus'])) {
$myProtectionPercent = (float) $qual['growth_bonus'];
}
}
// 3. Kinder verarbeiten
if (!empty($this->businessUserItems)) {
foreach ($this->businessUserItems as $childItem) {
// Rekursion: Hol dir die Volumen-Töpfe aus der Downline
// Hinweis: Hier muss sichergestellt sein, dass die Kinder geladen sind.
// initBusinesslUserDetail lädt normalerweise die Struktur.
// Falls Kinder nicht geladen sind, müssten sie hier theoretisch geladen werden.
// Wir gehen davon aus, dass die Struktur bereits rekursiv via readParentsBusinessUsers geladen wurde.
$childVolumes = $childItem->getVolumeByProtectionLevel();
// 4. Schutz-Level anwenden (Aggregation)
foreach ($childVolumes as $protectedPercentStr => $vol) {
$incomingProtection = (float) $protectedPercentStr;
// Das Volumen ist bereits mit $incomingProtection geschützt.
// Da es nun durch MICH fließt, erhöht sich der Schutz auf MEINEN Level (falls meiner höher ist).
$effectiveProtection = max($incomingProtection, $myProtectionPercent);
$newKey = (string) $effectiveProtection;
if (!isset($volumes[$newKey])) $volumes[$newKey] = 0.0;
$volumes[$newKey] += $vol;
}
}
}
return $volumes;
}
```
### B. Neue Methode `calculateGrowthBonusRecursive()`
Diese Methode ersetzt die bisherige Berechnung und nutzt die oben definierte Aggregation.
```php
/**
* Berechnet den Growth Bonus (Tiefenbonus) basierend auf der Differenz-Logik.
*/
private function calculateGrowthBonusRecursive($qualUserLevel): float
{
if (empty($qualUserLevel->growth_bonus) || $qualUserLevel->growth_bonus <= 0) {
return 0.0;
}
$myGrowthPercent = (float) $qualUserLevel->growth_bonus;
$totalGrowthBonus = 0.0;
// Wir iterieren über alle direkten Beine (Firstlines)
foreach ($this->businessUserItems as $childItem) {
// Volumen-Verteilung aus diesem Bein abrufen
// Das Kind liefert uns: "Hier sind 1000 Punkte geschützt mit 0%, 5000 Punkte geschützt mit 1.5%"
$volumeDistribution = $childItem->getVolumeByProtectionLevel();
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;
// Optional Logging
// \Log::debug("Growth Bonus: User {$this->b_user->user_id} earns {$mySharePercent}% on {$volume} pts (Protected: {$alreadyDistributedPercent}%) from leg {$childItem->b_user->user_id}");
}
}
}
return $totalGrowthBonus;
}
```
### C. Integration in `calculateCommissions`
```php
private function calculateCommissions($qualUserLevel): void
{
$commission_pp_total = 0;
// 1. Normale Unilevel Provision (Payline) - NUR pr_line_X Werte
for ($i = 1; $i <= $qualUserLevel->paylines; $i++) {
if (isset($this->b_user->business_lines[$i])) {
$object = $this->b_user->business_lines[$i];
$margin = (float) $this->b_user->qual_user_level['pr_line_' . $i];
$points = is_array($object) ? ((float)($object['points'] ?? 0)) : ((float)($object->points ?? 0));
$commission = round($points / 100 * $margin, 2);
$commission_pp_total += $commission;
// Rückschreiben
if (is_array($object)) {
$object['margin'] = $margin;
$object['commission'] = $commission;
$object['payline'] = true;
} else {
$object->margin = $margin;
$object->commission = $commission;
$object->payline = true;
}
$this->b_user->business_lines[$i] = $object;
}
}
// 2. Growth Bonus - Unterscheidung Legacy vs. Neu
$commission_growth_total = 0;
if (!empty($qualUserLevel->growth_bonus)) {
// Stichtag: 01.11.2025
$isLegacy = ($this->date->year < 2025) ||
($this->date->year == 2025 && $this->date->month < 11);
if ($isLegacy) {
// ALT: Pauschal ab Ebene paylines+1 (FALSCH - doppelte Auszahlung!)
$commission_growth_total = $this->calculateLegacyGrowthBonus($qualUserLevel);
} else {
// NEU: Differenz-Logik via GrowthBonusCalculator
$commission_growth_total = $this->calculateGrowthBonusRecursive($qualUserLevel);
}
}
$this->b_user->commission_pp_total = $commission_pp_total;
$this->b_user->commission_growth_total = $commission_growth_total;
}
```
---
## Legacy-Berechnung (vor November 2025) - DEPRECATED
**⚠️ Diese Logik war FALSCH und führte zu doppelter Auszahlung!**
```php
/**
* ALT: Pauschal Growth Bonus ab Ebene paylines+1
* PROBLEM: Growth Bonus war bereits in pr_line_X enthalten!
*/
private function calculateLegacyGrowthBonus($qualUserLevel): float
{
$commission_growth_total = 0;
// Start ab Ebene paylines+1 (z.B. 7 bei Gold)
$payline = (int) ($this->b_user->qual_user_level['paylines'] ?? 0) + 1;
$maxlines = count($this->b_user->business_lines ?? []) + 1;
$growth_bonus = (float) ($this->b_user->qual_user_level['growth_bonus'] ?? 0);
// Auf JEDE Ebene ab payline wird der volle Growth Bonus gezahlt
// OHNE Differenz-Prüfung = FALSCH!
for ($i = $payline; $i <= $maxlines; $i++) {
if (isset($this->b_user->business_lines[$i])) {
$points = $this->b_user->business_lines[$i]['points'] ?? 0;
$commission = round($points / 100 * $growth_bonus, 2);
$commission_growth_total += $commission;
}
}
return $commission_growth_total;
}
```
**Warum war das falsch?**
1. `pr_line_1` bei Gold = 9% (enthielt bereits 2% Growth Bonus)
2. Growth Bonus wurde ab Ebene 7 NOCHMAL mit 2% berechnet
3. = **Doppelte Auszahlung** auf tieferen Ebenen
---
## Neue Berechnung (ab November 2025) - KORREKT
Der `GrowthBonusCalculator` verwendet die Differenz-Logik:
1. **Aggregation:** Sammelt Volumen gruppiert nach "Schutz-Level"
2. **Differenz:** Berechnet nur die Differenz (mein Anspruch - bereits verteilt)
3. **Einmal pro Bein:** Growth Bonus wird nur einmal pro Firstline-Zweig ausgezahlt
Siehe `GrowthBonusCalculator.php` für die Implementation.

View file

@ -0,0 +1,381 @@
<?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;
}
}
}

View file

@ -0,0 +1,448 @@
# BusinessPlan System - Gesamtübersicht
## 📋 Inhaltsverzeichnis
1. [System-Architektur](#system-architektur)
2. [Datei-Übersicht](#datei-übersicht)
3. [Datenfluss](#datenfluss)
4. [Punktetypen & Begriffe](#punktetypen--begriffe)
5. [Level-System](#level-system)
6. [Provisionsberechnung](#provisionsberechnung)
7. [Cron-Job (Monatsabschluss)](#cron-job-monatsabschluss)
8. [Dashboard-Integration](#dashboard-integration)
---
## System-Architektur
```
┌─────────────────────────────────────────────────────────────────────┐
│ FRONTEND / VIEWS │
├─────────────────────────────────────────────────────────────────────┤
│ dashboard/_statistics.blade.php │ dashboard/_points.blade.php │
│ user/team/*.blade.php │ admin/business/*.blade.php │
└───────────────────┬─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ CONTROLLER │
├─────────────────────────────────────────────────────────────────────┤
│ HomeController │ TeamController │ AdminController │
└───────────────────────┼─────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ BUSINESS PLAN SERVICES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ TreeCalcBotOptimized.php │ │
│ │ - Hauptklasse für MLM-Strukturberechnungen │ │
│ │ - Initialisiert Business-User Strukturen │ │
│ │ - Berechnet Punkte über alle Ebenen │ │
│ │ - Delegiert an Repository, Renderer, Calculator │ │
│ └──────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ Business │ │ BusinessUserItem │ │ GrowthBonus │ │
│ │ User │ │ Optimized.php │ │ Calculator.php │ │
│ │ Repository │ │ │ │ │ │
│ │ .php │ │ - User-Datenbehälter │ │ - Tiefenbonus │ │
│ │ │ │ - Qualifikations- │ │ - Differenz- │ │
│ │ - DB Queries │ │ berechnung │ │ Logik │ │
│ │ - Caching │ │ - Provisions- │ │ │ │
│ │ - Relations │ │ berechnung │ │ │ │
│ └──────────────┘ └──────────────────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ SalesPointsVolume.php │ │
│ │ - Punkteerfassung bei Bestellungen │ │
│ │ - KP/TP Punkte-Unterscheidung │ │
│ │ - Neuberechnung bei Änderungen │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ MODELS │
├─────────────────────────────────────────────────────────────────────┤
│ User │ UserBusiness │ UserSalesVolume │
│ UserLevel │ UserBusinessStruct │ UserAbo │
│ ShoppingOrder │ ShoppingUser │ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Datei-Übersicht
| Datei | Zweck | Abhängigkeiten |
| ------------------------------- | ---------------------------------------- | ------------------------------ |
| `TreeCalcBotOptimized.php` | Hauptklasse für MLM-Strukturberechnungen | Repository, Renderer, Logger |
| `BusinessUserItemOptimized.php` | User-Datenbehälter mit Berechnungslogik | GrowthBonusCalculator |
| `BusinessUserRepository.php` | Optimierte DB-Abfragen mit Caching | User, UserBusiness Models |
| `GrowthBonusCalculator.php` | Tiefenbonus-Berechnung (Differenz-Logik) | BusinessUserItemOptimized |
| `SalesPointsVolume.php` | Punkteerfassung bei Bestellungen | UserSalesVolume, ShoppingOrder |
| `TreeHtmlRenderer.php` | HTML-Ausgabe für Struktur-Ansichten | - |
| `TreeHelperOptimized.php` | Hilfsfunktionen für Tree-Operationen | - |
### Dokumentation
| Datei | Inhalt |
| ------------------------- | ---------------------------------------- |
| `TreeCalcBotOptimized.md` | Technische Dokumentation der Hauptklasse |
| `Growth-Bonus.md` | Erklärung der Tiefenbonus-Logik |
| `Growth-Bonus-Matrix.md` | Matrix-Darstellung des Tiefenbonus |
---
## Datenfluss
### 1. Punkteerfassung (bei Bestellung)
```
┌──────────────────┐ ┌────────────────────┐ ┌──────────────────┐
│ ShoppingOrder │────▶│ SalesPointsVolume │────▶│ UserSalesVolume │
│ wird erstellt │ │ ::addSalesPoints │ │ wird erstellt │
└──────────────────┘ │ VolumeUser() │ └──────────────────┘
└────────────────────┘ │
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ reCalculateSales │────▶│ month_KP_points │
│ PointsVolume() │ │ month_TP_points │
└────────────────────┘ │ month_shop_points│
└──────────────────┘
```
### 2. Strukturberechnung (Live oder Cron)
```
┌───────────────────────────────────────────────────────────────────────┐
│ TreeCalcBotOptimized │
│ │
│ 1. initStructureAdmin() oder initStructureUser() │
│ │ │
│ ├─▶ Prüfe gespeicherte Struktur (UserBusinessStructure) │
│ │ └─▶ Falls vorhanden und !forceLiveCalculation → laden │
│ │ │
│ └─▶ buildFreshStructure() │
│ │ │
│ ├─▶ loadRootUsers() - Top-Sponsoren ohne Parent │
│ │ │
│ ├─▶ loadParentsUsers() - Rekursive Downline │
│ │ └─▶ readParentsBusinessUsers() für jeden User │
│ │ │
│ ├─▶ calculateUserPointsOptimized() │
│ │ └─▶ Punkte pro Ebene (business_lines[1..n]) │
│ │ │
│ └─▶ calcQualPP() für jeden User │
│ ├─▶ calcuQualLevel() - Erreichte Stufe │
│ ├─▶ setNextUserLevel() - Nächste Stufe │
│ └─▶ calculateCommissions() - Provisionen │
│ ├─▶ Payline-Provisionen (Ebene 1-6) │
│ └─▶ Growth Bonus (ab Ebene 7+) │
│ └─▶ GrowthBonusCalculator │
└───────────────────────────────────────────────────────────────────────┘
```
### 3. Monatlicher Cron-Job
```
┌───────────────────────────────────────────────────────────────────────┐
│ BusinessUsersStoreOptimized (Cron) │
│ │
│ Ausführung: Einmal pro Monat (nach Monatsende) │
│ │
│ 1. storeUserBusinessStructure() │
│ └─▶ Erstellt UserBusinessStructure mit allen User-IDs │
│ │
│ 2. storeBusinessUsersDetail() │
│ └─▶ Für jeden User: initBusinesslUserDetail() mit forceLive=true │
│ └─▶ Speichert UserBusiness Datensatz │
│ │
│ 3. storeBusinessCompleted() │
│ └─▶ Markiert Struktur als "completed" │
└───────────────────────────────────────────────────────────────────────┘
```
---
## Punktetypen & Begriffe
### Punktearten
| Kürzel | Name | Beschreibung |
| ------ | -------------- | --------------------------------------------------- |
| **KP** | Kunden-Punkte | Eigene Bestellungen + Kundenbestellungen (Shop) |
| **TP** | Team-Punkte | KP + Punkte aus der Downline (Payline) |
| **PP** | Payline-Punkte | Summe der TP aus allen Ebenen bis zur Payline-Tiefe |
### Felder in UserSalesVolume
| Feld | Beschreibung |
| ------------------- | --------------------------------------------------------------------------- |
| `points` | Punkte dieser einzelnen Transaktion |
| `month_KP_points` | Kumulierte KP-Punkte des Monats |
| `month_TP_points` | Kumulierte TP-Punkte des Monats |
| `month_shop_points` | Kumulierte Shop-Punkte des Monats |
| `status` | 1=Berater-Bestellung, 2=Shop, 3=Shop-Pending, 4=Gutschrift, 5=Registrierung |
| `status_points` | 1=KP+TP, 2=nur KP |
### Felder in UserBusiness
| Feld | Beschreibung |
| ---------------------------- | ---------------------------------------------------------- |
| `sales_volume_KP_points` | KP-Punkte |
| `sales_volume_TP_points` | TP-Punkte |
| `sales_volume_points_shop` | Shop-Punkte |
| `sales_volume_points_KP_sum` | KP + Shop |
| `sales_volume_points_TP_sum` | TP + Shop |
| `payline_points` | Summe der Ebenen 1 bis Payline-Tiefe |
| `payline_points_qual_kp` | payline_points + Rest-KP |
| `business_lines` | JSON: Punkte pro Ebene {1: {points: X}, 2: {points: Y}...} |
| `qual_user_level` | Erreichtes Qualifikations-Level (Array) |
| `qual_user_level_next` | Nächste Provisions-Stufe |
| `next_qual_user_level` | Nächstes erreichbares Level |
| `commission_pp_total` | Payline-Provision |
| `commission_shop_sales` | Shop-Provision |
| `commission_growth_total` | Growth-Bonus (Tiefenbonus) |
---
## Level-System
### Qualifikationsbedingungen
| Level | Name | Min. KP (qual_kp) | Min. PP (qual_pp) | Paylines | Growth Bonus |
| ----- | -------------------- | ----------------- | ----------------- | -------- | ------------ |
| 1 | Junior Berater | 150 | 0 | 3 | - |
| 2 | Aktiv Junior Berater | 250 | 500 | 3 | - |
| 3 | Berater | 350 | 1.000 | 4 | - |
| 4 | Aktiv Berater | 450 | 2.500 | 5 | - |
| 5 | Vertriebspartner | 600 | 5.000 | 6 | - |
| 6 | Vertriebsleiter | 600 | 9.000 | 6 | - |
| 7 | Bronze Member | 600 (690\*) | 18.000 | 6 | 1,0% |
| 8 | Silber Member | 600 | 30.000 | 6 | 1,5% |
| 9 | Gold Member | 600 | 50.000 | 6 | 2,0% |
| 10 | Diamant Member | 600 | 100.000 | 6 | 2,5% |
| 11 | Platin Member\* | 600 | 250.000 | 7 | 3,0% |
| 12 | Platin Member\*\* | 600 | 500.000 | 7 | 3,5% |
| 13 | Platin Member\*\*\* | 600 | 1.000.000 | 8 | 4,0% |
\*690 Punkte zur Auszahlung (Schecksicherung)
### Payline-Prozentsätze (pr_line_X) - OHNE Growth Bonus
**⚠️ Diese Werte sind NUR der Payline-Anteil. Growth Bonus wird separat berechnet!**
| Level | E1 | E2 | E3 | E4 | E5 | E6 | E7 | E8 | Growth |
| -------------------- | --- | --- | --- | --- | --- | --- | --- | --- | ------ |
| Junior Berater | 6% | 3% | 1% | - | - | - | - | - | - |
| Aktiv Junior Berater | 6% | 4% | 2% | - | - | - | - | - | - |
| Berater | 6% | 5% | 3% | 2% | - | - | - | - | - |
| Aktiv Berater | 6% | 5% | 4% | 2% | 1% | - | - | - | - |
| Vertriebspartner | 6% | 6% | 5% | 3% | 2% | 1% | - | - | - |
| Vertriebsleiter | 6% | 6% | 6% | 4% | 2% | 1% | - | - | - |
| Bronze Member | 6% | 6% | 6% | 4% | 2% | 2% | - | - | 1,0% |
| Silber Member | 7% | 7% | 7% | 4% | 2% | 2% | - | - | 1,5% |
| Gold Member | 7% | 7% | 7% | 4% | 2% | 2% | - | - | 2,0% |
| Diamant Member | 7% | 7% | 7% | 4% | 2% | 2% | - | - | 2,5% |
| Platin Member\* | 7% | 7% | 7% | 4% | 3% | 2% | 1% | - | 3,0% |
| Platin Member\*\* | 7% | 7% | 7% | 4% | 3% | 2% | 1% | - | 3,5% |
| Platin Member\*\*\* | 7% | 7% | 7% | 4% | 4% | 3% | 2% | 1% | 4,0% |
**Gesamt-Provision pro Ebene = pr_line_X + Growth Bonus (mit Differenz-Logik)**
### Qualifikations-Algorithmus
```php
// In BusinessUserItemOptimized::calcuQualLevel()
1. Hole alle Levels wo qual_kp <= User's KP-Summe
2. Sortiere nach Position (höchstes zuerst)
3. Für jedes Level:
a. Berechne payline_points für dieses Level (Ebenen 1 bis paylines)
b. Berechne rest_kp = max(0, KP_sum - Level.qual_kp)
c. payline_points_qual_kp = payline_points + rest_kp
d. Wenn payline_points_qual_kp >= Level.qual_pp → QUALIFIZIERT
4. Return erstes qualifiziertes Level (höchstes)
```
---
## Provisionsberechnung
### 1. Payline-Provisionen (Unilevel)
Feste Prozentsätze auf Teamumsatz, begrenzt auf Payline-Tiefe.
**⚠️ WICHTIG:** Die `pr_line_X` Werte in der DB sind **NUR Payline** (ohne Growth Bonus)!
**Beispiel Gold Member (6 Paylines) - korrekte Werte:**
| Ebene | Payline (`pr_line_X`) | Growth Bonus | Gesamt |
| ----- | --------------------- | ------------ | ------ |
| 1 | 7% | +2% | 9% |
| 2 | 7% | +2% | 9% |
| 3 | 7% | +2% | 9% |
| 4 | 4% | +2% | 6% |
| 5 | 2% | +2% | 4% |
| 6 | 2% | +2% | 4% |
| 7+ | - | +2% | 2% |
```php
// In BusinessUserItemOptimized::calculateCommissions()
for ($i = 1; $i <= $qualUserLevel->paylines; $i++) {
$points = $business_lines[$i]['points'];
$margin = $qual_user_level['pr_line_' . $i]; // NUR Payline-Prozentsatz
$commission = round($points / 100 * $margin, 2);
}
// Growth Bonus wird SEPARAT berechnet (siehe unten)
```
### 2. Growth Bonus (Tiefenbonus / Differenz-Bonus)
**Aktivierung:** Ab Bronze Member (Level 7+)
**⚠️ Bug-Fix November 2025:**
- **VOR Nov 2025:** Growth Bonus war IN den `pr_line_X` Werten enthalten UND wurde nochmal separat berechnet = **Doppelte Auszahlung!**
- **AB Nov 2025:** Growth Bonus wird NUR separat berechnet, `pr_line_X` enthält nur Payline-Anteil
**Logik:** Differenz zwischen eigenem Anspruch und bereits verteiltem Prozentsatz
```
Beispiel:
- User A: Diamant (2,5% Anspruch)
- User B (in A's Downline): Silber (1,5% Anspruch)
Punkte UNTER B:
- B erhält: 1,5% (sein voller Anspruch)
- A erhält: 2,5% - 1,5% = 1,0% (Differenz)
Punkte von B selbst (GAP):
- B erhält: 0% (kein Bonus auf sich selbst)
- A erhält: 2,5% (voller Anspruch, da B nicht "schützt")
```
**Implementation:** Siehe `GrowthBonusCalculator.php` und `Growth-Bonus.md`
### 3. Shop-Provision
```php
$commission_shop_sales = sales_volume_total_shop / 100 * margin_shop;
```
---
## Cron-Job (Monatsabschluss)
### Datei: `app/Cron/BusinessUsersStoreOptimized.php`
### Ausführung
```bash
# Typischerweise am 1. des Folgemonats
php artisan schedule:run
# Oder manuell:
php artisan business:store-monthly {month} {year}
```
### Ablauf
1. **storeUserBusinessStructure()**
- Prüft ob bereits Struktur für Monat/Jahr existiert
- Erstellt neue `UserBusinessStructure` mit allen User-IDs
- Speichert komplette Baumstruktur als JSON
2. **storeBusinessUsersDetail()**
- Iteriert über alle User (aus users Array)
- Für jeden nicht-abgeschlossenen User:
- `TreeCalcBotOptimized::initBusinesslUserDetail(user, forceLive=true)`
- Speichert `UserBusiness` Datensatz
- Markiert User als "completed" in Struktur
3. **storeBusinessCompleted()**
- Prüft ob alle User abgeschlossen
- Setzt `completed = true` auf Struktur
### Performance
- Typische Laufzeit: 10-60 Minuten (je nach User-Anzahl)
- Memory: 512MB-1GB empfohlen
- Kann in Batches/Chunks aufgeteilt werden
---
## Dashboard-Integration
### Dashboard-Statistiken (`dashboard/_statistics.blade.php`)
| Metrik | Datenquelle | Beschreibung |
| -------------------- | --------------------------------------------------------- | -------------------- |
| Kunden-Umsatz Punkte | `UserSalesVolume.getPointsKPSum()` | KP + Shop |
| Team-Umsatz Punkte | `UserBusiness.payline_points` | Payline-Summe |
| Direkte Neupartner | `User WHERE m_sponsor = $userId AND active_date IN month` | Neue Firstlines |
| Neupartner im Team | `UserSalesVolume WHERE status = 5 (registration)` | Registrierungspunkte |
| Kundenabos | `UserAbo WHERE member_id = $userId` | Kunden-Abos |
| Teamabos | `UserAbo WHERE user_id IN firstline_ids` | Team-Abos |
### Punkte-Tabelle (`dashboard/_points.blade.php`)
Zeigt detaillierte `UserSalesVolume` Einträge für gewählten Monat/Jahr:
- Datum, Punkte, Netto-Umsatz
- Status (Berater-Bestellung, Shop, Gutschrift, Registrierung)
- Bestellungs-Link, Kundeninformationen
---
## Erweiterungen & TODO
### Geplante Änderungen
1. **Dashboard-Statistiken erweitern**
- Monats/Jahr-Filter implementiert
- Statistik-Kacheln hinzugefügt
2. **Marketingplan-Anpassungen** (in Arbeit)
- Level-Struktur überprüfen
- Provisionsberechnung validieren
- Growth-Bonus Differenz-Logik testen
### Bekannte Einschränkungen
- Growth Bonus nur ab November 2025 mit neuer Differenz-Logik
- Vor November 2025: Legacy-Berechnung (pauschal ab Ebene 7+)
- Struktur-Tiefe begrenzt auf 20-30 Ebenen (Performance)
---
## Kontakt & Wartung
**Letzte Aktualisierung:** Dezember 2025
**Version:** BusinessPlan System v2.0
### Log-Dateien
- `storage/logs/laravel.log` - Allgemeine Logs
- BusinessUserItem/TreeCalcBot Logs mit Prefix "BusinessUserItem:" / "TreeCalcBot:"
### Cache löschen
```php
// In Repository
$repository->clearCache();
// Oder manuell
Cache::forget("stored_structure_{$month}_{$year}");
Cache::forget("root_users_{$month}_{$year}");
```

View file

@ -1,25 +1,28 @@
<?php
namespace App\Services\BusinessPlan;
use App\User;
use stdClass;
use App\Services\Util;
use App\Models\ShoppingOrder;
use App\Models\UserSalesVolume;
use App\Services\Util;
use App\User;
use stdClass;
class SalesPointsVolume
{
public static function changeSalesPointsVolumeUser(ShoppingOrder $shoppingOrder, $to_user_id){
public static function changeSalesPointsVolumeUser(ShoppingOrder $shoppingOrder, $to_user_id)
{
if($shoppingOrder->user_sales_volume){
if ($shoppingOrder->user_sales_volume) {
$to_user_id = intval($to_user_id);
if($shoppingOrder->user_sales_volume->user_id === $to_user_id){
if ($shoppingOrder->user_sales_volume->user_id === $to_user_id) {
\Session()->flash('alert-error', 'Keine Änderung: selber Berater');
return;
}
if(!$shoppingOrder->user_sales_volume->isCurrentMonthYear()){
if (! $shoppingOrder->user_sales_volume->isCurrentMonthYear()) {
\Session()->flash('alert-error', 'Änderung muss im selben Monat sein');
return;
}
@ -30,73 +33,74 @@ class SalesPointsVolume
$to_user = User::find($to_user_id);
$form_user = User::find($form_user_id);
$shoppingOrder->user_sales_volume->user_id = $to_user_id;
$shoppingOrder->user_sales_volume->message = 'zugewiesen: '.date('d.m.Y');
$shoppingOrder->user_sales_volume->user_id = $to_user_id;
$shoppingOrder->user_sales_volume->message = 'zugewiesen: ' . date('d.m.Y');
$syslog = $shoppingOrder->user_sales_volume->syslog;
$from_email = $form_user ? $form_user->email : '';
$to_email = $to_user ? $to_user->email : '';
$syslog[date('d.m.Y-h:i:s')] = 'change form: #'.$form_user_id.' '.$from_email.' to: #'.$to_user_id.' '.$to_email;
$syslog[date('d.m.Y-h:i:s')] = 'change form: #' . $form_user_id . ' ' . $from_email . ' to: #' . $to_user_id . ' ' . $to_email;
$shoppingOrder->user_sales_volume->syslog = $syslog;
$shoppingOrder->user_sales_volume->save();
//recalculate
// recalculate
self::reCalculateSalesPointsVolume($to_user_id, $month, $year);
self::reCalculateSalesPointsVolume($form_user_id, $month, $year);
\Session()->flash('alert-save', true);
}
}
private static function add_KP_TP_Points($userSalesVolume, $month_points){
if($userSalesVolume->status_points === 2) { //KP
private static function add_KP_TP_Points($userSalesVolume, $month_points)
{
if ($userSalesVolume->status_points === 2) { // KP
$month_points->KP += $userSalesVolume->points;
}else{
} else {
// === 1 //TP + KP
$month_points->KP += $userSalesVolume->points;
$month_points->TP += $userSalesVolume->points;
}
return $month_points;
}
public static function reCalculateSalesPointsVolume($user_id, $month, $year){
public static function reCalculateSalesPointsVolume($user_id, $month, $year)
{
$userSalesVolumes = UserSalesVolume::where('user_id', $user_id)->where('month', $month)->where('year', $year)->orderBy('id', 'ASC')->get();
$month_points = new stdClass();
$month_points = new stdClass;
$month_points->KP = 0;
$month_points->TP = 0;
$month_total_net = 0;
$month_shop_points = 0;
$month_shop_total_net = 0;
//TDOO Status === 3???
foreach($userSalesVolumes as $userSalesVolume){
// TDOO Status === 3???
foreach ($userSalesVolumes as $userSalesVolume) {
switch ($userSalesVolume->status) {
case 1: //Bestellung Berater
case 1: // Bestellung Berater
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
$month_total_net += $userSalesVolume->total_net;
$month_total_net += $userSalesVolume->total_net;
break;
case 2: //Shop
case 2: // Shop
$month_shop_points += $userSalesVolume->points;
$month_shop_total_net += $userSalesVolume->total_net;
$month_shop_total_net += $userSalesVolume->total_net;
break;
case 4: //Gutschrift
case 4: // Gutschrift
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
if($userSalesVolume->status_turnover === 2){
$month_shop_total_net += $userSalesVolume->total_net;
//ggf hier zu den Shop Points zählen wäre aber immer KP + TP kann nicht keine trennung bei month_shop_points
}else{
$month_total_net += $userSalesVolume->total_net;
if ($userSalesVolume->status_turnover === 2) {
$month_shop_total_net += $userSalesVolume->total_net;
// ggf hier zu den Shop Points zählen wäre aber immer KP + TP kann nicht keine trennung bei month_shop_points
} else {
$month_total_net += $userSalesVolume->total_net;
}
break;
case 5: //Registrierung
case 5: // Registrierung
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
$month_total_net += $userSalesVolume->total_net;
break;
$month_total_net += $userSalesVolume->total_net;
break;
}
$userSalesVolume->month_shop_points = $month_shop_points;
$userSalesVolume->month_shop_total_net = $month_shop_total_net;
@ -107,8 +111,8 @@ class SalesPointsVolume
}
}
public static function addSalesPointsVolumeUser(ShoppingOrder $shoppingOrder){
public static function addSalesPointsVolumeUser(ShoppingOrder $shoppingOrder)
{
/*
status
@ -118,15 +122,14 @@ class SalesPointsVolume
*/
$status = self::getStatusByOrderPaymentFor($shoppingOrder);
$user_id = $shoppingOrder->auth_user_id ? $shoppingOrder->auth_user_id : $shoppingOrder->member_id;
//akuteller tag / Monat.
$user_id = $shoppingOrder->auth_user_id ? $shoppingOrder->auth_user_id : $shoppingOrder->member_id;
// akuteller tag / Monat.
$month = date('m');
$year = date('Y');
$date = date('d.m.Y');
if($status === 3){ //shop bestellung User pending if is_like
$user_id = NULL;
if ($status === 3) { // shop bestellung User pending if is_like
$user_id = null;
}
$user_sales_volume = UserSalesVolume::create([
'user_id' => $user_id,
@ -135,24 +138,24 @@ class SalesPointsVolume
'year' => $year,
'date' => $date,
'points' => $shoppingOrder->points,
'total_net' => $shoppingOrder->subtotal,
'status_points' => 1, //KP + TP
'total_net' => $shoppingOrder->subtotal,
'status_points' => 1, // KP + TP
'message' => '',
'status' => $status,
]);
if($status !== 3){
if ($status !== 3) {
self::reCalculateSalesPointsVolume($user_sales_volume->user_id, $user_sales_volume->month, $user_sales_volume->year);
}
return $user_sales_volume;
}
public static function setToUserAndReCalculate(UserSalesVolume $user_sales_volume, $user_id){
public static function setToUserAndReCalculate(UserSalesVolume $user_sales_volume, $user_id)
{
//set month year date new, calculate it in the currently month!
//If the month has changed, it can no longer be added to the month before
// set month year date new, calculate it in the currently month!
// If the month has changed, it can no longer be added to the month before
$month = date('m');
$year = date('Y');
$date = date('d.m.Y');
@ -161,60 +164,64 @@ class SalesPointsVolume
$user_sales_volume->month = $month;
$user_sales_volume->year = $year;
$user_sales_volume->date = $date;
$user_sales_volume->status = 2; //hinzugefügt aus Shop can only Pending
$user_sales_volume->save();
$user_sales_volume->status = 2; // hinzugefügt aus Shop can only Pending
$user_sales_volume->save();
self::reCalculateSalesPointsVolume($user_id, $month, $year);
}
}
public static function getStatusByOrderPaymentFor(ShoppingOrder $shoppingOrder){
if($shoppingOrder->payment_for){
if($shoppingOrder->payment_for === 6){ //Kunde-Shop
if($shoppingOrder->shopping_user && $shoppingOrder->shopping_user->is_like){
return 3; //shop Kunden, berater zuordnen <- need?
public static function getStatusByOrderPaymentFor(ShoppingOrder $shoppingOrder)
{
if ($shoppingOrder->payment_for) {
if ($shoppingOrder->payment_for === 6) { // Kunde-Shop
if ($shoppingOrder->shopping_user && $shoppingOrder->shopping_user->is_like) {
return 3; // shop Kunden, berater zuordnen <- need?
}
return 2;
}
return 1;
}
return 0;
}
public static function editSalesPointsVolume($data){
public static function editSalesPointsVolume($data)
{
$user_sales_volume = UserSalesVolume::findOrFail($data['id']);
if(!$user_sales_volume->isCurrentMonthYear()){
if (! $user_sales_volume->isCurrentMonthYear()) {
\Session()->flash('alert-error', 'Änderung muss im selben Monat sein');
return;
}
$old_points = $user_sales_volume->points;
$old_total_net = $user_sales_volume->total_net;
$user_sales_volume->total_net = Util::reFormatNumber($data['total_net']);
$user_sales_volume->points = intval($data['points']);
$user_sales_volume->points = Util::reFormatNumber($data['points']);
$user_sales_volume->message = 'geändert: '.date('d.m.Y');
$user_sales_volume->message = 'geändert: ' . date('d.m.Y');
$user_sales_volume->info = $data['info'];
$user_sales_volume->status_points = $data['status_points'];
$user_sales_volume->status_turnover = isset($data['status_turnover']) ? intval($data['status_turnover']) : null;
$syslog = $user_sales_volume->syslog;
$syslog[date('d.m.Y-h:i:s')] = 'edit points: #'.$old_points.' '.$user_sales_volume->points .' total: #'.$old_total_net.' '.$user_sales_volume->total_net;
$syslog[date('d.m.Y-h:i:s')] = 'edit points: #' . $old_points . ' ' . $user_sales_volume->points . ' total: #' . $old_total_net . ' ' . $user_sales_volume->total_net;
$user_sales_volume->syslog = $syslog;
$user_sales_volume->save();
self::reCalculateSalesPointsVolume($user_sales_volume->user_id, $user_sales_volume->month, $user_sales_volume->year);
\Session()->flash('alert-success', "Points geändert");
return;
\Session()->flash('alert-success', 'Points geändert');
}
public static function addSalesPointsVolume($data){
if(!isset($data['user_id'])){
public static function addSalesPointsVolume($data)
{
if (! isset($data['user_id'])) {
\Session()->flash('alert-error', 'Kein Berater ausgewählt');
return;
}
$user = User::findOrFail($data['user_id']);
@ -223,8 +230,8 @@ class SalesPointsVolume
$date = date('d.m.Y');
$total_net = isset($data['total_net']) ? Util::reFormatNumber($data['total_net']) : 0;
$points = isset($data['points']) ? intval($data['points']) : 0;
$syslog[date('d.m.Y-h:i:s')] = 'add points: #'.$points.' total: #'.$total_net;
$points = isset($data['points']) ? Util::reFormatNumber($data['points']) : 0;
$syslog[date('d.m.Y-h:i:s')] = 'add points: #' . $points . ' total: #' . $total_net;
$status = isset($data['status']) ? intval($data['status']) : 4;
$status_turnover = isset($data['status_turnover']) ? intval($data['status_turnover']) : null;
@ -238,20 +245,14 @@ class SalesPointsVolume
'status_points' => $data['status_points'],
'status_turnover' => $status_turnover,
'total_net' => $total_net,
'message' => 'hinzugefügt: '.date('d.m.Y'),
'message' => 'hinzugefügt: ' . date('d.m.Y'),
'info' => $data['info'],
'syslog' => $syslog,
'status' => $status,
]);
self::reCalculateSalesPointsVolume($user_sales_volume->user_id, $user_sales_volume->month, $user_sales_volume->year);
\Session()->flash('alert-success', "Points hinzugefügt");
\Session()->flash('alert-success', 'Points hinzugefügt');
}
}

View file

@ -1,4 +1,5 @@
<?php
namespace App\Services\BusinessPlan;
use App\User;
@ -17,31 +18,31 @@ class TreeCalcBot
private $sponsor;
private $init_from;
private static $userIDs = [];
public static function addUserID($id){
public static function addUserID($id)
{
self::$userIDs[$id] = $id;
}
public function __construct($month, $year, $init_from = 'member')
{
$this->date = new stdClass();
$date = Carbon::parse($year.'-'.$month.'-1');
$date = Carbon::parse($year . '-' . $month . '-1');
$this->date->month = $month;
$this->date->year = $year;
$this->date->start_date = $date->format('Y-m-d H:i:s');
$this->date->end_date = $date->endOfMonth()->format('Y-m-d H:i:s');
$this->init_from = $init_from;
}
public function initStructureAdmin($check = true, $forceLiveCalculation = false)
{
//check is month is saved.
if($check && $UserBusinessStructure = self::isFromStored($this->date->month, $this->date->year)){
if ($check && $UserBusinessStructure = self::isFromStored($this->date->month, $this->date->year)) {
$this->readStoredRootUsers($UserBusinessStructure);
$this->readStoredParentsUsers($UserBusinessStructure);
$this->readStoredParentlessUser($UserBusinessStructure);
}else{
} else {
$this->readRootUsers();
$this->readParentsUsers();
$this->readParentlessUser();
@ -50,20 +51,20 @@ class TreeCalcBot
public function initStructureUser($user_id)
{
$BusinessUserItem = new BusinessUserItem($this->date);
$BusinessUserItem->makeUser($user_id);
$BusinessUserItem->addUserID();
$this->business_users[] = $BusinessUserItem;
//check is month is saved.
if($UserBusinessStructure = self::isFromStored($this->date->month, $this->date->year)){
if ($UserBusinessStructure = self::isFromStored($this->date->month, $this->date->year)) {
$this->readStoredParentsUsers($UserBusinessStructure);
if(isset($this->business_users[0]) && $this->business_users[0]->sponsor){
if (isset($this->business_users[0]) && $this->business_users[0]->sponsor) {
$this->readStoredSponsorUser($this->business_users[0]->sponsor->user_id);
}
}else{
} else {
$this->readParentsUsers();
$this->readSponsorUser($user_id);
}
@ -74,11 +75,11 @@ class TreeCalcBot
$this->business_user = new BusinessUserItem($this->date);
$this->business_user->makeUser($user->id);
$this->business_user->checkSponsor($user);
if(!$this->business_user->isSave()){
if (!$this->business_user->isSave()) {
//Aufbau der Struktur für den User in die unendliche Tiefe.
$this->business_user->readParentsBusinessUsers();
//calculate Points in Lines
if(count($this->business_user->businessUserItems) > 0){
if (count($this->business_user->businessUserItems) > 0) {
$this->calcUserPoints($this->business_user->businessUserItems, 1);
}
//qualifikation nach qual_kp (KundenPoints) und qual_pp (PaylinePoints)
@ -91,24 +92,26 @@ class TreeCalcBot
$this->business_user->storeUser();
}*/
public static function isFromStored($month, $year){
public static function isFromStored($month, $year)
{
//when is stored an completed
$UserBusinessStructure = UserBusinessStructure::where('year', $year)->where('month', $month)->first();
if($UserBusinessStructure && $UserBusinessStructure->completed){
return $UserBusinessStructure;
if ($UserBusinessStructure && $UserBusinessStructure->completed) {
return $UserBusinessStructure;
}
return false;
}
private function calcUserPoints($businessUserItems, $line){
if(!isset($this->business_user->business_lines[$line])){
$obj = new stdClass();
$obj->points = 0;
$this->business_user->addBusinessLineToUser($line, $obj);
private function calcUserPoints($businessUserItems, $line)
{
if (!isset($this->business_user->business_lines[$line])) {
$obj = new stdClass();
$obj->points = 0;
$this->business_user->addBusinessLineToUser($line, $obj);
}
foreach($businessUserItems as $business_user_item){
if(count($business_user_item->businessUserItems) > 0){
$this->calcUserPoints($business_user_item->businessUserItems, $line+1);
foreach ($businessUserItems as $business_user_item) {
if (count($business_user_item->businessUserItems) > 0) {
$this->calcUserPoints($business_user_item->businessUserItems, $line + 1);
}
//business_lines points nach line
$this->business_user->addBusinessLinePoints($line, $business_user_item->sales_volume_points_TP_sum); //TP + Shop Points
@ -117,36 +120,38 @@ class TreeCalcBot
}
}
public function getGrowthBonus(){
if(count($this->business_user->business_lines) > 6){
public function getGrowthBonus()
{
if (count($this->business_user->business_lines) > 6) {
$b_lines = $this->business_user->business_lines->toArray();
return array_slice($b_lines, 6);
return array_slice($b_lines, 6);
}
return [];
}
public function getKeybyLine($line, $key){
if($this->business_user->business_lines){
public function getKeybyLine($line, $key)
{
if ($this->business_user->business_lines) {
$b_lines = $this->business_user->business_lines;
if(isset($b_lines[$line])){
if($b_lines[$line] instanceof stdClass){
if(isset($b_lines[$line]->{$key})){
if (isset($b_lines[$line])) {
if ($b_lines[$line] instanceof stdClass) {
if (isset($b_lines[$line]->{$key})) {
return $b_lines[$line]->{$key};
}
}else{
if(isset($b_lines[$line][$key])){
} else {
if (isset($b_lines[$line][$key])) {
return $b_lines[$line][$key];
}
}
}
}
return 0;
}
//* reading from current*//
private function readRootUsers(){
private function readRootUsers()
{
$users = User::with('account')->select('users.*')
->where('users.deleted_at', '=', null)
->where('users.id', '!=', 1)
@ -154,10 +159,10 @@ class TreeCalcBot
->where('users.m_level', "!=", null)
->where('users.m_sponsor', "=", null)
->where('users.payment_account', "!=", null)
->where('users.active_date', "<=", $this->date->end_date)
->where('users.active_date', "<=", $this->date->end_date)
->get();
if($users){
foreach($users as $user){
if ($users) {
foreach ($users as $user) {
$BusinessUserItem = new BusinessUserItem($this->date);
$BusinessUserItem->makeUser($user->id);
$BusinessUserItem->addUserID();
@ -166,23 +171,25 @@ class TreeCalcBot
}
}
private function readParentsUsers(){
foreach($this->business_users as $business_user){
private function readParentsUsers()
{
foreach ($this->business_users as $business_user) {
$business_user->readParentsBusinessUsers();
}
}
private function readParentlessUser(){
private function readParentlessUser()
{
$users = User::with('account')->select('users.*')
->where('users.deleted_at', '=', null)
->where('users.id', '!=', 1)
->where('users.admin', "<", 4)
->where('users.payment_account', "!=", null)
->where('users.active_date', "<=", $this->date->end_date)
->get();
foreach($users as $user){
if(!isset(self::$userIDs[$user->id])){
->where('users.deleted_at', '=', null)
->where('users.id', '!=', 1)
->where('users.admin', "<", 4)
->where('users.payment_account', "!=", null)
->where('users.active_date', "<=", $this->date->end_date)
->get();
foreach ($users as $user) {
if (!isset(self::$userIDs[$user->id])) {
$BusinessUserItem = new BusinessUserItem($this->date);
$BusinessUserItem->makeUser($user->id);
$this->parentless[] = $BusinessUserItem;
@ -191,201 +198,207 @@ class TreeCalcBot
}
//* reading from stored*//
private function readStoredRootUsers(UserBusinessStructure $userBusinessStructure){
//* reading from stored*//
private function readStoredRootUsers(UserBusinessStructure $userBusinessStructure)
{
//first level is root
if($userBusinessStructure->structure){
foreach($userBusinessStructure->structure as $obj){
if ($userBusinessStructure->structure) {
foreach ($userBusinessStructure->structure as $obj) {
$BusinessUserItem = new BusinessUserItem($this->date);
$BusinessUserItem->makeUser($obj->user_id);
$BusinessUserItem->addUserID();
$this->business_users[] = $BusinessUserItem;
}
}
}
private function readStoredParentsUsers(UserBusinessStructure $userBusinessStructure){
foreach($this->business_users as $business_user){
private function readStoredParentsUsers(UserBusinessStructure $userBusinessStructure)
{
foreach ($this->business_users as $business_user) {
$business_user->readStoredParentsBusinessUsers($userBusinessStructure->structure);
}
}
private function readStoredParentlessUser(UserBusinessStructure $userBusinessStructure){
if($userBusinessStructure->parentless){
foreach($userBusinessStructure->parentless as $obj){
if(!isset(self::$userIDs[$obj->user_id])){
private function readStoredParentlessUser(UserBusinessStructure $userBusinessStructure)
{
if ($userBusinessStructure->parentless) {
foreach ($userBusinessStructure->parentless as $obj) {
if (!isset(self::$userIDs[$obj->user_id])) {
$BusinessUserItem = new BusinessUserItem($this->date);
$BusinessUserItem->makeUser($obj->user_id);
$this->parentless[] = $BusinessUserItem;
}
}
}
}
public function readSponsorUser($user_id){
public function readSponsorUser($user_id)
{
$user = User::find($user_id);
$userSponsor = User::find($user->m_sponsor);
if($userSponsor){
if ($userSponsor) {
$this->sponsor = new BusinessUserItem($this->date);
$this->sponsor->makeUser($userSponsor->id);
}
}
public function readStoredSponsorUser($user_id){
public function readStoredSponsorUser($user_id)
{
$this->sponsor = new BusinessUserItem($this->date);
$this->sponsor->makeUser($user_id);
}
public function getItems(){
public function getItems()
{
return $this->business_users;
}
public function makeHtmlTree(){
public function makeHtmlTree()
{
$deep = 0;
$ret = '<ol class="dd-list">';
foreach($this->business_users as $business_user){
$ret .= $this->addItem($business_user, $deep);
}
foreach ($this->business_users as $business_user) {
$ret .= $this->addItem($business_user, $deep);
}
$ret .= '</ol>';
return $ret;
}
private function addItem($item, $deep){
private function addItem($item, $deep)
{
$button = '';
if(($this->init_from === 'admin' && \Auth::user()->isAdmin()) || ($this->init_from === 'member')){ // && \Auth::user()->id === $item->user_id
if (($this->init_from === 'admin' && \Auth::user()->isAdmin()) || ($this->init_from === 'member')) { // && \Auth::user()->id === $item->user_id
$button = ' | <button type="button" class="btn icon-btn btn-xs btn-secondary" data-toggle="modal" data-target="#modals-load-content"
data-id="'.$item->user_id.'"
data-id="' . $item->user_id . '"
data-action="business-user-detail"
data-back=""
data-modal="modal-xl"
data-init_from="'.$this->init_from .'"
data-route="'.route('modal_load').'"><span class="fa fa-calculator"></span></button>';
data-init_from="' . $this->init_from . '"
data-route="' . route('modal_load') . '"><span class="fa fa-calculator"></span></button>';
}
return '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
'<div class="dd-handle">
return '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
'<div class="dd-handle">
<div class="media align-items-center">
<div class="d-flex flex-column justify-content-center align-items-center">
'.(($deep > 0) ? '<div class="text-large font-weight-bolder line-height-1 my-2 text-secondary badge badge-outline-secondary">'.$deep.'</div>' : '').'
' . (($deep > 0) ? '<div class="text-large font-weight-bolder line-height-1 my-2 text-secondary badge badge-outline-secondary">' . $deep . '</div>' : '') . '
</div>
<div class="media-body ml-2">
<span class="'.($item->active_account ? '' : 'text-muted').'">
<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" data-id="'.$item->user_id.'" data-action="business-user-show" data-back="" data-modal="modal-md" data-init_from="'.$this->init_from .'" data-route="'.route('modal_load').'">
<span class="mr-1 ion ion-ios-contact '.($item->active_account ? 'text-primary' : 'text-danger').'"></span> <strong>'.$item->first_name.' '.$item->last_name.'</strong>
<span class="' . ($item->active_account ? '' : 'text-muted') . '">
<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" data-id="' . $item->user_id . '" data-action="business-user-show" data-back="" data-modal="modal-md" data-init_from="' . $this->init_from . '" data-route="' . route('modal_load') . '">
<span class="mr-1 ion ion-ios-contact ' . ($item->active_account ? 'text-primary' : 'text-danger') . '"></span> <strong>' . $item->first_name . ' ' . $item->last_name . '</strong>
</a>
<a href="mailto: '.$item->email.'">'.$item->email.'</a>
'.($item->user_birthday ? ' | <i class="ion ion-ios-gift text-primary"></i> '.$item->user_birthday : '').'
'.($item->user_phone ? ' | <i class="ion ion-ios-call text-primary"></i> '.$item->user_phone : '').'
<span class="badge badge-outline-default '.($item->active_account ? '' : 'text-muted').'">'.\App\Services\TranslationHelper::transUserLevelName($item->user_level_name).' | '.$item->m_account.'</span>
<br><span class="small">'.
($item->active_account ?
'<strong>'.__('team.total_points').': '.$item->sales_volume_points_KP_sum.'</strong> | '.__('team.e').': '.$item->sales_volume_KP_points.' | '.__('team.s').': '.$item->sales_volume_points_shop.' <strong>
| '.__('team.net_turnover').': '.formatNumber($item->sales_volume_total_sum).' &euro;</strong> | '.__('team.e').': '.formatNumber($item->sales_volume_total).' &euro; | '.__('team.s').': '.formatNumber($item->sales_volume_total_shop).' &euro;'.
$button
:
__('team.account_to').': '.$item->payment_account_date).
'</span>
<a href="mailto: ' . $item->email . '">' . $item->email . '</a>
' . ($item->user_birthday ? ' | <i class="ion ion-ios-gift text-primary"></i> ' . $item->user_birthday : '') . '
' . ($item->user_phone ? ' | <i class="ion ion-ios-call text-primary"></i> ' . $item->user_phone : '') . '
<span class="badge badge-outline-default ' . ($item->active_account ? '' : 'text-muted') . '">' . \App\Services\TranslationHelper::transUserLevelName($item->user_level_name) . ' | ' . $item->m_account . '</span>
<br><span class="small">' .
($item->active_account ?
'<strong>' . __('team.total_points') . ': ' . formatNumber($item->sales_volume_points_KP_sum) . '</strong> | ' . __('team.e') . ': ' . formatNumber($item->sales_volume_KP_points) . ' | ' . __('team.s') . ': ' . formatNumber($item->sales_volume_points_shop) . ' <strong>
| ' . __('team.net_turnover') . ': ' . formatNumber($item->sales_volume_total_sum) . ' &euro;</strong> | ' . __('team.e') . ': ' . formatNumber($item->sales_volume_total) . ' &euro; | ' . __('team.s') . ': ' . formatNumber($item->sales_volume_total_shop) . ' &euro;' .
$button
:
__('team.account_to') . ': ' . $item->payment_account_date) .
'</span>
</span>
</div>
</div>
</div>'.
$this->addParentItem($item, $deep).
</div>' .
$this->addParentItem($item, $deep) .
'</li>';
}
private function addParentItem($item, $deep){
if($item->businessUserItems){
private function addParentItem($item, $deep)
{
if ($item->businessUserItems) {
$ret = '<ol class="dd-list dd-nodrag">';
foreach($item->businessUserItems as $parent){
$ret .= $this->addItem($parent, $deep+1);
}
$ret .='</ol>';
foreach ($item->businessUserItems as $parent) {
$ret .= $this->addItem($parent, $deep + 1);
}
$ret .= '</ol>';
return $ret;
}
return;
}
return;
}
public function isParentless(){
public function isParentless()
{
return $this->parentless ? true : false;
}
}
public function makeParentlessHtml(){
$ret = "";
foreach($this->parentless as $item){
$ret .= '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
'<div class="dd-handle">
<span class="'.($item->active_account ? '' : 'text-muted').'">
<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" data-id="'.$item->user_id.'" data-action="business-user-show" data-back="" data-modal="modal-md" data-init_from="'.$this->init_from .'" data-route="'.route('modal_load').'">
<span class="mr-1 ion ion-ios-contact '.($item->active_account ? 'text-primary' : 'text-danger').'"></span> <strong>'.$item->first_name.' '.$item->last_name.'</strong>
public function makeParentlessHtml()
{
$ret = "";
foreach ($this->parentless as $item) {
$ret .= '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
'<div class="dd-handle">
<span class="' . ($item->active_account ? '' : 'text-muted') . '">
<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" data-id="' . $item->user_id . '" data-action="business-user-show" data-back="" data-modal="modal-md" data-init_from="' . $this->init_from . '" data-route="' . route('modal_load') . '">
<span class="mr-1 ion ion-ios-contact ' . ($item->active_account ? 'text-primary' : 'text-danger') . '"></span> <strong>' . $item->first_name . ' ' . $item->last_name . '</strong>
</a>
<a href="mailto: '.$item->email.'">'.$item->email.'</a>
'.($item->user_birthday ? ' | <i class="ion ion-ios-gift text-primary"></i> '.$item->user_birthday : '').'
'.($item->user_phone ? ' | <i class="ion ion-ios-call text-primary"></i> '.$item->user_phone : '').'
<span class="badge badge-outline-default '.($item->active_account ? '' : 'text-muted').'">'.\App\Services\TranslationHelper::transUserLevelName($item->user_level_name).' | '.$item->m_account.'</span>
<br><span class="small">'.
($item->active_account ?
'<strong>'.__('team.total_points').': '.$item->sales_volume_points_KP_sum.'</strong> | '.__('team.e').': '.$item->sales_volume_KP_points.' | '.__('team.s').': '.$item->sales_volume_points_shop.' <strong>
| '.__('team.net_turnover').': '.formatNumber($item->sales_volume_total_sum).' &euro;</strong> | '.__('team.e').': '.formatNumber($item->sales_volume_total).' &euro; | '.__('team.s').': '.formatNumber($item->sales_volume_total_shop).' &euro;'.
' | <button type="button" class="btn icon-btn btn-xs btn-secondary" data-toggle="modal" data-target="#modals-load-content"
data-id="'.$item->user_id.'"
<a href="mailto: ' . $item->email . '">' . $item->email . '</a>
' . ($item->user_birthday ? ' | <i class="ion ion-ios-gift text-primary"></i> ' . $item->user_birthday : '') . '
' . ($item->user_phone ? ' | <i class="ion ion-ios-call text-primary"></i> ' . $item->user_phone : '') . '
<span class="badge badge-outline-default ' . ($item->active_account ? '' : 'text-muted') . '">' . \App\Services\TranslationHelper::transUserLevelName($item->user_level_name) . ' | ' . $item->m_account . '</span>
<br><span class="small">' .
($item->active_account ?
'<strong>' . __('team.total_points') . ': ' . $item->sales_volume_points_KP_sum . '</strong> | ' . __('team.e') . ': ' . $item->sales_volume_KP_points . ' | ' . __('team.s') . ': ' . $item->sales_volume_points_shop . ' <strong>
| ' . __('team.net_turnover') . ': ' . formatNumber($item->sales_volume_total_sum) . ' &euro;</strong> | ' . __('team.e') . ': ' . formatNumber($item->sales_volume_total) . ' &euro; | ' . __('team.s') . ': ' . formatNumber($item->sales_volume_total_shop) . ' &euro;' .
' | <button type="button" class="btn icon-btn btn-xs btn-secondary" data-toggle="modal" data-target="#modals-load-content"
data-id="' . $item->user_id . '"
data-action="business-user-detail"
data-back=""
data-modal="modal-xl"
data-route="'.route('modal_load').'"><span class="fa fa-calculator"></span></button>'
:
__('team.account_to').' '.$item->payment_account_date).
'<br>'.$item->m_sponsor_name.
'</span>
data-route="' . route('modal_load') . '"><span class="fa fa-calculator"></span></button>'
:
__('team.account_to') . ' ' . $item->payment_account_date) .
'<br>' . $item->m_sponsor_name .
'</span>
</span>
</div>'.
</div>' .
'</li>';
}
return $ret;
}
}
public function makeSponsorHtml(){
public function makeSponsorHtml()
{
if($this->sponsor){
if ($this->sponsor) {
//' | <a href="' . route('admin_business_user_detail', [$this->sponsor->id]) . '" class="btn icon-btn btn-xs btn-secondary"><span class="fa fa-calculator"></span></a>'
$ret = '<li class="dd-item dd-nodrag" data-id="">'.
'<div class="dd-handle">
<span class="'.($this->sponsor->active_account ? '' : 'text-muted').'">
<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" data-id="'.$this->sponsor->user_id.'" data-action="business-user-show" data-back="" data-init_from="'.$this->init_from .'" data-modal="modal-md" data-route="'.route('modal_load').'">
<span class="mr-1 ion ion-ios-contact '.($this->sponsor->active_account ? 'text-primary' : 'text-danger').'"></span> <strong>'.$this->sponsor->first_name.' '.$this->sponsor->last_name.'</strong>
$ret = '<li class="dd-item dd-nodrag" data-id="">' .
'<div class="dd-handle">
<span class="' . ($this->sponsor->active_account ? '' : 'text-muted') . '">
<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" data-id="' . $this->sponsor->user_id . '" data-action="business-user-show" data-back="" data-init_from="' . $this->init_from . '" data-modal="modal-md" data-route="' . route('modal_load') . '">
<span class="mr-1 ion ion-ios-contact ' . ($this->sponsor->active_account ? 'text-primary' : 'text-danger') . '"></span> <strong>' . $this->sponsor->first_name . ' ' . $this->sponsor->last_name . '</strong>
</a>
<a href="mailto: '.$this->sponsor->email.'">'.$this->sponsor->email.'</a>
'.($this->sponsor->user_birthday ? ' | <i class="ion ion-ios-gift text-primary"></i> '.$this->sponsor->user_birthday : '').'
'.($this->sponsor->user_phone ? ' | <i class="ion ion-ios-call text-primary"></i> '.$this->sponsor->user_phone : '').'
<span class="badge badge-outline-default '.($this->sponsor->active_account ? '' : 'text-muted').'">'.\App\Services\TranslationHelper::transUserLevelName($this->sponsor->user_level_name).' | '.$this->sponsor->m_account.'</span>';
<a href="mailto: ' . $this->sponsor->email . '">' . $this->sponsor->email . '</a>
' . ($this->sponsor->user_birthday ? ' | <i class="ion ion-ios-gift text-primary"></i> ' . $this->sponsor->user_birthday : '') . '
' . ($this->sponsor->user_phone ? ' | <i class="ion ion-ios-call text-primary"></i> ' . $this->sponsor->user_phone : '') . '
<span class="badge badge-outline-default ' . ($this->sponsor->active_account ? '' : 'text-muted') . '">' . \App\Services\TranslationHelper::transUserLevelName($this->sponsor->user_level_name) . ' | ' . $this->sponsor->m_account . '</span>';
if($this->init_from === 'admin'){
$ret .= '<br><span class="small">'.
($this->sponsor->active_account ?
'<strong>'.__('team.total_points').': '.$this->sponsor->sales_volume_points_KP_sum.'</strong> | '.__('team.e').': '.$this->sponsor->sales_volume_KP_points.' | '.__('team.s').': '.$this->sponsor->sales_volume_points_shop.' <strong>
| '.__('team.net_turnover').': '.formatNumber($this->sponsor->sales_volume_total_sum).' &euro;</strong> | '.__('team.e').': '.formatNumber($this->sponsor->sales_volume_total).' &euro; | '.__('team.s').': '.formatNumber($this->sponsor->sales_volume_total_shop).' &euro;'
:
__('team.account_to').' '.$this->sponsor->payment_account_date).
if ($this->init_from === 'admin') {
$ret .= '<br><span class="small">' .
($this->sponsor->active_account ?
'<strong>' . __('team.total_points') . ': ' . $this->sponsor->sales_volume_points_KP_sum . '</strong> | ' . __('team.e') . ': ' . $this->sponsor->sales_volume_KP_points . ' | ' . __('team.s') . ': ' . $this->sponsor->sales_volume_points_shop . ' <strong>
| ' . __('team.net_turnover') . ': ' . formatNumber($this->sponsor->sales_volume_total_sum) . ' &euro;</strong> | ' . __('team.e') . ': ' . formatNumber($this->sponsor->sales_volume_total) . ' &euro; | ' . __('team.s') . ': ' . formatNumber($this->sponsor->sales_volume_total_shop) . ' &euro;'
:
__('team.account_to') . ' ' . $this->sponsor->payment_account_date) .
'</span>';
}
$ret .= '</span>
}
$ret .= '</span>
</div>
</li>';
return $ret;
return $ret;
}
return __('team.no_sponsor_assigned');
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -79,9 +79,11 @@ class TreeCalcBotOptimized
if ($storedStructure) {
$this->logger->info("Loading stored business structure for {$this->date->month}/{$this->date->year}");
$this->loadStoredStructure($storedStructure);
return;
} else {
$this->logger->info("Building fresh business structure for {$this->date->month}/{$this->date->year}");
$this->buildFreshStructure();
return;
}
} catch (\Exception $e) {
$this->logger->error("Error initializing admin structure: " . $e->getMessage());
@ -171,9 +173,8 @@ class TreeCalcBotOptimized
{
try {
$this->logger->info("Initializing business user details for: {$user->id}");
$this->businessUser = new BusinessUserItemOptimized($this->date, $this);
$this->businessUser->makeUserFromModel($user, $forceLiveCalculation); // ✅ Nutzt bereits User-Objekt
$this->businessUser->makeUserFromModel($user, $forceLiveCalculation);
$this->businessUser->checkSponsor($user);
// Führe vollständige Berechnung durch, wenn:
@ -184,14 +185,22 @@ class TreeCalcBotOptimized
$this->logger->info("Forcing live calculation for user {$user->id}");
}
// Aufbau der Struktur für den User in die unendliche Tiefe
// Phase 1: Aufbau der Struktur für den User in die unendliche Tiefe
$this->businessUser->readParentsBusinessUsers($forceLiveCalculation);
// Calculate Points in Lines (optimiert für Memory-Effizienz)
// Phase 2: Calculate Points in Lines (optimiert für Memory-Effizienz)
if (count($this->businessUser->businessUserItems) > 0) {
$this->calculateUserPointsOptimized($this->businessUser->businessUserItems, 1, $this->businessUser);
}
// Qualifikation nach qual_kp (KundenPoints) und qual_pp (PaylinePoints)
// Phase 3: Qualifikation für ALLE User in der Struktur berechnen (Bottom-Up)
// WICHTIG: Muss VOR der Root-Qualifikation erfolgen, damit die Kinder
// ihr qual_user_level haben (für Growth Bonus Differenz-Berechnung)
if (count($this->businessUser->businessUserItems) > 0) {
$this->calculateQualificationsForStructure($this->businessUser->businessUserItems);
}
// Phase 4: Qualifikation für ROOT-User nach qual_kp und qual_pp
$this->businessUser->calcQualPP();
}
} catch (\Exception $e) {
@ -200,6 +209,96 @@ class TreeCalcBotOptimized
}
}
/**
* Berechnet Qualifikationen für alle User in der Struktur rekursiv (Bottom-Up)
*
* WICHTIG: Diese Methode muss NACH der Punkte-Aggregation aufgerufen werden!
* Sie stellt sicher, dass alle User in der Struktur ihr qual_user_level haben,
* was für die Growth Bonus Differenz-Berechnung benötigt wird.
*
* Der Ablauf ist:
* 1. Rekursiv zuerst die Kinder berechnen (Bottom-Up)
* 2. business_lines für diesen User berechnen (basierend auf seinen Kindern)
* 3. Qualifikation berechnen (verwendet business_lines für Payline-Punkte)
*
* @param array $businessUserItems Array von BusinessUserItemOptimized
*/
private function calculateQualificationsForStructure(array $businessUserItems): void
{
foreach ($businessUserItems as $item) {
// Rekursiv zuerst die Kinder berechnen (Bottom-Up)
// So haben tiefere Ebenen ihr qual_user_level bevor die höheren Ebenen berechnet werden
if (!empty($item->businessUserItems)) {
$this->calculateQualificationsForStructure($item->businessUserItems);
}
// Business Lines für diesen User berechnen (basierend auf seinen Kindern)
// WICHTIG: Dies ist nötig, damit getPointsforPayline() korrekt funktioniert
if (!empty($item->businessUserItems)) {
$this->calculateBusinessLinesForUser($item);
}
// Dann Qualifikation für diesen User berechnen
// Nur wenn noch nicht berechnet (Performance-Optimierung)
if (!$item->isQualificationCalculated()) {
$item->calcQualPP(false);
}
}
}
/**
* Berechnet die business_lines für einen einzelnen User basierend auf seinen Kindern
*
* Diese Methode aggregiert die Team-Punkte der Kinder in die business_lines,
* ähnlich wie calculateUserPointsOptimized, aber nur für einen einzelnen User.
*
* @param BusinessUserItemOptimized $user Der User, für den die business_lines berechnet werden
*/
private function calculateBusinessLinesForUser(BusinessUserItemOptimized $user): void
{
// Bereits berechnet? (business_lines existieren und haben Daten)
$existingLines = $user->business_lines;
if (!empty($existingLines) && count($existingLines) > 0) {
return;
}
// Initialisiere business_lines über die Methode
$user->initBusinessLines();
// Sammle alle Kinder rekursiv mit ihrer Tiefe
$this->collectChildrenPointsForUser($user->businessUserItems, 1, $user);
}
/**
* Rekursive Hilfsfunktion zum Sammeln der Punkte für business_lines
*
* @param array $children Die Kinder des Users
* @param int $line Die aktuelle Linie (Tiefe)
* @param BusinessUserItemOptimized $targetUser Der User, für den wir die business_lines bauen
*/
private function collectChildrenPointsForUser(array $children, int $line, BusinessUserItemOptimized $targetUser): void
{
foreach ($children as $child) {
// Initialisiere die Linie falls nötig
if (!$targetUser->hasBusinessLine($line)) {
$obj = new stdClass();
$obj->points = 0;
$targetUser->addBusinessLineToUser($line, $obj);
}
// Füge die Team-Punkte des Kindes hinzu
$points = (float) ($child->sales_volume_points_TP_sum ?? 0);
if ($points > 0) {
$targetUser->addBusinessLinePoints($line, $points);
}
// Rekursiv für die Kinder des Kindes (nächste Linie)
if (!empty($child->businessUserItems)) {
$this->collectChildrenPointsForUser($child->businessUserItems, $line + 1, $targetUser);
}
}
}
/**
* Gibt Growth Bonus zurück (ab Linie 6)
* Erweitert um Array/Object-Kompatibilität für business_lines
@ -276,6 +375,15 @@ class TreeCalcBotOptimized
return $this->businessUsers;
}
/**
* Getter-Methoden (Rückwärtskompatibilität)
*/
public function getItem(): object
{
return $this->businessUser;
}
/**
* Zählt die Gesamtanzahl aller User in der Struktur (rekursiv)
*/

View file

@ -22,8 +22,8 @@ class TreeHelperOptimized
}
$qualKP = (int) $userBusiness->qual_kp;
$pointsSum = (int) $userBusiness->sales_volume_points_KP_sum;
$qualKP = (float) $userBusiness->qual_kp;
$pointsSum = (float) $userBusiness->sales_volume_points_KP_sum;
$isQual = $pointsSum >= $qualKP;
$badgeClass = $isQual ? 'badge-outline-success' : 'badge-outline-info';
@ -39,8 +39,8 @@ class TreeHelperOptimized
return '-';
}
$qualKP = (int) $user->user_level->qual_kp;
$pointsSum = (int) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_points_KP_sum');
$qualKP = (float) $user->user_level->qual_kp;
$pointsSum = (float) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_points_KP_sum');
$isQual = $pointsSum >= $qualKP;
$badgeClass = $isQual ? 'badge-outline-success' : 'badge-outline-warning-dark';
@ -54,9 +54,9 @@ class TreeHelperOptimized
public static function generateSalesVolumeDisplay(UserBusiness $userBusiness, string $type): string
{
if ($type === 'points') {
$total = (int) $userBusiness->sales_volume_points_KP_sum;
$individual = (int) $userBusiness->sales_volume_KP_points;
$shop = (int) $userBusiness->sales_volume_points_shop;
$total = (float) $userBusiness->sales_volume_points_KP_sum;
$individual = (float) $userBusiness->sales_volume_KP_points;
$shop = (float) $userBusiness->sales_volume_points_shop;
} else {
$total = (float) $userBusiness->sales_volume_total_sum;
$individual = (float) $userBusiness->sales_volume_total;
@ -64,9 +64,9 @@ class TreeHelperOptimized
$suffix = ' &euro;';
}
$totalFormatted = $type === 'points' ? $total : formatNumber($total);
$individualFormatted = $type === 'points' ? $individual : formatNumber($individual);
$shopFormatted = $type === 'points' ? $shop : formatNumber($shop);
$totalFormatted = formatNumber($total);
$individualFormatted = formatNumber($individual);
$shopFormatted = formatNumber($shop);
$suffix = $type === 'points' ? '' : ' &euro;';
return '<div class="no-line-break">' . $totalFormatted . $suffix . '</div>' .
@ -79,18 +79,18 @@ class TreeHelperOptimized
public static function generateSalesVolumeDisplayForUser(User $user, string $type, int $month, int $year): string
{
if ($type === 'points') {
$total = (int) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_points_KP_sum');
$individual = (int) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_KP_points');
$shop = (int) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_points_shop');
$total = (float) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_points_KP_sum');
$individual = (float) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_KP_points');
$shop = (float) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_points_shop');
} else {
$total = (float) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_total_sum');
$individual = (float) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_total');
$shop = (float) $user->getUserSalesVolumeBy($month, $year, 'sales_volume_total_shop');
}
$totalFormatted = $type === 'points' ? $total : formatNumber($total);
$individualFormatted = $type === 'points' ? $individual : formatNumber($individual);
$shopFormatted = $type === 'points' ? $shop : formatNumber($shop);
$totalFormatted = formatNumber($total);
$individualFormatted = formatNumber($individual);
$shopFormatted = formatNumber($shop);
$suffix = $type === 'points' ? '' : ' &euro;';
return '<div class="no-line-break">' . $totalFormatted . $suffix . '</div>' .

View file

@ -65,9 +65,9 @@ class TreeHtmlRenderer
return '<li class="dd-item dd-nodrag" data-id="">' .
'<div class="dd-handle">' .
$this->renderUserInfo($sponsor, false, true) .
$this->renderUserInfo($sponsor, false, true) .
'</div>' .
'</li>';
'</li>';
}
/**
@ -100,30 +100,30 @@ class TreeHtmlRenderer
$html = '<div class="dd-item">';
$html .= '<div class="dd-handle">';
$html .= '<div class="row">';
// Sponsor Info
$html .= '<div class="col-md-3">';
$html .= '<strong>' . e($sponsor->account->first_name . ' ' . $sponsor->account->last_name) . '</strong><br>';
$html .= '<small>' . e($sponsor->email) . '</small>';
$html .= '</div>';
// Account Info
$html .= '<div class="col-md-2">';
$html .= '<span class="badge badge-secondary">' . e($sponsor->account->m_account ?? '') . '</span>';
$html .= '</div>';
// Level Info
$html .= '<div class="col-md-2">';
if ($sponsor->user_level) {
$html .= '<span class="badge badge-primary">' . e($sponsor->user_level->getLang('name')) . '</span>';
}
$html .= '</div>';
// Status
$html .= '<div class="col-md-2">';
$html .= get_active_badge($sponsor->isActiveAccount());
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
@ -139,52 +139,52 @@ class TreeHtmlRenderer
$html = '<li class="dd-item" data-id="' . ($member->user_id ?? 0) . '">';
$html .= '<div class="dd-handle">';
$html .= '<div class="row">';
// Einrückung basierend auf Tiefe
$indent = str_repeat('&nbsp;&nbsp;&nbsp;&nbsp;', $depth);
// Name und Email
$html .= '<div class="col-md-3">';
$html .= $indent;
$html .= '<strong>' . e(($member->first_name ?? '') . ' ' . ($member->last_name ?? '')) . '</strong><br>';
$html .= $indent . '<small>' . e($member->email ?? '') . '</small>';
$html .= '</div>';
// Account ID
$html .= '<div class="col-md-2">';
$html .= '<span class="badge badge-secondary">' . e($member->m_account ?? '') . '</span>';
$html .= '</div>';
// Level
$html .= '<div class="col-md-2">';
if (!empty($member->user_level_name)) {
$html .= '<span class="badge badge-primary">' . e($member->user_level_name) . '</span>';
if ($member->next_qual_user_level) {
$html .= '<span class="badge badge-outline-success ml-2"><i class="fa fa-arrow-up text-success" title="Karriere-Level erreicht!"></i></span>';
}
}
$html .= '</div>';
// Qualifikation
$html .= '<div class="col-md-2">';
if (!empty($member->qual_kp)) {
$pointsSum = (int) ($member->sales_volume_points_KP_sum ?? 0);
$qualKP = (int) $member->qual_kp;
$pointsSum = (float) ($member->sales_volume_points_KP_sum ?? 0);
$qualKP = (float) $member->qual_kp;
$isQual = $pointsSum >= $qualKP;
$badgeClass = $isQual ? 'badge-success' : 'badge-warning';
$html .= '<span class="badge ' . $badgeClass . '">KU ' . $qualKP . '</span>';
}
$html .= '</div>';
// Status
$html .= '<div class="col-md-2">';
$html .= get_active_badge($member->active_account ?? 0);
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
// Kinder rendern
if (!empty($member->businessUserItems) && is_array($member->businessUserItems)) {
$html .= '<ol class="dd-list">';
@ -193,9 +193,9 @@ class TreeHtmlRenderer
}
$html .= '</ol>';
}
$html .= '</li>';
return $html;
}
@ -215,10 +215,10 @@ class TreeHtmlRenderer
return '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
'<div class="dd-handle">' .
$this->renderUserCardWithDepth($item, $deep) .
$this->renderUserCardWithDepth($item, $deep) .
'</div>' .
$childrenHtml .
'</li>';
'</li>';
}
/**
@ -228,9 +228,9 @@ class TreeHtmlRenderer
{
return '<li class="dd-item dd-nodrag" data-id="' . $item->user_id . '">' .
'<div class="dd-handle">' .
$this->renderUserInfo($item, true, false) .
$this->renderUserInfo($item, true, false) .
'</div>' .
'</li>';
'</li>';
}
/**
@ -242,15 +242,15 @@ class TreeHtmlRenderer
if ($deep > 0) {
$depthBadge = '<div class="d-flex flex-column justify-content-center align-items-center">' .
'<div class="text-large font-weight-bolder line-height-1 my-2 text-secondary badge badge-outline-secondary">' . $deep . '</div>' .
'</div>';
'</div>';
}
return '<div class="media align-items-center">' .
$depthBadge .
'<div class="media-body ml-2">' .
$this->renderUserInfo($item, false, false) .
$this->renderUserInfo($item, false, false) .
'</div>' .
'</div>';
'</div>';
}
/**
@ -262,16 +262,16 @@ class TreeHtmlRenderer
$iconClass = $item->active_account ? 'text-primary' : 'text-danger';
\Log::debug("TreeHtmlRenderer: Rendering user info for user {$item->user_id}");
$html = '<span class="' . $statusClass . '">';
// User Link
$html .= '<a href="#" class="text-black" data-toggle="modal" data-target="#modals-load-content" ' .
'data-id="' . $item->user_id . '" data-action="business-user-show" data-back="" ' .
'data-modal="modal-md" data-init_from="' . $this->initFrom . '" data-route="' . route('modal_load') . '">' .
'<span class="mr-1 ion ion-ios-contact ' . $iconClass . '"></span> ' .
'<strong>' . e($item->first_name . ' ' . $item->last_name) . '</strong>' .
'</a>';
'</a>';
// Email
$html .= ' <a href="mailto:' . e($item->email) . '">' . e($item->email) . '</a>';
@ -292,7 +292,7 @@ class TreeHtmlRenderer
$levelName = $item->user_level_name ? TranslationHelper::transUserLevelName($item->user_level_name) : '';
$account = $item->m_account ?: '';
$html .= ' <span class="badge badge-outline-default ' . $statusClass . '">' . e($levelName . ' | ' . $account) . '</span>';
// Karriere-Aufstiegs-Icon für qualifizierte User (nur in Struktur-Ansicht)#
if ($item->next_qual_user_level) {
@ -302,15 +302,15 @@ class TreeHtmlRenderer
// Details für aktive Accounts
if ($item->active_account) {
$html .= '<br><span class="small">';
if(!$isSponsor){
if (!$isSponsor) {
$html .= $this->renderAccountDetails($item);
}
// Action Button (außer für Sponsor-Ansicht)
if (!$isSponsor && $this->shouldShowActionButton()) {
$html .= $this->renderActionButton($item->user_id);
}
$html .= '</span>';
} else {
// Inaktive Accounts
@ -336,14 +336,14 @@ class TreeHtmlRenderer
$totalPoints = $item->sales_volume_points_KP_sum ?: 0;
$ePoints = $item->sales_volume_KP_points ?: 0;
$sPoints = $item->sales_volume_points_shop ?: 0;
$totalSum = $item->sales_volume_total_sum ?: 0;
$eSum = $item->sales_volume_total ?: 0;
$sSum = $item->sales_volume_total_shop ?: 0;
return '<strong>' . __('team.total_points') . ': ' . $totalPoints . '</strong> | ' .
__('team.e') . ': ' . $ePoints . ' | ' .
__('team.s') . ': ' . $sPoints . ' <strong> | ' .
return '<strong>' . __('team.total_points') . ': ' . formatNumber($totalPoints) . '</strong> | ' .
__('team.e') . ': ' . formatNumber($ePoints) . ' | ' .
__('team.s') . ': ' . formatNumber($sPoints) . ' <strong> | ' .
__('team.net_turnover') . ': ' . formatNumber($totalSum) . ' &euro;</strong> | ' .
__('team.e') . ': ' . formatNumber($eSum) . ' &euro; | ' .
__('team.s') . ': ' . formatNumber($sSum) . ' &euro;';
@ -363,7 +363,7 @@ class TreeHtmlRenderer
'data-optimized="1" ' .
'data-route="' . route('modal_load') . '">' .
'<span class="fa fa-calculator"></span>' .
'</button>';
'</button>';
}
/**
@ -372,8 +372,8 @@ class TreeHtmlRenderer
private function shouldShowActionButton(): bool
{
try {
return ($this->initFrom === 'admin' && \Auth::check() && \Auth::user()->isAdmin()) ||
($this->initFrom === 'member');
return ($this->initFrom === 'admin' && \Auth::check() && \Auth::user()->isAdmin()) ||
($this->initFrom === 'member');
} catch (\Exception $e) {
// Fallback for tests or when no user is authenticated
return $this->initFrom === 'member';
@ -388,4 +388,4 @@ class TreeHtmlRenderer
$this->initFrom = $initFrom;
return $this;
}
}
}