mivita/app/Services/Backoffice/BackofficeDashboardService.php
Kevin 8d7a98c685 Statistik: abgeschlossene Monate werden vollstaendig eingefroren
Am ersten des Folgemonats wird der Monat abgerechnet, die Provision berechnet
und Gutschriften werden ausgestellt. Deswegen fliessen auch Stornos nicht mehr
rueckwirkend ein. Genau dieser Stand muss eingefroren werden — und zwar
einschliesslich Teamstruktur und Detaillisten, nicht nur der Kennzahlen.

Bisher hat `overlayCurrentAboStock()` beim Lesen eines gespeicherten Monats die
gesamte Kundenabo-Topliste live neu gerechnet, also auch abgerechnete
Monatswerte. Und die Detaillisten hinter den Kacheln lasen Monatsbuchungen und
Teamzuordnung grundsaetzlich neu — nach einem Sponsorwechsel oder einer
Loeschung konnte derselbe historische Klick andere Ergebnisse liefern als die
Kachel darueber.

Das Overlay entfaellt; der Snapshot wird unveraendert zurueckgegeben. Die
Detaillisten lesen die eingefrorene Mitgliedermenge aus dem Snapshot
(`user_ids` lag dort bereits, wurde nur nie benutzt) und die eingefrorenen
Abo-Ids aus dem neuen Feld `abo_ids`. Payload-Version 2.

Grenze, die bleibt: Ein Rebuild vergangener Monate friert die Struktur des
AUSFUEHRUNGSTAGS ein, nicht die des damaligen Monatsendes — frueheres
Sponsoring wird nirgends historisiert. Der `--force`-Lauf ist deshalb nur
richtig, wenn man bewusst einen einheitlichen Stand herstellt. Das ist hier der
Fall (Schemawechsel des Payloads), fuer den laufenden Betrieb genuegt der
naechtliche Lauf ohne `--force`.

Loest die August-Entscheidung F7 ("Abo-Bestaende tagesaktuell") ab. Zwei Tests
hielten die alte Regel fest und beschreiben jetzt die neue.

Mit drin, weil dieselben Dateien betroffen sind:
- Negative Monatssalden verschwanden aus dem Drilldown (`HAVING > 0`), obwohl
  ihre Betraege in der Uebersicht steckten. Nur die 1000-Punkte-Liste behaelt
  ihren Schwellenfilter (A03).
- Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet statt
  voll abgezogen (A06).
- Unformatierte Sortierwerte fuer die Detailliste (A14, siehe Folgecommit).

Audit-Befunde A03, A04, A06, A12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:19:06 +00:00

1146 lines
46 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\ShoppingOrderItem;
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;
/**
* Monatsuebersicht der Backoffice-Statistik.
*
* ## Warum abgeschlossene Monate eingefroren sind (Kundenentscheidung 2026-09-10)
*
* Am ersten Tag des Folgemonats laeuft die Monatsabrechnung: Provisionen werden
* berechnet, Gutschriften ausgestellt, der Monat ist damit kaufmaennisch
* abgeschlossen. Aus demselben Grund fliessen auch keine Stornos mehr rueckwirkend
* in einen abgeschlossenen Monat ({@see SalesPointsVolume::isStornoMonthOpen()}).
*
* Die Statistik muss dieser Logik folgen, denn die Team-Struktur ist beweglich:
* Teampartner kommen dazu, wechseln den Sponsor oder fallen weg. Wuerde ein
* vergangener Monat weiter aus heutigen Daten gerechnet, saehe eine Beraterin dort
* spaeter andere Zahlen als in der Abrechnung, die sie fuer diesen Monat erhalten
* hat — und die Ursache waere von aussen nicht erkennbar.
*
* Eingefroren wird deshalb der **gesamte** Monatsstand, nicht nur die Summen:
* Punkte, Umsaetze, Abo-Bestaende, die Mitglieder je Linie (`user_ids`) und die
* Abos je Linie (`abo_ids`). Aus diesen beiden ID-Listen baut
* {@see BackofficeDrilldownService} die Detaillisten, sodass jede verlinkte Liste
* zu der Zahl passt, aus der sie aufgerufen wurde.
*
* Bewusst NICHT eingefroren werden Stammdaten wie Name oder Karrierestufe: sie
* werden zur Anzeigezeit gelesen. Eine Umbenennung soll rueckwirkend sichtbar sein,
* eine Strukturaenderung nicht.
*/
class BackofficeDashboardService
{
/**
* Payload-Schema der gespeicherten Monats-Snapshots.
*
* 2 — Linienzeilen tragen zusaetzlich `abo_ids`, damit die Detaillisten eines
* abgeschlossenen Monats aus demselben eingefrorenen Stand kommen wie die
* Zahl darueber.
*/
public const PAYLOAD_VERSION = 2;
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) {
// Abgeschlossener Monat: der gespeicherte Stand wird unveraendert
// ausgeliefert — siehe Klassenkommentar. Frueher wurden hier die
// Abo-Bestaende live nachgerechnet; das hat den eingefrorenen Monat
// mit heutigen Zahlen vermischt.
return $this->withMeta(
$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);
$recruiter = $this->recruiterRanking($user, $lineBuckets, $month, $year);
$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,
// Schema-Version des Payloads: ab 2 enthalten die Linienzeilen `abo_ids`,
// aus denen die Detaillisten eines abgeschlossenen Monats gebaut werden.
// Aeltere Snapshots bleiben lesbar, ihre Detaillisten fallen auf die
// Monatsend-Rueckrechnung zurueck ({@see BackofficeDrilldownService}).
'payload_version' => self::PAYLOAD_VERSION,
'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' => $recruiter['rows'],
// Eigener Payload-Schlüssel statt geänderter Listenform: ältere
// Snapshots kennen nur recruiter_ranking und bleiben lesbar.
'recruiter_totals' => $recruiter['totals'],
];
}
/**
* @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);
// Nicht nur zaehlen, sondern die Abo-IDs mitschreiben: der Snapshot ist der
// eingefrorene Monatsstand, und die Detailliste hinter der Zahl muss genau
// diese Abos zeigen — auch wenn eines davon spaeter gekuendigt oder geloescht
// wird. Ohne die IDs liesse sich die Liste nur aus dem heutigen Bestand
// rekonstruieren und wuerde von ihrer eigenen Kachel abweichen.
$partnerAboIds = $this->aboIds($userIds, 'user_id', 'me');
$customerAboIds = $this->aboIds($userIds, 'member_id', 'ot');
$partnerAboIdsNew = $this->aboIds($userIds, 'user_id', 'me', $month, $year);
$customerAboIdsNew = $this->aboIds($userIds, 'member_id', 'ot', $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' => count($partnerAboIds),
'team_partner_abos_new' => count($partnerAboIdsNew),
'team_customer_abos' => count($customerAboIds),
'team_customer_abos_new' => count($customerAboIdsNew),
'abo_ids' => [
'team_partner_abos' => $partnerAboIds,
'team_partner_abos_new' => $partnerAboIdsNew,
'team_customer_abos' => $customerAboIds,
'team_customer_abos_new' => $customerAboIdsNew,
],
'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]);
}
/**
* IDs der aktiven Abos einer Mitgliedermenge. Mit Monat/Jahr nur die in diesem
* Monat gestarteten ("+N neu").
*
* @param int[] $userIds
* @return int[]
*/
private function aboIds(array $userIds, string $userColumn, string $isFor, ?int $month = null, ?int $year = null): array
{
if ($userIds === []) {
return [];
}
$query = $this->activeAboQuery()
->whereIn($userColumn, $userIds)
->where('is_for', $isFor);
if ($month !== null && $year !== null) {
$query->whereBetween('start_date', [
Carbon::create($year, $month, 1)->startOfMonth(),
Carbon::create($year, $month, 1)->endOfMonth(),
]);
}
return $query->pluck('id')->map(fn ($id) => (int) $id)->all();
}
/**
* @param int[] $userIds
*/
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
{
return count($this->aboIds($userIds, $userColumn, $isFor, $month, $year));
}
/**
* @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);
$addonPoints = $this->customerAboAddonPoints($memberIds, $month, $year);
$rows = collect($usersWithLine)
->map(function (array $entry, int $userId) use ($aboCounts, $targetPoints, $actualPoints, $addonPoints) {
$actual = (float) $actualPoints->get($userId, 0.0);
// Einmal-Zusätze aus der Position ableiten, den wiederkehrenden
// Anteil als Rest: so ergeben die beiden Spalten IMMER die
// angezeigte Gesamtsumme, auch wenn Positions- und
// Zeilensummen in Altdaten einmal auseinanderfallen.
$addon = min(max((float) $addonPoints->get($userId, 0.0), 0.0), $actual);
return [
'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' => $actual,
'points_addon' => round($addon, 2),
'points_recurring' => round($actual - $addon, 2),
];
});
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);
}
/**
* Von den abgerechneten Kundenabo-Punkten der Anteil, der aus EINMALIG zum
* Abo dazubestellten Positionen stammt (Kundenwunsch 2026-09-07:
* "1231 abgerechnet – davon 1000 reine Abo-Punkte – 231 einmalige").
*
* Grundlage ist `shopping_order_items.is_abo_addon`. Die Positionspunkte sind
* Stückpunkte zum Bestellzeitpunkt, die Zeilensumme also `points * qty` —
* gegen die Produktivdaten geprüft: die Positionssummen je Bestellung stimmen
* mit `user_sales_volumes.points` überein.
*
* Bewusst zweistufig statt als ein JOIN: eine Bestellung darf pro Berater nur
* EINMAL zählen, ein zusätzlicher Join auf die Positionen würde die Zeilen
* vervielfachen, sobald zu einer Bestellung mehrere Umsatzzeilen existieren.
*
* Einschränkung: `is_abo_addon` gibt es erst seit 06/2026. Für ältere
* Bestellungen ist der Wert false — die Aufteilung zeigt dort alles als
* wiederkehrend.
*
* @param int[] $memberIds
* @return Collection<int, float>
*/
private function customerAboAddonPoints(array $memberIds, int $month, int $year): Collection
{
if ($memberIds === []) {
return collect();
}
// Je Berater und Bestellung sowohl die gebuchte Summe (netto, Stornos also
// gegengerechnet) als auch die urspruenglich positive Summe: aus deren
// Verhaeltnis ergibt sich, wie viel von der Bestellung im Monat stehen
// geblieben ist.
$pairs = UserSalesVolume::query()
->join('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_sales_volumes.user_id', $memberIds)
->where('user_sales_volumes.month', $month)
->where('user_sales_volumes.year', $year)
->where('shopping_orders.is_abo', 1)
->whereRaw(SalesVolumePoints::shopPredicate())
->groupBy('user_sales_volumes.user_id', 'shopping_orders.id')
->selectRaw('user_sales_volumes.user_id, shopping_orders.id as shopping_order_id')
->selectRaw('SUM(user_sales_volumes.points) as net_points')
->selectRaw('SUM(CASE WHEN user_sales_volumes.points > 0 THEN user_sales_volumes.points ELSE 0 END) as gross_points')
->get();
if ($pairs->isEmpty()) {
return collect();
}
$addonByOrder = ShoppingOrderItem::query()
->whereIn('shopping_order_id', $pairs->pluck('shopping_order_id')->unique()->all())
->addonItems()
->groupBy('shopping_order_id')
->selectRaw('shopping_order_id, COALESCE(SUM(points * qty), 0) as points')
->pluck('points', 'shopping_order_id');
$byUser = [];
foreach ($pairs as $pair) {
$addon = (float) $addonByOrder->get((int) $pair->shopping_order_id, 0.0);
if ($addon <= 0) {
continue;
}
// Anteil der Bestellung, der im Monat tatsaechlich stehen geblieben ist.
// Vollstorno ergibt 0, eine unstornierte Bestellung 1. Ein Teilstorno wird
// anteilig verrechnet: welche Position storniert wurde, geht aus der
// Stornobuchung nicht hervor — sie hat keine Positionsaufteilung.
$gross = (float) $pair->gross_points;
$factor = $gross > 0 ? max(0.0, min(1.0, (float) $pair->net_points / $gross)) : 0.0;
$points = $addon * $factor;
if ($points > 0) {
$byUser[(int) $pair->user_id] = ($byUser[(int) $pair->user_id] ?? 0.0) + $points;
}
}
return collect($byUser);
}
/**
* "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.
*
* `totals` (Kundenwunsch 2026-09-07) ist bewusst NICHT die Spaltensumme:
* `volume_points` enthält je Sponsor das Kettenvolumen, ein Neupartner steckt
* also gleichzeitig in der Zeile seines Sponsors und in seiner eigenen. Die
* Summe wird deshalb einmal über die deduplizierte Neupartner-Menge gerechnet
* und ist entsprechend kleiner als die Spalte darüber.
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array{rows: 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}>}>}>, totals: array{new_partner_count: int, volume_points: float}}
*/
private function recruiterRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
{
$emptyTotals = ['new_partner_count' => 0, 'volume_points' => 0.0];
$usersByLine = $this->usersWithLine($lineBuckets, $rootUser);
if ($usersByLine === []) {
return ['rows' => [], 'totals' => $emptyTotals];
}
$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 ['rows' => [], 'totals' => $emptyTotals];
}
$newPartnerIds = collect($newPartnersBySponsor)
->flatMap(fn (array $partners) => collect($partners)->map(fn (User $partner) => (int) $partner->id))
->unique()
->values()
->all();
$pointsByUser = $this->totalPointsByUser($newPartnerIds, $month, $year);
$totals = [
'new_partner_count' => count($newPartnerIds),
'volume_points' => round((float) collect($newPartnerIds)->sum(fn (int $id) => (float) $pointsByUser->get($id, 0.0)), 2),
];
$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 [
'rows' => collect($rows)
->sortBy([['new_partner_count', 'desc'], ['volume_points', 'desc']])
->values()
->all(),
'totals' => $totals,
];
}
/**
* 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);
}
/**
* Karriere-Level in der Sprache des Betrachters — dieselbe Auflösung wie in
* den übrigen Team-Auswertungen ({@see TeamAboOverviewService}, Firstlines,
* Jahresgebühr-Liste), sonst steht in einer Karte der deutsche Name.
*/
private function careerLevel(User $user): string
{
return $user->user_level?->getLang('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();
}
}