mivita/app/Services/Backoffice/BackofficeDashboardService.php
Kevin ac3bd6e8ea Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)
Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert

Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:56:02 +00:00

873 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
namespace App\Services\Backoffice;
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class BackofficeDashboardService
{
private const MAX_DEPTH_SAFETY_LIMIT = 100;
private const LEADER_LEVEL_NAME = 'Vertriebsleiter';
/**
* @return array<string, string>
*/
public function metricLabels(): array
{
return [
'consultants' => 'Berater',
'new_partners' => 'Neupartner',
'team_partner_abos' => 'Teamabos',
'team_customer_abos' => 'Teamkundenabos',
'own_points' => 'Eigenpunkte',
'external_points' => 'Externe Kundenpunkte',
'customer_abo_points' => 'Kundenabo-Punkte',
'customer_single_order_points' => 'Einzelbestellungs-Punkte',
'customer_other_points' => 'Sonstige Kundenpunkte',
'total_points' => 'Gesamtpunkte',
'shop_1000' => '1000 Punkte Shop',
];
}
/**
* @return array{month: int, year: int, metric_labels: array<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
public function overview(User $user, int $month, int $year): array
{
if ($this->isClosedMonth($month, $year)) {
$snapshot = BackofficeStatisticsSnapshot::query()
->where('user_id', $user->id)
->where('month', $month)
->where('year', $year)
->first();
if ($snapshot) {
return $this->withMeta(
$this->overlayCurrentAboStock($user, $snapshot->payload),
'snapshot',
$snapshot->calculated_at?->format('d.m.Y H:i')
);
}
}
return $this->withMeta($this->buildOverview($user, $month, $year), 'live');
}
public function storeSnapshot(User $user, int $month, int $year): BackofficeStatisticsSnapshot
{
$payload = $this->buildOverview($user, $month, $year);
return BackofficeStatisticsSnapshot::query()->updateOrCreate(
[
'user_id' => $user->id,
'year' => $year,
'month' => $month,
],
[
'payload' => $payload,
'calculated_at' => now(),
]
);
}
/**
* @return array{month: int, year: int, metric_labels: array<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
private function buildOverview(User $user, int $month, int $year): array
{
$lineBuckets = $this->lineBuckets($user->id);
$lines = [];
$totals = $this->emptyLine(0, []);
foreach ($lineBuckets as $line => $users) {
$row = $this->buildLineRow($line, $users, $month, $year);
$lines[$line] = $row;
$totals = $this->addToTotals($totals, $row);
}
// 1000-Punkte-Shop: der VIP selbst zählt mit (Kundenwunsch), obwohl er in
// keiner Linie steckt — nur diese Kennzahl bekommt ihn zusätzlich.
$totals['shop_1000'] += $this->shop1000Count([(int) $user->id], $month, $year);
$totals['label'] = 'Summe';
return [
'month' => $month,
'year' => $year,
'metric_labels' => $this->metricLabels(),
'lines' => $lines,
'totals' => $totals,
'non_payline_points' => $this->nonPaylinePointsSum($totals['user_ids'], $month, $year),
'new_customer_abos' => $this->newCustomerAboRanking($user, $lineBuckets, $month, $year),
'leader_abos' => $this->leaderAboOverview($lineBuckets),
'top_customer_abos' => $this->topCustomerAboRanking($user, $lineBuckets, $month, $year),
];
}
/**
* Überlagert einen Snapshot-Payload mit den tagesaktuellen Abo-Bestandszahlen
* (Kundenentscheid F7): Teamabos/Teamkundenabos je Linie + Summe sowie die
* Führungskräfte- und Top-10-Listen sind Bestands-Kennzahlen ohne Monatsbezug
* und werden live nachgerechnet. Monatsbezogene Werte (Punkte, Umsätze,
* Neupartner, "+N neu", neue Kundenabos) bleiben eingefroren.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function overlayCurrentAboStock(User $user, array $payload): array
{
$lineBuckets = $this->lineBuckets($user->id);
$totalPartnerAbos = 0;
$totalCustomerAbos = 0;
$snapshotLines = collect($payload['lines'] ?? [])
->map(fn ($row, $key) => (int) ($row['line'] ?? $key))
->values()
->all();
foreach ($payload['lines'] ?? [] as $key => $row) {
$line = (int) ($row['line'] ?? $key);
$userIds = ($lineBuckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
$partnerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
$customerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
$payload['lines'][$key]['team_partner_abos'] = $partnerAbos;
$payload['lines'][$key]['team_customer_abos'] = $customerAbos;
$totalPartnerAbos += $partnerAbos;
$totalCustomerAbos += $customerAbos;
}
// Linien, die erst nach dem Snapshot entstanden sind (Team ist heute tiefer),
// tragen ebenfalls aktuellen Abo-Bestand: als neue Zeile anhängen, Monatswerte 0.
foreach ($lineBuckets as $line => $users) {
if (in_array((int) $line, $snapshotLines, true)) {
continue;
}
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
if ($userIds === []) {
continue;
}
$partnerAbos = $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
$customerAbos = $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
if ($partnerAbos === 0 && $customerAbos === 0) {
continue;
}
$row = $this->emptyLine((int) $line, $userIds);
$row['team_partner_abos'] = $partnerAbos;
$row['team_customer_abos'] = $customerAbos;
$payload['lines'][(int) $line] = $row;
$totalPartnerAbos += $partnerAbos;
$totalCustomerAbos += $customerAbos;
}
$payload['lines'] = collect($payload['lines'] ?? [])->sortBy('line')->all();
if (isset($payload['totals'])) {
$payload['totals']['team_partner_abos'] = $totalPartnerAbos;
$payload['totals']['team_customer_abos'] = $totalCustomerAbos;
}
$payload['leader_abos'] = $this->leaderAboOverview($lineBuckets);
$payload['top_customer_abos'] = $this->topCustomerAboRanking($user, $lineBuckets, (int) ($payload['month'] ?? now()->month), (int) ($payload['year'] ?? now()->year));
$payload['abo_stock_live'] = true;
return $payload;
}
/**
* @param array<string, mixed> $overview
* @return array<string, mixed>
*/
private function withMeta(array $overview, string $source, ?string $calculatedAt = null): array
{
$overview['_meta'] = [
'source' => $source,
'source_label' => $source === 'snapshot' ? 'Snapshot' : 'Live',
'calculated_at' => $calculatedAt,
];
return $overview;
}
private function isClosedMonth(int $month, int $year): bool
{
return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth());
}
/**
* @return array<int, Collection<int, User>>
*/
public function lineBuckets(int $rootUserId): array
{
$lineBuckets = [];
$currentSponsorIds = [$rootUserId];
$visitedUserIds = [$rootUserId];
for ($line = 1; $line <= self::MAX_DEPTH_SAFETY_LIMIT && $currentSponsorIds !== []; $line++) {
$users = User::query()
->with(['account', 'user_level'])
->whereIn('m_sponsor', $currentSponsorIds)
->whereNotIn('id', $visitedUserIds)
->whereColumn('id', '!=', 'm_sponsor')
->whereNull('deleted_at')
->get();
if ($users->isEmpty()) {
break;
}
$lineBuckets[$line] = $users;
$currentSponsorIds = $users->pluck('id')->all();
$visitedUserIds = array_merge($visitedUserIds, $currentSponsorIds);
}
return $lineBuckets;
}
/**
* @return int[]
*/
public function lineUserIds(int $rootUserId, int $line): array
{
if ($line === 0) {
return $this->teamUserIds($rootUserId);
}
if ($line < 1) {
return [];
}
return ($this->lineBuckets($rootUserId)[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
}
/**
* @return int[]
*/
public function teamUserIds(int $rootUserId): array
{
return collect($this->lineBuckets($rootUserId))
->flatMap(fn (Collection $users) => $users->pluck('id'))
->map(fn ($id) => (int) $id)
->values()
->all();
}
/**
* IDs der gesamten Downline, aber nur aktive Berater
* (gleiche Definition wie die "Berater"-Spalte der Backoffice-Statistik).
*
* @return int[]
*/
public function activeTeamUserIds(int $rootUserId): array
{
return collect($this->lineBuckets($rootUserId))
->flatMap(fn (Collection $users) => $users)
->filter(fn (User $user) => $this->isActiveConsultant($user))
->map(fn (User $user) => (int) $user->id)
->values()
->all();
}
/**
* Ein aktiver Berater hat eine Mitgliedsebene und ein gesetztes Abrechnungskonto.
* Maßgeblich für die "Berater"-Zählung in den Team-Übersichten.
*/
public function isActiveConsultant(User $user): bool
{
return $user->m_level !== null && $user->payment_account !== null;
}
/**
* @param Collection<int, User> $users
* @return array<string, mixed>
*/
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
{
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
if ($userIds === []) {
return $this->emptyLine($line, $userIds);
}
$salesSummary = $this->salesSummary($userIds, $month, $year);
return [
'line' => $line,
'label' => 'Linie '.$line,
'user_ids' => $userIds,
'consultants' => $this->activeConsultants($users),
'new_partners' => $this->newPartners($users, $month, $year),
'team_partner_abos' => $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(),
'team_partner_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year),
'team_customer_abos' => $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(),
'team_customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year),
'own_points' => $salesSummary['own_points'],
'external_points' => $salesSummary['external_points'],
'customer_abo_points' => $salesSummary['customer_abo_points'],
'customer_single_order_points' => $salesSummary['customer_single_order_points'],
'customer_other_points' => $salesSummary['customer_other_points'],
'total_points' => $salesSummary['total_points'],
'turnover_net' => $salesSummary['turnover_net'],
'shop_1000' => $this->shop1000Count($userIds, $month, $year),
];
}
/**
* @return array<string, mixed>
*/
private function emptyLine(int $line, array $userIds): array
{
return [
'line' => $line,
'label' => $line > 0 ? 'Linie '.$line : 'Summe',
'user_ids' => $userIds,
'consultants' => 0,
'new_partners' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'own_points' => 0.0,
'external_points' => 0.0,
'customer_abo_points' => 0.0,
'customer_single_order_points' => 0.0,
'customer_other_points' => 0.0,
'total_points' => 0.0,
'turnover_net' => 0.0,
'shop_1000' => 0,
];
}
/**
* @param array<string, mixed> $totals
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function addToTotals(array $totals, array $row): array
{
foreach (array_keys($this->metricLabels()) as $metric) {
$totals[$metric] += $row[$metric];
}
$totals['turnover_net'] += $row['turnover_net'];
$totals['team_partner_abos_new'] += $row['team_partner_abos_new'];
$totals['team_customer_abos_new'] += $row['team_customer_abos_new'];
$totals['user_ids'] = array_merge($totals['user_ids'], $row['user_ids']);
return $totals;
}
/**
* @param Collection<int, User> $users
*/
private function activeConsultants(Collection $users): int
{
return $users
->filter(fn (User $user) => $this->isActiveConsultant($user))
->count();
}
/**
* @param Collection<int, User> $users
*/
private function newPartners(Collection $users, int $month, int $year): int
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return $users
->filter(function (User $user) use ($startDate, $endDate): bool {
if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) {
return false;
}
$activeDate = Carbon::parse($user->active_date);
return $activeDate->betweenIncluded($startDate, $endDate);
})
->count();
}
private function hasActivePaymentAccount(User $user): bool
{
return $user->payment_account !== null && Carbon::parse($user->payment_account)->isFuture();
}
private function activeAboQuery(): Builder
{
return UserAbo::query()
->where('active', true)
->whereNotIn('status', [4, 5, 6]);
}
/**
* @param int[] $userIds
*/
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return $this->activeAboQuery()
->whereIn($userColumn, $userIds)
->where('is_for', $isFor)
->whereBetween('start_date', [$startDate, $endDate])
->count();
}
/**
* @param int[] $userIds
* @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, turnover_net: float}
*/
private function salesSummary(array $userIds, int $month, int $year): array
{
$summary = UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->selectRaw(SalesVolumePoints::ownPoints().' as own_points')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::customerAboPoints().' as customer_abo_points')
->selectRaw(SalesVolumePoints::customerSingleOrderPoints().' as customer_single_order_points')
->selectRaw(SalesVolumePoints::customerOtherPoints().' as customer_other_points')
->selectRaw(SalesVolumePoints::turnoverNet().' as turnover_net')
->first();
$ownPoints = (float) ($summary->own_points ?? 0);
$externalPoints = (float) ($summary->external_points ?? 0);
return [
'own_points' => $ownPoints,
'external_points' => $externalPoints,
'customer_abo_points' => (float) ($summary->customer_abo_points ?? 0),
'customer_single_order_points' => (float) ($summary->customer_single_order_points ?? 0),
'customer_other_points' => (float) ($summary->customer_other_points ?? 0),
'total_points' => $ownPoints + $externalPoints,
'turnover_net' => (float) ($summary->turnover_net ?? 0),
];
}
/**
* Summe der Punkte des Teams, die nicht in die Payline fließen (nur-KP-Einträge
* wie Neupartner-Einschreibungspunkte) — erklärt die Differenz zwischen den
* Gesamtpunkten hier und der PP-Summe der Struktur-/Provisionsansicht.
*
* @param int[] $userIds
*/
private function nonPaylinePointsSum(array $userIds, int $month, int $year): float
{
if ($userIds === []) {
return 0.0;
}
return (float) UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->selectRaw(SalesVolumePoints::nonPaylinePoints().' as points')
->value('points');
}
/**
* Anzahl der Neupartner (aktiver Account, im Monat aktiv geworden).
* Konsistent mit der Drilldown-Liste {@see BackofficeDrilldownService::newPartnerRows()}.
*
* @param int[] $userIds
*/
public function newPartnersCount(array $userIds, int $month, int $year): int
{
if ($userIds === []) {
return 0;
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return User::query()
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->where('payment_account', '>', now())
->whereBetween('active_date', [$startDate, $endDate])
->count();
}
/**
* Ranking: Wer im Team hat im Monat wie viele neue Kundenabos abgeschlossen?
* Sortiert nach Anzahl absteigend (nicht nach Linie), Linie wird je Berater mitgeliefert,
* dazu der aktuelle Gesamtbestand an Kundenabos des Beraters.
* Der Statistik-Nutzer selbst zählt mit (Kundenwunsch, Linie 0 / "ich").
* Zählweise identisch zu team_customer_abos_new {@see newAboCount()}.
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, abo_count: int, total_abo_count: int}>
*/
private function newCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
{
$usersByLine = $this->usersWithLine($lineBuckets, $rootUser);
if ($usersByLine === []) {
return [];
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
$counts = $this->activeAboQuery()
->whereIn('member_id', array_keys($usersByLine))
->where('is_for', 'ot')
->whereBetween('start_date', [$startDate, $endDate])
->selectRaw('member_id, COUNT(*) as abo_count')
->groupBy('member_id')
->pluck('abo_count', 'member_id');
$totalCounts = $this->customerAboCounts($counts->keys()->all());
return collect($usersByLine)
->filter(fn (array $entry, int $userId) => $counts->has($userId))
->map(fn (array $entry, int $userId) => [
'user_id' => $userId,
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'is_self' => $entry['line'] === 0,
'abo_count' => (int) $counts->get($userId),
'total_abo_count' => (int) $totalCounts->get($userId, 0),
])
->sortByDesc('abo_count')
->values()
->all();
}
/**
* Abos der Führungskräfte (ab Stufe Vertriebsleiter) im Team: Gesamtzahlen plus
* Liste aller Führungskräfte inklusive Linie, eigener Abos, Kundenabos sowie
* Team-Abos und Team-Kundenabos ihres Teilbaums und der Gesamtsumme aller
* vier Kennzahlen (Kundenwunsch B7).
*
* Zwei Team-Sichten je Führungskraft (Kundenentscheid 2026-07-24, revidiert F9):
* - team_abo_count / team_customer_abo_count ("direkt"): Teilbaum wird an der
* nächsten Führungskraft abgeschnitten — überlappungsfrei, die Kopf-Summen
* (total_*) bleiben dadurch echte Gesamtwerte ohne Doppelzählung.
* - team_abo_count_total / team_customer_abo_count_total ("gesamt"): komplette
* Downline der Führungskraft; verschachtelte Führungskräfte zählen hier
* mehrfach, deshalb werden diese Spalten nicht aufsummiert.
* total_abo_count_full = eigene + Kundenabos + Team gesamt (Sortierung).
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array{level_name: string, total: int, total_own: int, total_customer: int, total_team: int, total_team_customer: int, rows: array<int, array{user_id: int, name: string, career_level: string, line: int, own_abo_count: int, customer_abo_count: int, team_abo_count: int, team_customer_abo_count: int, team_abo_count_total: int, team_customer_abo_count_total: int, total_abo_count: int, total_abo_count_full: int}>}
*/
private function leaderAboOverview(array $lineBuckets): array
{
$leaderMinPos = UserLevel::query()
->where('name', self::LEADER_LEVEL_NAME)
->value('pos');
$usersWithLine = $this->usersWithLine($lineBuckets);
$isLeader = fn (User $user): bool => $leaderMinPos !== null && ($user->user_level?->pos ?? 0) >= $leaderMinPos;
$leadersByLine = $leaderMinPos === null ? [] : collect($usersWithLine)
->filter(fn (array $entry) => $isLeader($entry['user']))
->all();
if ($leadersByLine === []) {
return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'total_team' => 0, 'total_team_customer' => 0, 'rows' => []];
}
$leaderIds = array_keys($leadersByLine);
$teamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, $isLeader);
$fullTeamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, fn (User $user): bool => false);
$allCountIds = array_values(array_unique(array_merge($leaderIds, ...array_values($fullTeamIdsByLeader))));
$ownCounts = $this->activeAboQuery()
->whereIn('user_id', $allCountIds)
->where('is_for', 'me')
->selectRaw('user_id, COUNT(*) as abo_count')
->groupBy('user_id')
->pluck('abo_count', 'user_id');
$customerCounts = $this->customerAboCounts($allCountIds);
$rows = collect($leadersByLine)
->map(function (array $entry, int $userId) use ($ownCounts, $customerCounts, $teamIdsByLeader, $fullTeamIdsByLeader) {
$teamIds = $teamIdsByLeader[$userId] ?? [];
$fullTeamIds = $fullTeamIdsByLeader[$userId] ?? [];
$own = (int) $ownCounts->get($userId, 0);
$customer = (int) $customerCounts->get($userId, 0);
$team = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0));
$teamCustomer = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0));
$teamTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0));
$teamCustomerTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0));
return [
'user_id' => $userId,
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'own_abo_count' => $own,
'customer_abo_count' => $customer,
'team_abo_count' => $team,
'team_customer_abo_count' => $teamCustomer,
'team_abo_count_total' => $teamTotal,
'team_customer_abo_count_total' => $teamCustomerTotal,
'total_abo_count' => $own + $customer + $team + $teamCustomer,
'total_abo_count_full' => $own + $customer + $teamTotal + $teamCustomerTotal,
];
})
->sortByDesc('total_abo_count_full')
->values();
$totalOwn = (int) $rows->sum('own_abo_count');
$totalCustomer = (int) $rows->sum('customer_abo_count');
$totalTeam = (int) $rows->sum('team_abo_count');
$totalTeamCustomer = (int) $rows->sum('team_customer_abo_count');
return [
'level_name' => self::LEADER_LEVEL_NAME,
'total' => $totalOwn + $totalCustomer + $totalTeam + $totalTeamCustomer,
'total_own' => $totalOwn,
'total_customer' => $totalCustomer,
'total_team' => $totalTeam,
'total_team_customer' => $totalTeamCustomer,
'rows' => $rows->all(),
];
}
/**
* Teilbaum-User-IDs je Führungskraft aus der gemeinsamen Sponsor-Map
* (ein Durchlauf über die bereits geladene Downline, keine Abfragen pro
* Führungskraft). Der Abstieg stoppt an jedem Kind, für das $isLeader true
* liefert (dieses und sein Team werden übersprungen); mit einem immer-false
* Callable ergibt sich die komplette Downline.
*
* @param array<int, array{user: User, line: int}> $usersWithLine
* @param int[] $leaderIds
* @return array<int, int[]>
*/
private function teamIdsPerLeader(array $usersWithLine, array $leaderIds, callable $isLeader): array
{
$childrenBySponsor = [];
foreach ($usersWithLine as $entry) {
$childrenBySponsor[(int) $entry['user']->m_sponsor][] = $entry['user'];
}
$teamIdsByLeader = [];
foreach ($leaderIds as $leaderId) {
$teamIds = [];
$queue = $childrenBySponsor[$leaderId] ?? [];
while ($queue !== []) {
$child = array_shift($queue);
if ($isLeader($child)) {
continue;
}
$teamIds[] = (int) $child->id;
foreach ($childrenBySponsor[(int) $child->id] ?? [] as $grandChild) {
$queue[] = $grandChild;
}
}
$teamIdsByLeader[$leaderId] = $teamIds;
}
return $teamIdsByLeader;
}
/**
* Top-10-Ranglisten der Kundenabos im eigenen Team (Kundenwunsch B5/B6,
* "unabhängig vom Incentive" = aktueller Gesamtbestand aktiver Kundenabos):
*
* - by_count: nach Anzahl aktiver Kundenabos je Berater
* - by_points: nach Umsatz mit Ist (im gewählten Monat abgerechnete
* Kundenabo-Punkte aus user_sales_volumes, Hauptwert und Sortierung —
* Kundenentscheid 2026-07-24) und Soll (Abo-Punkte-Bestand, Produktpunkte
* × Menge der laufenden Kundenabos) als Zweitwert
*
* Der Statistik-Nutzer selbst zählt in beiden Listen mit (Linie 0 / "ich").
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array{by_count: array<int, array<string, mixed>>, by_points: array<int, array<string, mixed>>}
*/
private function topCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
{
$usersWithLine = $this->usersWithLine($lineBuckets, $rootUser);
if ($usersWithLine === []) {
return ['by_count' => [], 'by_points' => []];
}
$memberIds = array_keys($usersWithLine);
$aboCounts = $this->customerAboCounts($memberIds);
$targetPoints = $this->customerAboTargetPoints($memberIds);
$actualPoints = $this->customerAboActualPoints($memberIds, $month, $year);
$rows = collect($usersWithLine)
->map(fn (array $entry, int $userId) => [
'user_id' => $userId,
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'is_self' => $entry['line'] === 0,
'abo_count' => (int) $aboCounts->get($userId, 0),
'points_target' => (float) $targetPoints->get($userId, 0.0),
'points_actual' => (float) $actualPoints->get($userId, 0.0),
]);
return [
'by_count' => $rows
->filter(fn (array $row) => $row['abo_count'] > 0)
->sortByDesc('abo_count')
->take(10)
->values()
->all(),
'by_points' => $rows
->filter(fn (array $row) => $row['points_target'] > 0 || $row['points_actual'] > 0)
->sortByDesc('points_actual')
->take(10)
->values()
->all(),
];
}
/**
* Soll-Punkte je Berater: Abo-Punkte-Bestand der aktiven Kundenabos
* (Produktpunkte × Menge, nur Hauptartikel comp = 0 — gleiche Logik wie
* {@see UserAbo::getTotalPoints()}).
*
* @param int[] $memberIds
* @return Collection<int, float>
*/
private function customerAboTargetPoints(array $memberIds): Collection
{
return UserAbo::query()
->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id')
->join('products', 'products.id', '=', 'user_abo_items.product_id')
->whereNull('products.deleted_at')
->where('user_abos.active', true)
->whereNotIn('user_abos.status', [4, 5, 6])
->whereIn('user_abos.member_id', $memberIds)
->where('user_abos.is_for', 'ot')
->where('user_abo_items.comp', 0)
->selectRaw('user_abos.member_id, COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
->groupBy('user_abos.member_id')
->pluck('points', 'member_id')
->map(fn ($points) => (float) $points);
}
/**
* Ist-Punkte je Berater: im Monat tatsächlich abgerechnete Kundenabo-Punkte.
*
* @param int[] $memberIds
* @return Collection<int, float>
*/
private function customerAboActualPoints(array $memberIds, int $month, int $year): Collection
{
return UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $memberIds)
->where('month', $month)
->where('year', $year)
->select('user_sales_volumes.user_id')
->selectRaw(SalesVolumePoints::customerAboPoints().' as points')
->groupBy('user_sales_volumes.user_id')
->pluck('points', 'user_id')
->map(fn ($points) => (float) $points);
}
/**
* Aktueller Bestand aktiver Kundenabos je Berater (member_id).
*
* @param int[] $memberIds
* @return Collection<int, int>
*/
private function customerAboCounts(array $memberIds): Collection
{
if ($memberIds === []) {
return collect();
}
return $this->activeAboQuery()
->whereIn('member_id', $memberIds)
->where('is_for', 'ot')
->selectRaw('member_id, COUNT(*) as abo_count')
->groupBy('member_id')
->pluck('abo_count', 'member_id');
}
/**
* Downline-User mit ihrer Linie; optional inklusive des Statistik-Nutzers
* selbst als Linie 0 (er steckt in keinem lineBucket, {@see lineBuckets()}).
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array<int, array{user: User, line: int}>
*/
private function usersWithLine(array $lineBuckets, ?User $rootUser = null): array
{
$usersByLine = [];
if ($rootUser !== null) {
$usersByLine[(int) $rootUser->id] = ['user' => $rootUser, 'line' => 0];
}
foreach ($lineBuckets as $line => $users) {
foreach ($users as $user) {
$usersByLine[(int) $user->id] = ['user' => $user, 'line' => $line];
}
}
return $usersByLine;
}
private function userName(User $user): string
{
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));
return $name !== '' ? $name : ($user->email ?: '#'.$user->id);
}
private function careerLevel(User $user): string
{
return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-');
}
/**
* Anzahl der Berater mit >= 1000 Punkten Kundenumsatz (KP + Shop) im Monat.
*
* @param int[] $userIds
*/
public function shop1000Count(array $userIds, int $month, int $year): int
{
return UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_id')
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
->groupBy('user_id')
->havingRaw(SalesVolumePoints::totalPoints().' >= 1000')
->get()
->count();
}
}