mivita/app/Services/BusinessPlan/TreeHtmlRenderer.php
Kevin 841d87e161 Incentive: Ticket-Fixes 2026-08-12 + Team-Struktur nach active_date sortiert
Incentive (Montenegro 2026):
- Live-Tracker: whereDate() für Zeitraum-Vergleiche – Bestellungen am letzten
  Tag des Qualifikationszeitraums gingen verloren (DATE-Spalte vs. Datetime)
- Repair/Rebuild: Neupartner-Definition auf Starterpaket-Bestelldatum im
  Zeitraum umgestellt (statt users.created_at) – Bestandskunden-Upgrades
  zählen jetzt wie im Live-Tracking (Fall Dino Portaro → Steffi Karrer)
- Neu: removeStaleSponsorTrackings() – Partner-Trackings wandern nach
  Sponsorwechsel zum aktuellen Sponsor (Fall Anna Schall lag beim Firmenkonto)
- incentive:calculate: Sponsorwechsel-Bereinigung eingebunden + Statistik

Team-Struktur (/user/team/structure):
- Downline sortiert nach active_date (Partner-Aktivierung) statt users.id:
  zentral in TreeHtmlRenderer::sortByActivation() (wirkt sofort, auch für
  gespeicherte Monats-Snapshots) + orderBy in beiden Downline-Queries

Doku: dev/2026-08-12-intensiv-tickets/README.md (4 Kundentickets analysiert,
Lücke durch verspätete Zeitraum-Verlängerung hatte sich per Nacht-Cron geheilt)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 10:28:06 +00:00

525 lines
17 KiB
PHP

<?php
namespace App\Services\BusinessPlan;
use App\Models\UserAbo;
use App\Services\TranslationHelper;
use App\User;
/**
* Klasse für die HTML-Darstellung von Business-Trees
* Trennt Präsentationslogik von Geschäftslogik
*/
class TreeHtmlRenderer
{
private string $initFrom;
private bool $forceLiveCalculation;
/**
* Abo-Kennzeichen je User-ID (gebatcht geladen, nicht pro Knoten).
*
* @var array<int, array{own: bool, customer_count: int}>
*/
private array $aboBadges = [];
public function __construct(string $initFrom = 'member', bool $forceLiveCalculation = false)
{
$this->initFrom = $initFrom;
$this->forceLiveCalculation = $forceLiveCalculation;
}
/**
* Rendert den kompletten Business-Tree als HTML
*/
public function renderTree(array $businessUsers): string
{
if (empty($businessUsers)) {
return '<div class="alert alert-info">Keine Business-User gefunden.</div>';
}
$this->loadAboBadges($this->collectUserIds($businessUsers));
$html = '<ol class="dd-list">';
foreach (self::sortByActivation($businessUsers) as $businessUser) {
$html .= $this->renderUserItem($businessUser, 0);
}
$html .= '</ol>';
return $html;
}
/**
* Sortiert Business-User-Items nach Partner-Aktivierung (active_date, dann user_id).
*
* Die Loader liefern die Downline in users.id-Reihenfolge (= Konto-Registrierung);
* gespeicherte Monats-Snapshots konservieren diese Reihenfolge zusaetzlich.
* Bestandskunden, die erst spaeter zum Berater upgraden, stuenden damit mitten in
* der Liste statt am Ende - deshalb wird hier zentral beim Rendern sortiert.
*
* Achtung: kein `$item->active_date ?? null` verwenden - BusinessUserItem(Optimized)
* hat __get ohne __isset, damit liefert `??` immer null.
*
* @param array<int, object> $items
* @return array<int, object>
*/
public static function sortByActivation(array $items): array
{
usort($items, function ($a, $b): int {
$aDate = $a->active_date;
$bDate = $b->active_date;
$aKey = $aDate ? strtotime((string) $aDate) : PHP_INT_MAX;
$bKey = $bDate ? strtotime((string) $bDate) : PHP_INT_MAX;
return $aKey <=> $bKey ?: ((int) $a->user_id <=> (int) $b->user_id);
});
return $items;
}
/**
* Rendert parentlose User als HTML
*/
public function renderParentless(array $parentless): string
{
if (empty($parentless)) {
return '<div class="alert alert-info">Keine parentlosen User gefunden.</div>';
}
$this->loadAboBadges($this->collectUserIds($parentless));
$html = '';
foreach ($parentless as $item) {
$html .= $this->renderParentlessItem($item);
}
return $html;
}
/**
* Rendert Sponsor-Information als HTML
*/
public function renderSponsor($sponsor): string
{
if (! $sponsor) {
return '<div class="alert alert-warning">'.__('team.no_sponsor_assigned').'</div>';
}
return '<li class="dd-item dd-nodrag" data-id="">'.
'<div class="dd-handle">'.
$this->renderUserInfo($sponsor, false, true).
'</div>'.
'</li>';
}
/**
* Rendert User Team Tree (für UserTeamCalcBot)
*/
public function renderUserTeamTree(array $teamMembers): string
{
if (empty($teamMembers)) {
return '<div class="alert alert-info">Keine Team-Mitglieder gefunden.</div>';
}
$html = '<ol class="dd-list">';
foreach ($teamMembers as $member) {
$html .= $this->renderTeamMemberItem($member, 0);
}
$html .= '</ol>';
return $html;
}
/**
* Rendert User Sponsor (für UserTeamCalcBot)
*/
public function renderUserSponsor(User $sponsor): string
{
if (! $sponsor || ! $sponsor->account) {
return '<div class="alert alert-info">Kein Sponsor gefunden.</div>';
}
$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>';
return $html;
}
/**
* Rendert einzelnes Team-Mitglied
*/
private function renderTeamMemberItem($member, int $depth): string
{
$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 = (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">';
foreach ($member->businessUserItems as $child) {
$html .= $this->renderTeamMemberItem($child, $depth + 1);
}
$html .= '</ol>';
}
$html .= '</li>';
return $html;
}
/**
* Rendert einen einzelnen User-Item mit Hierarchie
*/
private function renderUserItem($item, int $deep): string
{
$childrenHtml = '';
if ($item->businessUserItems && count($item->businessUserItems) > 0) {
$childrenHtml = '<ol class="dd-list dd-nodrag">';
foreach (self::sortByActivation($item->businessUserItems) as $child) {
$childrenHtml .= $this->renderUserItem($child, $deep + 1);
}
$childrenHtml .= '</ol>';
}
return '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
'<div class="dd-handle">'.
$this->renderUserCardWithDepth($item, $deep).
'</div>'.
$childrenHtml.
'</li>';
}
/**
* Rendert parentlosen User-Item
*/
private function renderParentlessItem($item): string
{
return '<li class="dd-item dd-nodrag" data-id="'.$item->user_id.'">'.
'<div class="dd-handle">'.
$this->renderUserInfo($item, true, false).
'</div>'.
'</li>';
}
/**
* Rendert User-Card mit Tiefe-Anzeige
*/
private function renderUserCardWithDepth($item, int $deep): string
{
$depthBadge = '';
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>';
}
return '<div class="media align-items-center">'.
$depthBadge.
'<div class="media-body ml-2">'.
$this->renderUserInfo($item, false, false).
'</div>'.
'</div>';
}
/**
* Rendert die Basis-User-Informationen
*/
private function renderUserInfo($item, bool $showSponsor = false, bool $isSponsor = false): string
{
$statusClass = $item->active_account ? '' : 'text-muted';
$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>';
// Email
$html .= ' <a href="mailto:'.e($item->email).'">'.e($item->email).'</a>';
// Optional: Geburtstag
$birthday = $item->user_birthday; // Magic Method __get() verwenden
if ($birthday && trim($birthday) !== '') {
$html .= ' | <i class="ion ion-ios-gift text-primary"></i> '.e($birthday);
}
// Optional: Telefon
$phone = $item->user_phone; // Magic Method __get() verwenden
if ($phone && trim($phone) !== '') {
$html .= ' | <i class="ion ion-ios-call text-primary"></i> '.e($phone);
}
// Level Badge
$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) {
$html .= '<span class="badge badge-outline-success ml-2"><i class="fa fa-arrow-up text-success" title="Karriere-Level erreicht!"></i></span>';
}
// Abo-Kennzeichen (eigenes Abo + eigene Kundenabos)
$html .= $this->renderAboBadges((int) $item->user_id);
// Details für aktive Accounts
if ($item->active_account) {
$html .= '<br><span class="small">';
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
$paymentDate = $item->payment_account_date ?: '';
$html .= '<br><span class="small">'.__('team.account_to').': '.e($paymentDate).'</span>';
}
// Sponsor für parentlose User
if ($showSponsor && $item->m_sponsor_name) {
$html .= '<br>'.e($item->m_sponsor_name);
}
$html .= '</span>';
return $html;
}
/**
* Rendert Account-Details (Punkte, Umsatz)
*/
private function renderAccountDetails($item): string
{
$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').': '.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;';
}
/**
* Rendert Action-Button für User-Details
*/
private function renderActionButton(int $userId): string
{
return ' | <button type="button" class="btn icon-btn btn-xs btn-secondary" '.
'data-toggle="modal" data-target="#modals-load-content" '.
'data-id="'.$userId.'" data-action="business-user-detail" '.
'data-back="" data-modal="modal-xl" '.
'data-init_from="'.$this->initFrom.'" '.
'data-live="'.$this->forceLiveCalculation.'" '.
'data-optimized="1" '.
'data-route="'.route('modal_load').'">'.
'<span class="fa fa-calculator"></span>'.
'</button>';
}
/**
* Prüft ob Action-Button angezeigt werden soll
*/
private function shouldShowActionButton(): bool
{
try {
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';
}
}
/**
* Lädt die Abo-Kennzeichen für alle übergebenen User-IDs gebatcht
* (zwei Group-by-Queries statt Einzelabfragen pro Knoten).
*
* Zählweise konsistent zur Backoffice-Statistik: aktive Abos = active=true
* und status NOT IN (4,5,6) via {@see UserAbo::scopeActiveAbo()}.
*
* @param array<int> $userIds
*/
public function loadAboBadges(array $userIds): void
{
$userIds = array_values(array_unique(array_filter($userIds)));
if (empty($userIds)) {
return;
}
$ownAboUserIds = UserAbo::query()
->activeAbo()
->where('is_for', 'me')
->whereIn('user_id', $userIds)
->distinct()
->pluck('user_id');
foreach ($ownAboUserIds as $userId) {
$this->aboBadges[(int) $userId]['own'] = true;
}
$customerAboCounts = UserAbo::query()
->activeAbo()
->where('is_for', 'ot')
->whereIn('member_id', $userIds)
->groupBy('member_id')
->selectRaw('member_id, COUNT(*) as abo_count')
->pluck('abo_count', 'member_id');
foreach ($customerAboCounts as $userId => $count) {
$this->aboBadges[(int) $userId]['customer_count'] = (int) $count;
}
}
/**
* Rendert die Abo-Kennzeichen für einen User:
* grünes "A"-Badge bei eigenem aktiven Abo, rotes "(n)"-Badge bei n > 0 eigenen aktiven Kundenabos.
*/
public function renderAboBadges(int $userId): string
{
$hasOwnAbo = $this->aboBadges[$userId]['own'] ?? false;
$customerAboCount = $this->aboBadges[$userId]['customer_count'] ?? 0;
$html = '';
if ($hasOwnAbo) {
$html .= ' <span class="badge badge-success" title="'.e(__('team.badge_own_abo')).'">A</span>';
}
if ($customerAboCount > 0) {
$html .= ' <span class="badge badge-danger" title="'.
e(trans_choice('team.badge_customer_abos', $customerAboCount)).'">('.$customerAboCount.')</span>';
}
return $html;
}
/**
* Sammelt rekursiv alle User-IDs aus den Business-User-Items.
*
* @return array<int>
*/
private function collectUserIds(array $items): array
{
$userIds = [];
foreach ($items as $item) {
$userId = (int) $item->user_id;
if ($userId > 0) {
$userIds[] = $userId;
}
if (isset($item->businessUserItems) && is_array($item->businessUserItems) && count($item->businessUserItems) > 0) {
$userIds = array_merge($userIds, $this->collectUserIds($item->businessUserItems));
}
}
return $userIds;
}
/**
* Setzt den Kontext für die Darstellung
*/
public function setInitFrom(string $initFrom): self
{
$this->initFrom = $initFrom;
return $this;
}
}