mivita/app/Services/Backoffice/BackofficeDashboardService.php
Kevin cee4a2e0eb Statistiken: Review-Fixes vor Livegang (Fehldarstellungen + Test-Regression)
Ergebnis des Senior-Reviews über dev/2026-07-22 (Konzept 22.07., Feedback 24.07.,
Plan 19.08.). Rechnerisch war nichts zu korrigieren: Payline-Punkte und Provision
des Prognose-Blocks (N4) stimmen exakt mit der Provisions-Engine überein
(54.718,5 P. bzw. 3.772,88 EUR gegengerechnet an User 454). Behoben sind
Darstellungs- und Konsistenzfehler, die der Beraterin als falsche Zahlen
aufgefallen wären:

- "+N neu" schrumpfte rückwirkend, der Bestand daneben nicht: newAboCount() nutzte
  weiter activeAbo(), während aboStockCount() auf das Monatsende zurückrechnet.
  Beide teilen sich jetzt aboStockQuery(); die Jahres-Wachstumstabelle ebenso.
- Level-Kachel widersprach sich ("Zum nächsten Level Diamant fehlen X" über
  "Karriere-Level: Diamant"): Text auf die Monats-Qualifikation umformuliert
  (de/en/es/fr), Überschrift "Qualifikation in diesem Monat", Karriere-Level nur
  noch bei Abweichung.
- Firstlines-Kachel zählte alle direkten Partner, "Teampartner gesamt" daneben nur
  aktive (F1): Kachel nutzt jetzt isActiveConsultant(), die Detailseite listet
  weiter alle und weist beide Zahlen aus.
- Neupartnerzahlen vergangener Monate sanken auf dem Home-Dashboard, sobald
  Jahresgebühren ausliefen: neue User::newPartnerReferenceDate() (laufender Monat =
  heute, abgeschlossener Monat = Monatsende). Dabei die drei Inline-Kopien der
  Neupartner-Regel auf scopeNewPartnerIn()/isNewPartnerIn() zusammengeführt.
- Top-3-Karte ließ den Betrachter aus, obwohl "ich gehört in die Liste" in der
  Juli-Runde für alle anderen Ranglisten umgesetzt wurde: zählt jetzt mit, inkl.
  is_self und "ich"-Badge.
- Neues Profilfoto zeigte bis zu 6 h ein totes Bild: die URL steckt nicht mehr im
  Cache, sondern entsteht beim Lesen (withProfileImages()).
- Test-Regression seit der Juli-Runde: Unit/Services/BackofficeDashboardServiceTest
  fiel über die fehlende Spalte status_points (kam mit nonPaylinePoints() dazu).
- Kleinigkeiten: ProfileImageController gab die komplette Exception als
  Flash-Message aus (jetzt Log + Sprachkey), BackofficeDrilldownService formatierte
  Datumsangaben hart auf d.m.Y statt über Util::formatDateDB().

Tests: neuer HomeMonthlyStatsTest (buildMonthlyStats war ungetestet) plus Tests für
Stichtags-Zählung und Foto-Cache. Fünf Tests kodierten das alte Verhalten
(Betrachter zählt nicht mit, abgelaufener Account zählt nie) und wurden auf die
neue Semantik umgestellt, jeweils mit Gegenprobe. Suite: 861 grün (vorher 855),
89 rot — ausschließlich die in docs/2026-06-11-test-suite-vorbestehende-fehler.md
dokumentierten Altlasten.

Offen und bewusst nicht enthalten:
- Deploy-Schritt: backoffice:store-statistics-snapshots --force (vorher dumpen).
  Kein vorhandener Snapshot enthält recruiter_ranking, Mai/Juni zusätzlich weder
  non_payline_points noch die Führungskräfte-"gesamt"-Spalten; der Cron überspringt
  vorhandene Snapshots ohne --force. Achtung: die Neuberechnung ist nicht
  verlustfrei.
- TreeHtmlRendererTest/TreeCalcBotIntegrationTest: die Abo-Badges machten den
  Renderer datenbankabhängig; die Dateien waren schon vorher rot und gehören in die
  separate Test-Aufräumaufgabe.
- deleteImage bleibt ein GET-Route (gleiches Muster wie das Shop-Bild).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 09:41:53 +00:00

1028 lines
40 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\Providers\AppServiceProvider;
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';
/**
* Prozess-Memo der Baum-Traversierung: ein Dashboard-Request braucht die
* Downline mehrfach (KPIs, Payline, Top 3, Prognose) — ohne Memo liefe die
* BFS pro Aufruf neu. Der Service ist als Singleton registriert
* ({@see AppServiceProvider}); pro Root-User wird einmal
* traversiert, Batch-Läufe über viele User bleiben durch den Key getrennt.
*
* @var array<int, array<int, Collection<int, User>>>
*/
private array $lineBucketsMemo = [];
/**
* @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');
}
/**
* Baum-Memo verwerfen — nötig, wenn sich die Team-Struktur innerhalb eines
* langlebigen Prozesses ändert (Batch-Kommandos, Tests).
*/
public function flushLineBucketsMemo(?int $rootUserId = null): void
{
if ($rootUserId === null) {
$this->lineBucketsMemo = [];
return;
}
unset($this->lineBucketsMemo[$rootUserId]);
}
public function storeSnapshot(User $user, int $month, int $year): BackofficeStatisticsSnapshot
{
// Frisch traversieren: der Snapshot-Cron läuft lange und über viele User —
// ein Memo aus früheren Aufrufen desselben Roots wäre potenziell veraltet.
$this->flushLineBucketsMemo((int) $user->id);
$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),
'recruiter_ranking' => $this->recruiterRanking($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
{
if (isset($this->lineBucketsMemo[$rootUserId])) {
return $this->lineBucketsMemo[$rootUserId];
}
$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 $this->lineBucketsMemo[$rootUserId] = $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
{
return $users
->filter(fn (User $user) => $user->isNewPartnerIn($month, $year))
->count();
}
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;
}
return User::query()
->whereIn('id', $userIds)
->newPartnerIn($month, $year)
->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);
}
/**
* "Recruiter des Monats": Wer im Team (inklusive des Statistik-Nutzers
* selbst, Linie 0) hat im gewählten Monat wie viele Neupartner gesponsert?
*
* Neupartner-Definition identisch zur Neupartner-KPI {@see newPartnersCount()}:
* m_level gesetzt, aktiver Account (payment_account in der Zukunft),
* active_date im Monat — Zuordnung über users.m_sponsor.
*
* "Neues Volumen" je Sponsor = Summe der Monats-Gesamtpunkte (eigene +
* Kundenpunkte, {@see SalesVolumePoints::totalPoints()} — das Starterpaket
* mit Status 1 zählt damit mit) aller seiner Neupartner, plus rekursiv die
* Gesamtpunkte der Neupartner, die diese Neupartner im selben Monat selbst
* geworben haben (Kettenlogik). Die Rekursion läuft rein im Speicher über
* die bereits geladene Neupartner-Menge und ist zyklen-sicher.
*
* Sortiert nach Anzahl absteigend (Volumen als Tiebreaker); nur Sponsoren
* mit mindestens einem Neupartner.
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, new_partner_count: int, volume_points: float, partners: array<int, array{user_id: int, name: string, career_level: string, total_points: float, new_partner_count: int, children: array<int, array{user_id: int, name: string, career_level: string, total_points: float}>}>}>
*/
private function recruiterRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
{
$usersByLine = $this->usersWithLine($lineBuckets, $rootUser);
if ($usersByLine === []) {
return [];
}
$newPartnersBySponsor = [];
foreach ($usersByLine as $entry) {
if ($entry['line'] === 0) {
continue;
}
$user = $entry['user'];
if (! $user->isNewPartnerIn($month, $year)) {
continue;
}
$newPartnersBySponsor[(int) $user->m_sponsor][] = $user;
}
if ($newPartnersBySponsor === []) {
return [];
}
$newPartnerIds = collect($newPartnersBySponsor)
->flatMap(fn (array $partners) => collect($partners)->map(fn (User $partner) => (int) $partner->id))
->values()
->all();
$pointsByUser = $this->totalPointsByUser($newPartnerIds, $month, $year);
$chainVolume = function (int $sponsorId, array $visited) use (&$chainVolume, $newPartnersBySponsor, $pointsByUser): float {
$volume = 0.0;
foreach ($newPartnersBySponsor[$sponsorId] ?? [] as $partner) {
$partnerId = (int) $partner->id;
if (isset($visited[$partnerId])) {
continue;
}
$visited[$partnerId] = true;
$volume += (float) $pointsByUser->get($partnerId, 0.0) + $chainVolume($partnerId, $visited);
}
return $volume;
};
$rows = [];
foreach ($usersByLine as $sponsorId => $entry) {
$directPartners = $newPartnersBySponsor[$sponsorId] ?? [];
if ($directPartners === []) {
continue;
}
$partners = [];
foreach ($directPartners as $partner) {
$partnerId = (int) $partner->id;
$children = [];
foreach ($newPartnersBySponsor[$partnerId] ?? [] as $child) {
if ((int) $child->id === (int) $sponsorId) {
continue;
}
$children[] = [
'user_id' => (int) $child->id,
'name' => $this->userName($child),
'career_level' => $this->careerLevel($child),
'total_points' => (float) $pointsByUser->get((int) $child->id, 0.0),
];
}
$partners[] = [
'user_id' => $partnerId,
'name' => $this->userName($partner),
'career_level' => $this->careerLevel($partner),
'total_points' => (float) $pointsByUser->get($partnerId, 0.0),
'new_partner_count' => count($children),
'children' => $children,
];
}
$rows[] = [
'user_id' => (int) $sponsorId,
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'is_self' => $entry['line'] === 0,
'new_partner_count' => count($directPartners),
'volume_points' => $chainVolume((int) $sponsorId, [(int) $sponsorId => true]),
'partners' => $partners,
];
}
return collect($rows)
->sortBy([['new_partner_count', 'desc'], ['volume_points', 'desc']])
->values()
->all();
}
/**
* Monats-Gesamtpunkte (eigene + Kundenpunkte, inkl. Starterpaket Status 1)
* je User — eine gebatchte Query für alle übergebenen IDs.
*
* @param int[] $userIds
* @return Collection<int, float>
*/
private function totalPointsByUser(array $userIds, int $month, int $year): Collection
{
return SalesVolumePoints::totalPointsByUser($userIds, $month, $year);
}
/**
* 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();
}
}