Statistiken: Kundenfeedback 2026-08-19 komplett (N1-N8) + Review-Fixes + VP-/Incentive-Repair gehärtet

Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen):
- N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch
- N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell)
- N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt'
- N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände)
- N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung)
- N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter)
- N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix
- N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav)

Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types

Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-08-21 10:44:44 +00:00
parent 841d87e161
commit 867dc21b57
87 changed files with 4480 additions and 240 deletions

View file

@ -6,6 +6,7 @@ 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;
@ -17,6 +18,17 @@ class BackofficeDashboardService
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>
*/
@ -61,8 +73,27 @@ class BackofficeDashboardService
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(
@ -109,6 +140,7 @@ class BackofficeDashboardService
'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),
];
}
@ -215,6 +247,10 @@ class BackofficeDashboardService
*/
public function lineBuckets(int $rootUserId): array
{
if (isset($this->lineBucketsMemo[$rootUserId])) {
return $this->lineBucketsMemo[$rootUserId];
}
$lineBuckets = [];
$currentSponsorIds = [$rootUserId];
$visitedUserIds = [$rootUserId];
@ -237,7 +273,7 @@ class BackofficeDashboardService
$visitedUserIds = array_merge($visitedUserIds, $currentSponsorIds);
}
return $lineBuckets;
return $this->lineBucketsMemo[$rootUserId] = $lineBuckets;
}
/**
@ -497,15 +533,9 @@ class BackofficeDashboardService
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])
->newPartnerIn($month, $year)
->count();
}
@ -796,6 +826,154 @@ class BackofficeDashboardService
->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 [];
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
$newPartnersBySponsor = [];
foreach ($usersByLine as $entry) {
if ($entry['line'] === 0) {
continue;
}
$user = $entry['user'];
if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) {
continue;
}
if (! Carbon::parse($user->active_date)->betweenIncluded($startDate, $endDate)) {
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).
*

View file

@ -151,7 +151,7 @@ class BackofficeDrilldownService
->get()
->keyBy('id');
$customerPoints = $this->externalPointsByUser($partners->pluck('id')->map(fn ($id) => (int) $id)->all(), $month, $year);
$monthlyPoints = $this->monthlyPointsByUser($partners->pluck('id')->map(fn ($id) => (int) $id)->all(), $month, $year);
return $partners
->map(fn (User $user) => [
@ -161,7 +161,8 @@ class BackofficeDrilldownService
'email' => $user->email,
'career_level' => $this->careerLevel($user),
'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-',
'customer_points' => (float) $customerPoints->get((int) $user->id, 0.0),
'customer_points' => (float) ($monthlyPoints->get((int) $user->id)['external_points'] ?? 0.0),
'total_points' => (float) ($monthlyPoints->get((int) $user->id)['total_points'] ?? 0.0),
'active_date' => $this->formatDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
])
@ -170,13 +171,14 @@ class BackofficeDrilldownService
}
/**
* Externe Kundenpunkte (Shop + Abo) je User im gewählten Monat — tagesaktuell
* Externe Kundenpunkte (Shop + Abo) sowie Gesamtpunkte (eigene + Kundenpunkte,
* inkl. Starterpaket Status 1) je User im gewählten Monat — tagesaktuell
* aus user_sales_volumes summiert (gleiche Status-Logik wie die Übersicht).
*
* @param int[] $userIds
* @return Collection<int, float>
* @return Collection<int, array{external_points: float, total_points: float}>
*/
private function externalPointsByUser(array $userIds, int $month, int $year): Collection
private function monthlyPointsByUser(array $userIds, int $month, int $year): Collection
{
if ($userIds === []) {
return collect();
@ -188,9 +190,14 @@ class BackofficeDrilldownService
->where('year', $year)
->select('user_id')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
->groupBy('user_id')
->pluck('external_points', 'user_id')
->map(fn ($points) => (float) $points);
->get()
->keyBy('user_id')
->map(fn ($row) => [
'external_points' => (float) $row->external_points,
'total_points' => (float) $row->total_points,
]);
}
/**

View file

@ -2,7 +2,9 @@
namespace App\Services\Backoffice;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use Illuminate\Support\Collection;
/**
* Liefert SQL-Aggregat-Ausdrücke für die monatliche Punkte-/Umsatzauswertung
@ -61,6 +63,31 @@ class SalesVolumePoints
return self::ownPoints().' + '.self::externalPoints();
}
/**
* Monats-Gesamtpunkte je User als gebatchte Abfrage — die eine Stelle für
* "Gesamtpunkte pro Berater" (Firstlines-Seite, Recruiter-Ranking,
* Top-3-Karte), damit die Status-Semantik nicht in mehreren Kopien driftet.
*
* @param int[] $userIds
* @return Collection<int, float> user_id => Punkte
*/
public static function totalPointsByUser(array $userIds, int $month, int $year): Collection
{
if ($userIds === []) {
return collect();
}
return UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->groupBy('user_id')
->select('user_id')
->selectRaw('('.self::totalPoints().') as total_points')
->pluck('total_points', 'user_id')
->map(fn ($points) => (float) $points);
}
/**
* Punkte aus Abo-Bestellungen (eigene wie Kundenabos) — alle zählbaren Status.
* Benötigt den Join auf shopping_orders.