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

@ -120,14 +120,17 @@ class IncentiveCalculate extends Command
$participant->rebuildFromSourceTables()->save();
} else {
if (! $skipRepair) {
$stats['tracking_partner_removed'] += $repairService->removeStaleSponsorTrackings($participant);
$stats['tracking_partner_added'] += $repairService->syncMissingTrackingPartners($participant);
$stats['tracking_abo_added'] += $repairService->syncMissingTrackingAbos($participant);
// FK-Reparatur MUSS vor der Stale-Bereinigung laufen: Log-Zeilen mit
// incentive_new_partner_id = NULL wuerden das Loeschen sonst als Waisen
// beim alten Sponsor ueberleben (Doppelzaehlung nach Umhaengen).
$r = $repairService->repairForeignKeys($participant);
$stats['repair_partner_fk'] += $r['partner_fk'];
$stats['repair_abo_fk'] += $r['abo_fk'];
$stats['repair_onetime_partner_fk'] += $r['onetime_partner_fk'];
$stats['repair_onetime_abo_fk'] += $r['onetime_abo_fk'];
$stats['tracking_partner_removed'] += $repairService->removeStaleSponsorTrackings($participant);
$stats['tracking_partner_added'] += $repairService->syncMissingTrackingPartners($participant);
$stats['tracking_abo_added'] += $repairService->syncMissingTrackingAbos($participant);
$stats['sv_logs_added'] += $repairService->syncMissingSalesVolumeLogs($participant);
}
$participant->recalculateFromTrackingTables()->save();

View file

@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use App\Services\SponsorBuyingPointsRepairService;
use Illuminate\Console\Command;
/**
* Nacht-Repair: fehlende Vertriebspartner-Punkte (VP, status=5) des Sponsors
* fuer bezahlte Bestellungen des laufenden Monats nachbuchen.
*
* Hintergrund und Faelle: dev/2026-08-12-intensiv-tickets/README.md
*/
class RepairSponsorBuyingPoints extends Command
{
protected $signature = 'sales:repair-sponsor-vp
{--month= : Monat (Standard: aktueller Monat)}
{--year= : Jahr (Standard: aktuelles Jahr)}
{--dry-run : Nur anzeigen, nichts buchen}
{--force : Buchung in einen anderen als den laufenden Monat erlauben}';
protected $description = 'Fehlende Vertriebspartner-Punkte (VP, status=5) des Sponsors für bezahlte Bestellungen des Monats nachbuchen';
public function handle(SponsorBuyingPointsRepairService $service): int
{
$month = (int) ($this->option('month') ?: now()->month);
$year = (int) ($this->option('year') ?: now()->year);
$dryRun = (bool) $this->option('dry-run');
$isCurrentMonth = $month === (int) now()->month && $year === (int) now()->year;
if (! $dryRun && ! $isCurrentMonth && ! $this->option('force')) {
$this->error('Buchungen in einen anderen als den laufenden Monat nur mit --force (keine rückwirkenden Buchungen in abgeschlossene Provisionsmonate).');
return self::FAILURE;
}
$this->info("VP-Repair für {$month}/{$year}".($dryRun ? ' (DRY-RUN)' : ''));
$result = $service->repair($month, $year, $dryRun);
$this->info(" Geprüfte bezahlte Bestellungen mit VP-Produkt: {$result['checked']}");
$this->info(" Bereits gebucht (übersprungen): {$result['skipped_existing']}");
$this->info(($dryRun ? ' Würde nachbuchen: ' : ' Nachgebucht: ').count($result['created']));
if (! empty($result['rebooked'])) {
$this->info(($dryRun ? ' Würde vom Firmenaccount umbuchen: ' : ' Vom Firmenaccount umgebucht: ').count($result['rebooked']));
$this->table(
['Order', 'Berater', 'Neupartner', 'Produkt', 'Punkte'],
array_map(fn (array $row) => [
$row['order_id'],
$row['sponsor'].' (#'.$row['sponsor_id'].')',
$row['buyer'],
$row['product'],
$row['points'],
], $result['rebooked'])
);
}
if (! empty($result['skipped_other_sponsor'])) {
$this->warn(' Achtung — Buchung liegt bei einem ANDEREN Sponsor (Umhängung?), bitte manuell prüfen:');
$this->table(
['Order', 'Neupartner', 'aktueller Sponsor', 'gebucht bei'],
array_map(fn (array $row) => [
$row['order_id'],
$row['buyer'],
'#'.$row['current_sponsor_id'],
'#'.$row['booked_sponsor_id'],
], $result['skipped_other_sponsor'])
);
}
if (! empty($result['created'])) {
$this->table(
['Order', 'Sponsor', 'Neupartner', 'Produkt', 'Punkte'],
array_map(fn (array $row) => [
$row['order_id'],
$row['sponsor'].' (#'.$row['sponsor_id'].')',
$row['buyer'],
$row['product'],
$row['points'],
], $result['created'])
);
}
$this->info('Fertig.');
return self::SUCCESS;
}
}

View file

@ -70,6 +70,10 @@ class Kernel extends ConsoleKernel
// Backoffice-Statistik-Snapshots: abgeschlossene Monate fuer VIP-Statistiken einfrieren
$schedule->command('backoffice:store-statistics-snapshots')->dailyAt('04:45');
// VP-Punkte des Sponsors: fehlende Registrierungs-Buchungen des laufenden
// Monats nachziehen (Callback-Aussetzer), vor business:store-optimized um 03:00
$schedule->command('sales:repair-sponsor-vp')->dailyAt('02:30');
// Incentive: Punkteberechnung täglich nach business:store-optimized
$schedule->command('incentive:calculate')->dailyAt('05:00');

View file

@ -12,6 +12,7 @@ use App\Services\Abo\TeamAboOverviewService;
use App\Services\AboHelper;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\BusinessPlan\LiveCommissionService;
use App\Services\Team\TopResultsService;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
@ -123,16 +124,30 @@ class HomeController extends Controller
$paylineForecast = $isCurrentMonth
? app(PaylineForecastService::class)->forecastDays($user, $customerPoints + (float) $paylinePoints)
: null;
$levelForecast = $isCurrentMonth
? app(PaylineForecastService::class)->levelAndCommissionForecast($user, $month, $year)
: null;
$firstlineIds = User::where('m_sponsor', $user->id)
->whereColumn('id', '!=', 'm_sponsor')
->whereNull('deleted_at')
$firstlineIds = User::firstlinesOf($user->id)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$dashboardService = app(BackofficeDashboardService::class);
$teamOverview = app(TeamAboOverviewService::class);
$paylineService = app(PaylineForecastService::class);
// Anzeige-Zählung konsistent zu "Teampartner gesamt" (F1: nur aktive
// Berater) — die Payline-Mitgliedermenge selbst enthält auch inaktive,
// deren Abos weiterhin in die Abo-/Punkte-Summen der Payline zählen.
$activeTeamUserIds = $dashboardService->activeTeamUserIds($user->id);
$paylineMemberIds = $paylineService->paylineContext($user, $month, $year)['member_ids'];
$activePaylineMemberCount = count(array_intersect($activeTeamUserIds, $paylineMemberIds));
// Abo-Zähler der Payline monatsbezogen wie die übrigen Abo-Kacheln (N2) —
// sonst widersprechen sich die Kacheln beim Monatswechsel.
$paylineAboCount = $teamOverview->aboStockCount($paylineMemberIds, 'user_id', 'me', $month, $year)
+ $teamOverview->aboStockCount($paylineMemberIds, 'member_id', 'ot', $month, $year);
return array_merge(
[
@ -144,6 +159,14 @@ class HomeController extends Controller
'payline_forecast' => $paylineForecast,
'commission' => app(LiveCommissionService::class)->commission($user, $month, $year),
'direct_new_partners' => $dashboardService->newPartnersCount($firstlineIds, $month, $year),
'firstline_count' => count($firstlineIds),
'team_partner_count' => count($activeTeamUserIds),
'payline_member_count' => $activePaylineMemberCount,
'payline_abo_count' => $paylineAboCount,
'payline_abos_new' => $paylineService->newPaylineAboCount($user, $month, $year),
'level' => app(LiveCommissionService::class)->levelProgress($user, $month, $year),
'level_forecast' => $levelForecast,
'top_results' => app(TopResultsService::class)->monthlyTop3($user, $month, $year),
],
$teamOverview->teamKpis($user, $month, $year),
$teamOverview->personalAboKpis($user, $month, $year)

View file

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Services\Slim;
use Auth;
use Illuminate\Http\RedirectResponse;
use Request;
/**
* Berater-Profilfoto (N6): Upload/Löschen über das Slim-Cropper-Muster
* ({@see UserShopController::uploadImage()}), gespeichert unter
* images/profile/ im public-Disk (user_accounts.profile_image).
*
* Der Upload ist nur mit gesetzter Einwilligungs-Checkbox möglich
* (teaminterne Anzeige in Ranglisten/Übersichten, account.profile_image_consent).
*/
class ProfileImageController extends Controller
{
public function uploadImage(): RedirectResponse
{
$user = Auth::user();
if (! Request::get('profile_image_consent')) {
\Session()->flash('alert-danger', __('account.profile_image_consent_required'));
return redirect(route('user_edit'));
}
try {
$image = Slim::getImages('images')[0];
if (isset($image['output']['data'])) {
$stored = Slim::storeCroppedImage($image, 'images/profile');
if ($stored === null) {
\Session()->flash('alert-danger', 'File is not jpg or png!');
return redirect(route('user_edit'));
}
if ($user->account->profile_image) {
\Storage::disk('public')->delete('images/profile/'.$user->account->profile_image);
}
$user->account->profile_image = $stored['filename'];
$user->account->save();
\Session()->flash('alert-success', __('msg.file_uploaded'));
return redirect(route('user_edit'));
}
\Session()->flash('alert-danger', __('msg.file_empty'));
return redirect(route('user_edit'));
} catch (\Exception $e) {
\Session()->flash('alert-danger', 'Error: '.$e);
return redirect(route('user_edit'));
}
}
public function deleteImage(): RedirectResponse
{
$user = Auth::user();
if ($user->account->profile_image) {
\Storage::disk('public')->delete('images/profile/'.$user->account->profile_image);
$user->account->profile_image = null;
$user->account->save();
\Session()->flash('alert-success', __('msg.file_deleted'));
}
return redirect(route('user_edit'));
}
}

View file

@ -238,7 +238,7 @@ class BackofficeStatisticsController extends Controller
}
if ($metric === 'new_partners') {
return ['Name', 'E-Mail', 'Karriere-Level', 'Sponsor', 'Kundenpunkte', 'Aktiv seit', 'Account gueltig bis'];
return ['Name', 'E-Mail', 'Karriere-Level', 'Sponsor', 'Kundenpunkte', 'Punkte gesamt', 'Aktiv seit', 'Account gueltig bis'];
}
return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status'];
@ -296,6 +296,7 @@ class BackofficeStatisticsController extends Controller
$row['career_level'] ?? '',
$row['sponsor'] ?? '',
$row['customer_points'] ?? 0,
$row['total_points'] ?? 0,
$row['active_date'] ?? '',
$row['payment_account'] ?? '',
];
@ -333,7 +334,7 @@ class BackofficeStatisticsController extends Controller
}
if ($metric === 'new_partners') {
return ['Summe', $summary['count'].' Eintraege', '', '', $summary['customer_points'], '', '', ''];
return ['Summe', $summary['count'].' Eintraege', '', '', $summary['customer_points'], $summary['total_points'], '', ''];
}
return ['Summe', $summary['count'].' Eintraege', '', '', '', ''];

View file

@ -12,6 +12,7 @@ use App\Services\Abo\PaylineForecastService;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\AboHelper;
use App\Services\AboOrderCart;
use App\Services\Backoffice\SalesVolumePoints;
use App\Services\BusinessPlan\ExportBot;
use App\Services\BusinessPlan\TreeCalcBot;
use App\Services\BusinessPlan\TreeCalcBotOptimized;
@ -19,9 +20,11 @@ use App\Services\BusinessPlan\TreeHelperOptimized;
use App\Services\HTMLHelper;
use App\Services\LevelReportService;
use App\Services\NextLevelBadgeHelper;
use App\Services\Team\AnnualFeeDueService;
use App\Services\TranslationHelper;
use App\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use Maatwebsite\Excel\Facades\Excel;
use Request;
@ -692,6 +695,68 @@ class TeamController extends Controller
}
}
/**
* Jahresgebühr-Nachfassliste: Teampartner der eigenen Downline, deren
* Account-Gültigkeit überfällig ist oder in ≤ 1/2/3 Monaten abläuft.
*/
public function annualFee(AnnualFeeDueService $annualFeeDueService): View
{
$groups = $annualFeeDueService->dueGroups((int) \Auth::user()->id);
$data = [
'groups' => $groups,
'totalCount' => array_sum(array_map('count', $groups)),
];
return view('user.team.annual-fee', $data);
}
/**
* Firstlines-Liste (N1, Kundenwunsch 2026-08-19): die direkten Teampartner
* mit Monats-Gesamtpunkten und Kontaktdaten — für alle Berater, verlinkt
* von der Firstlines-Kachel der Monatsstatistik.
*/
public function firstlines(): View
{
$user = User::find(\Auth::user()->id);
$month = max(1, min(12, (int) Request::get('month', now()->month)));
$year = max(2020, min((int) now()->year + 1, (int) Request::get('year', now()->year)));
$firstlines = User::query()
->with(['account', 'user_level'])
->firstlinesOf((int) $user->id)
->get();
$pointsByUser = SalesVolumePoints::totalPointsByUser(
$firstlines->pluck('id')->map(fn ($id) => (int) $id)->all(),
$month,
$year
);
$rows = $firstlines
->map(fn (User $member) => [
'user_id' => (int) $member->id,
'name' => trim((string) $member->getFullName(false)) ?: $member->email,
'email' => $member->email,
'phone' => trim(($member->account?->phone ?? '') !== '' ? (string) $member->account->phone : (string) ($member->account?->mobil ?? '')),
'career_level' => $member->user_level?->getLang('name') ?? '',
'total_points' => (float) $pointsByUser->get($member->id, 0),
'active_since' => $member->active_date,
'is_active_account' => $member->isActiveAccount(),
])
->sortByDesc('total_points')
->values()
->all();
return view('user.team.firstlines', [
'rows' => $rows,
'selectedMonth' => $month,
'selectedYear' => $year,
'filterMonths' => HTMLHelper::getTransMonths(),
'filterYears' => HTMLHelper::getYearRange(2022),
]);
}
/**
* Link für neue Mitglieder
*/

View file

@ -153,32 +153,19 @@ class UserShopController extends Controller
$image = Slim::getImages('images')[0];
if (isset($image['output']['data'])) {
// Base64 of the image
$data = $image['output']['data'];
$file_ex = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$stored = Slim::storeCroppedImage($image, 'images/shop');
if (! isset($file_ex[$image['output']['type']])) {
if ($stored === null) {
\Session()->flash('alert-danger', 'File is not jpg or png!');
return redirect(route('user_shop'));
}
$ext = $file_ex[$image['output']['type']];
// Original file name
$name = $image['output']['name'];
$name = Slim::sanitizeFileName($name);
$name = uniqid().'_'.$name;
$data = \Storage::disk('public')->put(
'images/shop/'.$name,
$data
);
$user->shop->filename = $name;
$user->shop->originalname = $image['output']['name'];
$user->shop->ext = $ext;
$user->shop->mine = $image['output']['type'];
$user->shop->size = $image['input']['size'];
$user->shop->filename = $stored['filename'];
$user->shop->originalname = $stored['originalname'];
$user->shop->ext = $stored['ext'];
$user->shop->mine = $stored['mime'];
$user->shop->size = $stored['size'];
$user->shop->save();
\Session()->flash('alert-success', __('msg.file_uploaded'));
@ -238,35 +225,21 @@ class UserShopController extends Controller
$image = Slim::getImages('images')[0];
if (isset($image['output']['data'])) {
$stored = Slim::storeCroppedImage($image, 'images/user_shop/'.$user->shop->id);
// Base64 of the image
$data = $image['output']['data'];
$file_ex = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (! isset($file_ex[$image['output']['type']])) {
if ($stored === null) {
\Session()->flash('alert-danger', 'File is not jpg or png!');
return redirect(route('user_shop'));
}
$ext = $file_ex[$image['output']['type']];
// Original file name
$name = $image['output']['name'];
$name = Slim::sanitizeFileName($name);
$name = uniqid().'_'.$name;
$data = \Storage::disk('public')->put(
'images/user_shop/'.$user->shop->id.'/'.$name,
$data
);
UserShopOnSite::create([
'user_shop_id' => $user->shop->id,
'filename' => $name,
'original_name' => $image['output']['name'],
'ext' => $ext,
'mine' => $image['output']['type'],
'size' => $image['input']['size'],
'filename' => $stored['filename'],
'original_name' => $stored['originalname'],
'ext' => $stored['ext'],
'mine' => $stored['mime'],
'size' => $stored['size'],
]);
\Session()->flash('alert-success', __('msg.file_uploaded'));

View file

@ -114,7 +114,6 @@ class CheckoutController extends Controller
'is_abo' => $is_abo,
'abo_interval' => $abo_interval,
'recovery_user_abo_id' => $recovery_user_abo_id,
'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(),
'shopping_data' => $shopping_data,
'user_shop' => Util::getUserShop(),
'shopping_user' => $shopping_user,
@ -274,8 +273,7 @@ class CheckoutController extends Controller
];
if (Request::get('is_from') === 'shopping') {
$rules['customer_order_source'] = 'required|in:'.implode(',', array_keys(ShoppingOrder::customerOrderSourceOptions()));
$rules['customer_order_source_comment'] = 'nullable|string|max:500';
$rules['customer_order_source_comment'] = 'required|string|max:500';
}
if (Request::get('same_as_billing')) {
@ -289,7 +287,9 @@ class CheckoutController extends Controller
]);
}
return Validator::make(Request::all(), $rules);
return Validator::make(Request::all(), $rules, [
'customer_order_source_comment.required' => __('order.source_comment_required'),
]);
}
/**

View file

@ -137,6 +137,16 @@ class Incentive extends Model
->where('calculation_end', '>=', $date);
}
/**
* Incentives, deren Qualifikationsfenster das Datum enthaelt (Datumsvergleich auf Tagesebene,
* damit auch Zeitstempel am letzten Fenstertag zaehlen).
*/
public function scopeQualificationWindowContains($query, Carbon $date)
{
return $query->whereDate('qualification_start', '<=', $date)
->whereDate('qualification_end', '>=', $date);
}
// Helpers
public function isActive(): bool
@ -218,6 +228,30 @@ class Incentive extends Model
return (string) ($this->{$key} ?? '');
}
/**
* Qualifikationsfenster als Zeitstempel-Paar: [Start 00:00:00, Ende 23:59:59].
* Zentrale Fensterberechnung, damit Bestellungen am letzten Fenstertag ueberall zaehlen.
*
* @return array{0: Carbon, 1: Carbon}
*/
public function qualificationWindow(): array
{
return [
$this->qualification_start->copy()->startOfDay(),
$this->qualification_end->copy()->endOfDay(),
];
}
/**
* Liegt der Zeitstempel im Qualifikationsfenster (inkl. des kompletten letzten Tages)?
*/
public function qualificationWindowContains(Carbon $date): bool
{
[$windowStart, $windowEnd] = $this->qualificationWindow();
return $date->between($windowStart, $windowEnd);
}
/**
* @return array<int, array{month: int, year: int}>
*/

View file

@ -267,8 +267,7 @@ class IncentiveParticipant extends Model
// A. Neupartner: direkt gesponserte User mit im Qualifikationszeitraum bezahltem Starterpaket
// (Bestelldatum zaehlt, wie Live-Tracking - nicht das User-Registrierungsdatum)
$partnerWindowStart = $incentive->qualification_start->copy()->startOfDay();
$partnerWindowEnd = $incentive->qualification_end->copy()->endOfDay();
[$partnerWindowStart, $partnerWindowEnd] = $incentive->qualificationWindow();
$new_partners = User::where('m_sponsor', $this->user_id)
->whereHas('shopping_orders', function ($q) use ($partnerWindowStart, $partnerWindowEnd) {
@ -277,13 +276,18 @@ class IncentiveParticipant extends Model
})
->get();
foreach ($new_partners as $partner) {
$starterOrderDate = $partner->shopping_orders()
$firstStarterOrderDates = $new_partners->isNotEmpty()
? ShoppingOrder::query()
->whereIn('auth_user_id', $new_partners->pluck('id'))
->wherePaidRegistrationIncludesStarterKit()
->whereBetween('created_at', [$partnerWindowStart, $partnerWindowEnd])
->orderBy('id')
->value('created_at');
$registeredAt = $starterOrderDate ? Carbon::parse($starterOrderDate) : $partner->created_at;
->groupBy('auth_user_id')
->selectRaw('auth_user_id, MIN(created_at) as first_order_at')
->pluck('first_order_at', 'auth_user_id')
: collect();
foreach ($new_partners as $partner) {
$registeredAt = Carbon::parse($firstStarterOrderDates->get($partner->id));
$newPartner = IncentiveNewPartner::create([
'participant_id' => $this->id,
@ -306,8 +310,7 @@ class IncentiveParticipant extends Model
}
// B. Kundenabos (ot) + Berater-Eigenabos (me): status=2
$qualStart = $incentive->qualification_start->copy()->startOfDay();
$qualEnd = $incentive->qualification_end->copy()->endOfDay();
[$qualStart, $qualEnd] = $incentive->qualificationWindow();
// Kundenabos: Berater steht in member_id (nicht user_id)
$customerAbosInPeriod = UserAbo::where('member_id', $this->user_id)

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@ -9,14 +10,14 @@ use Illuminate\Database\Eloquent\SoftDeletes;
/**
* App\Models\UserAccount
*
* @property-read \App\Models\Country $company_country
* @property-read \App\Models\Country $company_pre_phone
* @property-read \App\Models\Country $country
* @property-read Country $company_country
* @property-read Country $company_pre_phone
* @property-read Country $country
* @property mixed $birthday
* @property-read mixed $company
* @property-read \App\Models\Country $pre_mobil
* @property-read \App\Models\Country $pre_phone
* @property-read \App\User $user
* @property-read Country $pre_mobil
* @property-read Country $pre_phone
* @property-read User $user
*
* @method static bool|null forceDelete()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\UserAccount newModelQuery()
@ -62,8 +63,8 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read \App\Models\Country $shipping_country
* @property-read \App\Models\Country|null $shipping_pre_phone
* @property-read Country $shipping_country
* @property-read Country|null $shipping_pre_phone
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\UserAccount whereAddress($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\UserAccount whereAddress2($value)
@ -194,6 +195,7 @@ class UserAccount extends Model
'bank_bic',
'notice',
'language',
'profile_image',
];
// 'reverse_charge', 'reverse_charge_valid'
@ -367,6 +369,25 @@ class UserAccount extends Model
return $this->language;
}
/**
* URL zum Berater-Profilfoto (N6) oder null, wenn keins hochgeladen wurde
* bzw. die Datei nicht (mehr) existiert — Muster wie {@see UserShop::getImage()}.
*/
public function getProfileImageUrl(): ?string
{
$filename = $this->attributes['profile_image'] ?? null;
if (empty($filename)) {
return null;
}
$link = 'images/profile/'.$filename;
if (! \Storage::disk('public')->has($link)) {
return null;
}
return '/storage/'.$link.'?=lm='.\Storage::disk('public')->lastModified($link);
}
/**
* Liefert die verfügbaren Sprachen aus der Config als Array für Select-Felder.
*

View file

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Util;
@ -70,6 +71,10 @@ class AppServiceProvider extends ServiceProvider
return new StripeClientWrapper(config('stripe.secret'));
});
// Dashboard-Services teilen die Baum-Traversierung (lineBuckets-Memo):
// als Singleton läuft die Downline-BFS pro Request/Prozess nur einmal je Root.
$this->app->singleton(BackofficeDashboardService::class);
if ($this->app->environment() !== 'production' && class_exists(IdeHelperServiceProvider::class)) {
$this->app->register(IdeHelperServiceProvider::class);
}

View file

@ -110,7 +110,6 @@ class CheckoutRepository extends BaseRepository
];
if ($shopping_user->is_from === 'shopping') {
$data['customer_order_source'] = $requestData['customer_order_source'] ?? null;
$data['customer_order_source_comment'] = $requestData['customer_order_source_comment'] ?? null;
}
}

View file

@ -88,6 +88,31 @@ class PaylineForecastService
];
}
/**
* Neue Abos der Payline-Mitglieder im Monat (Berater- + Kundenabos,
* start_date im Monat) — für die "Neue Abos in der Payline"-Kachel (N1).
*/
public function newPaylineAboCount(User $user, int $month, int $year): int
{
$memberIds = $this->paylineContext($user, $month, $year)['member_ids'];
if ($memberIds === []) {
return 0;
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
return UserAbo::query()
->activeAbo()
->whereBetween('start_date', [$startDate, $endDate])
->where(function (Builder $query) use ($memberIds) {
$query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $memberIds))
->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $memberIds));
})
->count();
}
/**
* Anzahl aktiver Abos der Payline-Mitglieder (gleiche Filter wie Soll).
*
@ -100,8 +125,7 @@ class PaylineForecastService
}
return UserAbo::query()
->where('active', true)
->whereNotIn('status', [4, 5, 6])
->activeAbo()
->where('is_for', $isFor)
->whereIn($userColumn, $memberIds)
->count();
@ -155,6 +179,202 @@ class PaylineForecastService
];
}
/**
* N4: Level- und Provisions-Prognose zum letzten Liefertag des laufenden
* Monats (Kundenentscheid F5: gekennzeichnete Schätzung).
*
* - Prognose-Basis: heutige Punkte je Payline-Ebene (TP-Laufstände) plus die
* noch offenen Abo-Ausführungen, ebenengenau zugeordnet; eigene Abos und
* eigene Kundenabos zählen auf das persönliche Volumen.
* - Level-Prognose: erreicht die prognostizierte Payline-Punktzahl (inkl.
* KP-Überschuss, Logik wie {@see BusinessUserItemOptimized::setNextUserLevel()})
* das nächste Level — und falls nicht, wie viel fehlt laut Prognose?
* - Provisions-Schätzung: Ebenen-Punkte × Provisionssätze des laut Prognose
* erreichten Levels; Shop-/Wachstumsanteile werden mit dem heutigen
* Live-Wert fortgeschrieben. Rangverschiebungen im Team bleiben außen
* vor — deshalb im UI als unverbindlich kennzeichnen.
*
* @return array{next_name: string|null, reached: bool, missing_pp: float, missing_personal: float, personal_required: float, personal_forecast: float, estimated_commission: float, estimated_level_name: string, is_live: bool}|null
*/
public function levelAndCommissionForecast(User $user, int $month, int $year): ?array
{
$cacheKey = "level_forecast_{$user->id}_{$month}_{$year}";
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$forecast = $this->buildLevelAndCommissionForecast($user, $month, $year);
if ($forecast !== null) {
Cache::put($cacheKey, $forecast, now()->addHours(self::CACHE_HOURS));
}
return $forecast;
}
/**
* @return array{next_name: string|null, reached: bool, missing_pp: float, missing_personal: float, personal_required: float, personal_forecast: float, estimated_commission: float, estimated_level_name: string, is_live: bool}|null
*/
private function buildLevelAndCommissionForecast(User $user, int $month, int $year): ?array
{
$snapshot = $this->commissionService->snapshot($user, $month, $year);
$qualLevel = $snapshot['qual_level'];
if (! is_array($qualLevel) || ! empty($snapshot['failed'])) {
return null;
}
$today = Carbon::today();
$lineByUserId = $this->lineByUserId($user);
$upcoming = $this->upcomingPointsByLine($user, $lineByUserId, $today, $month, $year);
$linePointsForecast = $this->currentLinePoints($lineByUserId, $month, $year);
foreach ($upcoming['by_line'] as $line => $points) {
$linePointsForecast[$line] = ($linePointsForecast[$line] ?? 0.0) + $points;
}
$personalForecast = (float) $snapshot['personal_points'] + $upcoming['personal'];
$nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null;
$reached = false;
$missingPp = 0.0;
$missingPersonal = 0.0;
if ($nextLevel !== null) {
$restKp = max(0.0, $personalForecast - (float) ($nextLevel['qual_kp'] ?? 0));
$paylineForecast = $this->sumLinePoints($linePointsForecast, (int) ($nextLevel['paylines'] ?? 0)) + $restKp;
$missingPp = max(0.0, (float) ($nextLevel['qual_pp'] ?? 0) - $paylineForecast);
$missingPersonal = max(0.0, (float) ($nextLevel['qual_kp'] ?? 0) - $personalForecast);
$reached = $missingPp <= 0.0 && $missingPersonal <= 0.0;
}
$estimationLevel = ($reached && $nextLevel !== null) ? $nextLevel : $qualLevel;
$commissionPp = 0.0;
for ($line = 1; $line <= (int) ($estimationLevel['paylines'] ?? 0); $line++) {
$rate = (float) ($estimationLevel['pr_line_'.$line] ?? 0);
$commissionPp += ($linePointsForecast[$line] ?? 0.0) * $rate / 100;
}
return [
'next_name' => $nextLevel !== null ? (string) ($nextLevel['name'] ?? '') : null,
'reached' => $reached,
'missing_pp' => round($missingPp, 2),
'missing_personal' => round($missingPersonal, 2),
'personal_required' => (float) ($nextLevel['qual_kp'] ?? 0),
'personal_forecast' => round($personalForecast, 2),
'estimated_commission' => round($commissionPp + (float) $snapshot['shop'] + (float) $snapshot['growth'], 2),
'estimated_level_name' => (string) ($estimationLevel['name'] ?? ''),
'is_live' => (bool) $snapshot['is_live'],
];
}
/**
* Ebenen-Zuordnung der gesamten Downline (Linie je User-ID).
*
* @return array<int, int>
*/
private function lineByUserId(User $user): array
{
$map = [];
foreach ($this->dashboardService->lineBuckets($user->id) as $line => $users) {
foreach ($users as $member) {
$map[(int) $member->id] = (int) $line;
}
}
return $map;
}
/**
* Offene Abo-Ausführungen des Monats, aufgeteilt in persönliches Volumen
* (eigene Abos + eigene Kundenabos) und Team-Punkte je Ebene.
*
* @param array<int, int> $lineByUserId
* @return array{personal: float, by_line: array<int, float>}
*/
private function upcomingPointsByLine(User $user, array $lineByUserId, Carbon $today, int $month, int $year): array
{
if ((int) $today->month !== $month || (int) $today->year !== $year) {
return ['personal' => 0.0, 'by_line' => []];
}
$memberIds = array_keys($lineByUserId);
$personal = 0.0;
$byLine = [];
$abos = $this->upcomingAboQuery($user, $memberIds, $today)
->with('user_abo_items.product')
->get();
foreach ($abos as $abo) {
$ownerId = $abo->is_for === 'me' ? (int) $abo->user_id : (int) $abo->member_id;
$points = (float) $abo->getTotalPoints();
if ($ownerId === (int) $user->id) {
$personal += $points;
} elseif (isset($lineByUserId[$ownerId])) {
$byLine[$lineByUserId[$ownerId]] = ($byLine[$lineByUserId[$ownerId]] ?? 0.0) + $points;
}
}
return ['personal' => $personal, 'by_line' => $byLine];
}
/**
* Heutige Team-Punkte je Ebene: TP + Shop-Laufstand (letzte
* user_sales_volumes-Zeile je User) — dieselbe Punktbasis wie die
* business_lines des TreeCalcBot ({@see UserSalesVolume::getPointsTPSum()}).
*
* @param array<int, int> $lineByUserId
* @return array<int, float>
*/
private function currentLinePoints(array $lineByUserId, int $month, int $year): array
{
if ($lineByUserId === []) {
return [];
}
$rows = UserSalesVolume::query()
->whereIn('user_id', array_keys($lineByUserId))
->where('month', $month)
->where('year', $year)
->whereIn('id', function ($sub) use ($month, $year) {
$sub->selectRaw('MAX(id)')
->from('user_sales_volumes')
->where('month', $month)
->where('year', $year)
->groupBy('user_id');
})
->get(['user_id', 'month_TP_points', 'month_shop_points']);
$byLine = [];
foreach ($rows as $row) {
$line = $lineByUserId[(int) $row->user_id] ?? null;
if ($line !== null) {
$byLine[$line] = ($byLine[$line] ?? 0.0) + (float) $row->month_TP_points + (float) $row->month_shop_points;
}
}
return $byLine;
}
/**
* @param array<int, float> $linePoints
*/
private function sumLinePoints(array $linePoints, int $maxLine): float
{
$sum = 0.0;
foreach ($linePoints as $line => $points) {
if ($line <= $maxLine) {
$sum += $points;
}
}
return $sum;
}
/**
* @return array{0: array{paylines: int, member_ids: int[]}, 1: bool} [Kontext, Lauf fehlgeschlagen]
*/

View file

@ -3,6 +3,7 @@
namespace App\Services\Abo;
use App\Models\UserAbo;
use App\Models\UserAboStatusHistory;
use App\Models\UserSalesVolume;
use App\Services\AboHelper;
use App\Services\Backoffice\BackofficeDashboardService;
@ -58,6 +59,10 @@ class TeamAboOverviewService
/**
* Leichtgewichtige Kennzahlen für das Dashboard (ohne Punkte-/Mitglieder-Details).
*
* Abo-Bestände sind monatsbezogen (Kundenwunsch 2026-08-19, N2): im laufenden
* Monat der Live-Bestand, für vergangene Monate der Bestand zum Monatsende
* ({@see aboStockCount()}); die VIP-Statistik bleibt bewusst tagesaktuell (F7/F3).
*
* @return array{team_total_abos: int, team_partner_abos: int, team_partner_abos_new: int, team_customer_abos: int, team_customer_abos_new: int, shop_1000: int, new_partners: int}
*/
public function teamKpis(User $user, int $month, int $year): array
@ -80,8 +85,8 @@ class TeamAboOverviewService
];
}
$partnerAbos = UserAbo::query()->activeAbo()->where('is_for', 'me')->whereIn('user_id', $teamUserIds)->count();
$customerAbos = UserAbo::query()->activeAbo()->where('is_for', 'ot')->whereIn('member_id', $teamUserIds)->count();
$partnerAbos = $this->aboStockCount($teamUserIds, 'user_id', 'me', $month, $year);
$customerAbos = $this->aboStockCount($teamUserIds, 'member_id', 'ot', $month, $year);
return [
'team_total_abos' => $partnerAbos + $customerAbos,
@ -97,6 +102,7 @@ class TeamAboOverviewService
/**
* Persönliche Abo-Kennzahlen des Beraters selbst (eigenes Abo + eigene Kundenabos),
* konsistent mit der activeAbo-Definition. Inklusive der im Monat neu hinzugekommenen.
* Bestände monatsbezogen wie bei {@see teamKpis()}.
*
* @return array{own_abos: int, own_abos_new: int, own_customer_abos: int, own_customer_abos_new: int}
*/
@ -105,13 +111,76 @@ class TeamAboOverviewService
$userId = (int) $user->id;
return [
'own_abos' => UserAbo::query()->activeAbo()->where('is_for', 'me')->where('user_id', $userId)->count(),
'own_abos' => $this->aboStockCount([$userId], 'user_id', 'me', $month, $year),
'own_abos_new' => $this->newAboCount([$userId], 'user_id', 'me', $month, $year),
'own_customer_abos' => UserAbo::query()->activeAbo()->where('is_for', 'ot')->where('member_id', $userId)->count(),
'own_customer_abos' => $this->aboStockCount([$userId], 'member_id', 'ot', $month, $year),
'own_customer_abos_new' => $this->newAboCount([$userId], 'member_id', 'ot', $month, $year),
];
}
/**
* Abo-Bestand monatsbezogen (N2): Für den laufenden (oder einen zukünftigen)
* Monat der heutige Live-Bestand; für vergangene Monate der rückgerechnete
* Bestand zum Monatsende — Abo gestartet bis Monatsende und danach erst
* beendet (heute noch aktiv, später gekündigt/gelöscht oder laut
* Status-Historie erst später deaktiviert).
*
* Näherung für Monate vor Einführung der Status-Historie (07/2026):
* Deaktivierungen ohne cancel_date sind dort nicht datierbar — solche Abos
* fehlen im historischen Bestand.
*
* @param int[] $userIds
*/
public function aboStockCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
{
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
$query = UserAbo::query()
->where('is_for', $isFor)
->whereIn($userColumn, $userIds);
if ($endOfMonth->gte(now()->endOfMonth())) {
return $query->activeAbo()->count();
}
return $query
->withTrashed()
->where('start_date', '<=', $endOfMonth)
// Bis zum Stichtag datierbar beendet? (Kündigung/Löschung vor Monatsende)
->where(fn ($q) => $q->whereNull('cancel_date')->orWhere('cancel_date', '>', $endOfMonth))
->where(fn ($q) => $q->whereNull('deleted_at')->orWhere('deleted_at', '>', $endOfMonth))
// Laut Status-Historie zum Stichtag deaktiviert? (letztes Ereignis bis
// Monatsende ist deaktivierend und wurde bis dahin nicht reaktiviert)
->whereNotExists(function ($deactivated) use ($endOfMonth) {
$deactivated->from('user_abo_status_histories as h')
->whereColumn('h.user_abo_id', 'user_abos.id')
->whereIn('h.action', [UserAboStatusHistory::ACTION_CANCELLED, UserAboStatusHistory::ACTION_DEACTIVATED])
->where('h.created_at', '<=', $endOfMonth)
->whereNotExists(function ($reactivated) use ($endOfMonth) {
$reactivated->from('user_abo_status_histories as r')
->whereColumn('r.user_abo_id', 'h.user_abo_id')
->where('r.action', UserAboStatusHistory::ACTION_REACTIVATED)
->where('r.created_at', '<=', $endOfMonth)
->whereColumn('r.created_at', '>', 'h.created_at');
});
})
// Heute beendete Abos ohne datierbares Ende (z. B. Admin-Toggle vor
// Einführung der Historie) zählen nur, wenn das Ende nach dem Stichtag lag.
->where(function ($outer) use ($endOfMonth) {
$outer
->where(fn ($active) => $active->activeAbo()->whereNull('deleted_at'))
->orWhere('cancel_date', '>', $endOfMonth)
->orWhere('deleted_at', '>', $endOfMonth)
->orWhereHas('status_histories', function ($history) use ($endOfMonth) {
$history->whereIn('action', [
UserAboStatusHistory::ACTION_CANCELLED,
UserAboStatusHistory::ACTION_DEACTIVATED,
])->where('created_at', '>', $endOfMonth);
});
})
->count();
}
/**
* Monatliches Abo-Wachstum des Teams für ein Jahr: je Monat die Anzahl neu
* hinzugekommener (aktiver) Eigen- und Kunden-Abos anhand des Eingangsmonats

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.

View file

@ -24,7 +24,7 @@ class LiveCommissionService
private const CACHE_HOURS = 6;
/**
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool, qual_level: array<string, mixed>|null, next_level: array<string, mixed>|null, payline_points_qual_kp: float, personal_points: float}
*/
public function snapshot(User $user, int $month, int $year): array
{
@ -40,11 +40,21 @@ class LiveCommissionService
(float) ($stored->commission_pp_total ?? 0),
(float) ($stored->commission_shop_sales ?? 0),
(float) ($stored->commission_growth_total ?? 0),
false
false,
qualLevel: $this->normalizeLevel($stored->qual_user_level),
nextLevel: $this->resolveNextLevel(
$stored->qual_user_level_next,
$stored->next_qual_user_level,
$stored->next_can_user_level,
),
paylinePointsQualKp: (float) ($stored->payline_points_qual_kp ?? 0),
personalPoints: (float) ($stored->sales_volume_points_KP_sum ?? 0),
);
}
$cacheKey = "live_business_{$user->id}_{$month}_{$year}";
// v3: Payload um Level-/Quali-Daten erweitert (N1/N4, next_level mit
// Karriere-Fallback) — alte Cache-Einträge dürfen nicht wiederverwendet werden.
$cacheKey = "live_business_v3_{$user->id}_{$month}_{$year}";
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
@ -100,7 +110,15 @@ class LiveCommissionService
(float) $businessUser->commission_pp_total,
(float) $businessUser->commission_shop_sales,
(float) $businessUser->commission_growth_total,
true
true,
qualLevel: $this->normalizeLevel($businessUser->qual_user_level),
nextLevel: $this->resolveNextLevel(
$businessUser->qual_user_level_next,
$businessUser->next_qual_user_level,
$businessUser->next_can_user_level,
),
paylinePointsQualKp: (float) $businessUser->payline_points_qual_kp,
personalPoints: (float) $businessUser->sales_volume_points_KP_sum,
);
} catch (\Throwable $e) {
\Log::warning("LiveCommissionService: Live-Berechnung fehlgeschlagen für User {$user->id}: ".$e->getMessage());
@ -128,10 +146,93 @@ class LiveCommissionService
}
/**
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool}
* Level-Fortschritt für die Level-Kachel (N1, F2): monatliche
* Qualifikationsstufe plus "zum nächsten Level fehlen X Payline-Punkte /
* Y persönliches Volumen"; Karriere-Level als Zusatzinfo. Eine Stelle für
* die Rechnung, damit Kachel und Prognose nicht auseinanderlaufen.
*
* @return array{name: string, career_name: string, next_name: string|null, missing_pp: float, missing_personal: float, is_live: bool}|null
*/
private function buildSnapshot(float $paylinePoints, int $paylines, float $pp, float $shop, float $growth, bool $isLive, bool $failed = false): array
public function levelProgress(User $user, int $month, int $year): ?array
{
$snapshot = $this->snapshot($user, $month, $year);
$qualLevel = $snapshot['qual_level'];
if (! is_array($qualLevel) || ! empty($snapshot['failed'])) {
return null;
}
$nextLevel = $snapshot['next_level'];
// Punktebasis zum nächsten Level: dessen _calculated-Wert rechnet mit den
// Paylines/dem KP-Überschuss DES nächsten Levels — die eigene
// payline_points_qual_kp (erreichtes Level) wäre bei mehr Paylines zu klein.
$paylineBasis = is_array($nextLevel)
? (float) ($nextLevel['_calculated_payline_points_qual_kp'] ?? $snapshot['payline_points_qual_kp'])
: 0.0;
return [
'name' => (string) ($qualLevel['name'] ?? ''),
'career_name' => $user->user_level?->getLang('name') ?? '',
'next_name' => is_array($nextLevel) ? (string) ($nextLevel['name'] ?? '') : null,
'missing_pp' => is_array($nextLevel)
? max(0.0, (float) ($nextLevel['qual_pp'] ?? 0) - $paylineBasis)
: 0.0,
'missing_personal' => is_array($nextLevel)
? max(0.0, (float) ($nextLevel['qual_kp'] ?? 0) - (float) $snapshot['personal_points'])
: 0.0,
'is_live' => (bool) $snapshot['is_live'],
];
}
/**
* Nächstes Level für die "fehlen noch X Punkte"-Anzeige: qual_user_level_next
* ist im Normalzustand (Quali-Stufe == Karriere-Level) bewusst null — dann
* greift der direkt nächste Karriere-Level aus setNextUserLevel()
* (next_qual_user_level bzw. next_can_user_level, beide mit _calculated-Werten).
*
* @return array<string, mixed>|null
*/
private function resolveNextLevel(mixed $qualNext, mixed $nextQual, mixed $nextCan): ?array
{
return $this->normalizeLevel($qualNext)
?? $this->normalizeLevel($nextQual)
?? $this->normalizeLevel($nextCan);
}
/**
* Level-Daten (JSON aus UserBusiness oder Objekt/Array aus dem Live-Lauf)
* in ein einheitliches Array bringen; null wenn nichts vorliegt.
*
* @return array<string, mixed>|null
*/
private function normalizeLevel(mixed $level): ?array
{
if (is_object($level)) {
$level = method_exists($level, 'toArray') ? $level->toArray() : (array) $level;
}
return is_array($level) && $level !== [] ? $level : null;
}
/**
* @param array<string, mixed>|null $qualLevel
* @param array<string, mixed>|null $nextLevel
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool, qual_level: array<string, mixed>|null, next_level: array<string, mixed>|null, payline_points_qual_kp: float, personal_points: float}
*/
private function buildSnapshot(
float $paylinePoints,
int $paylines,
float $pp,
float $shop,
float $growth,
bool $isLive,
bool $failed = false,
?array $qualLevel = null,
?array $nextLevel = null,
float $paylinePointsQualKp = 0.0,
float $personalPoints = 0.0,
): array {
return [
'payline_points' => round($paylinePoints, 2),
'paylines' => $paylines,
@ -140,6 +241,10 @@ class LiveCommissionService
'growth' => round($growth, 2),
'is_live' => $isLive,
'failed' => $failed,
'qual_level' => $qualLevel,
'next_level' => $nextLevel,
'payline_points_qual_kp' => round($paylinePointsQualKp, 2),
'personal_points' => round($personalPoints, 2),
];
}
}

View file

@ -249,7 +249,7 @@ class SalesPointsVolume
$user_sales_volume = UserSalesVolume::create([
'user_id' => $user->id,
'shopping_order_id' => null,
'shopping_order_id' => $data['shopping_order_id'] ?? null,
'month' => $month,
'year' => $year,
'date' => $date,

View file

@ -202,6 +202,10 @@ class GdprDeletionService
$account->bank_bic = null;
$account->payment_data = null;
$account->notice = null;
if ($account->profile_image) {
Storage::disk('public')->delete('images/profile/'.$account->profile_image);
}
$account->profile_image = null;
$account->save();
}

View file

@ -9,6 +9,7 @@ use App\Models\Ingredient;
use App\Models\Product;
use App\Models\ShippingCountry;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserLevel;
use App\User;
@ -112,7 +113,7 @@ class HTMLHelper
public static function getAboDeliveryOptions($default = 5)
{
$values = \App\Models\UserAbo::$aboDeliveryDays;
$values = UserAbo::$aboDeliveryDays;
$ret = '';
foreach ($values as $value) {
$attr = ($value == $default) ? 'selected="selected"' : '';
@ -398,7 +399,7 @@ class HTMLHelper
public static function getTaxSaleOptions($id)
{
$values = ['1' => __('account.taxable_sales_1'), '2' => __('account.taxable_sales_2')];
$values = ['2' => __('account.taxable_sales_2'), '1' => __('account.taxable_sales_1')];
$ret = '';
$ret .= '<option value="">'.__('please select').'</option>\n';
foreach ($values as $key => $value) {

View file

@ -11,6 +11,7 @@ use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\User;
use Illuminate\Support\Facades\Log;
class IncentivePointsLogRepairService
{
@ -33,8 +34,7 @@ class IncentivePointsLogRepairService
$added = 0;
$windowStart = $incentive->qualification_start->copy()->startOfDay();
$windowEnd = $incentive->qualification_end->copy()->endOfDay();
[$windowStart, $windowEnd] = $incentive->qualificationWindow();
$candidates = User::query()
->where('m_sponsor', $participant->user_id)
@ -44,6 +44,17 @@ class IncentivePointsLogRepairService
})
->get();
$firstStarterOrders = $candidates->isNotEmpty()
? ShoppingOrder::query()
->whereIn('auth_user_id', $candidates->pluck('id'))
->wherePaidRegistrationIncludesStarterKit()
->whereBetween('created_at', [$windowStart, $windowEnd])
->orderBy('id')
->get()
->groupBy('auth_user_id')
->map(fn ($orders) => $orders->first())
: collect();
foreach ($candidates as $partner) {
if (IncentiveNewPartner::query()
->where('participant_id', $participant->id)
@ -52,12 +63,7 @@ class IncentivePointsLogRepairService
continue;
}
$order = ShoppingOrder::query()
->where('auth_user_id', $partner->id)
->wherePaidRegistrationIncludesStarterKit()
->whereBetween('created_at', [$windowStart, $windowEnd])
->orderBy('id')
->first();
$order = $firstStarterOrders->get($partner->id);
if ($order) {
IncentiveTracker::trackNewPartner($order);
@ -107,22 +113,55 @@ class IncentivePointsLogRepairService
* {@see syncMissingTrackingPartners} + {@see syncMissingSalesVolumeLogs} im selben
* Lauf alles neu an. Geloeschte User bleiben unangetastet.
*
* Ein leerer Sponsor (m_sponsor = NULL, z. B. Admin-Umbau in Arbeit oder verwaister
* Sponsor entfernt) gilt NICHT als Sponsorwechsel: das Tracking bleibt erhalten,
* geloescht wird nur bei einem tatsaechlich gesetzten, abweichenden Sponsor.
*
* @return int Anzahl entfernter Partner-Trackings fuer diesen Teilnehmer
*/
public function removeStaleSponsorTrackings(IncentiveParticipant $participant): int
{
$removed = 0;
foreach ($participant->newPartners()->get() as $tracking) {
$partner = User::find($tracking->user_id);
if (! $partner) {
$trackings = $participant->newPartners()->get();
if ($trackings->isEmpty()) {
return 0;
}
$sponsorIdByUserId = User::query()
->whereIn('id', $trackings->pluck('user_id'))
->pluck('m_sponsor', 'id');
foreach ($trackings as $tracking) {
if (! $sponsorIdByUserId->has($tracking->user_id)) {
continue;
}
if ((int) $partner->m_sponsor === (int) $participant->user_id) {
$currentSponsorId = $sponsorIdByUserId->get($tracking->user_id);
if ($currentSponsorId === null) {
Log::info('Incentive-Repair: Neupartner-Tracking uebersprungen, m_sponsor ist leer (kein Sponsorwechsel)', [
'participant_id' => $participant->id,
'tracking_id' => $tracking->id,
'partner_user_id' => $tracking->user_id,
'tracked_sponsor_user_id' => $participant->user_id,
]);
continue;
}
if ((int) $currentSponsorId === (int) $participant->user_id) {
continue;
}
Log::info('Incentive-Repair: Neupartner-Tracking wegen Sponsorwechsel entfernt', [
'participant_id' => $participant->id,
'tracking_id' => $tracking->id,
'partner_user_id' => $tracking->user_id,
'old_sponsor_user_id' => $participant->user_id,
'new_sponsor_user_id' => (int) $currentSponsorId,
]);
IncentivePointsLog::where('incentive_new_partner_id', $tracking->id)->delete();
$tracking->delete();
$removed++;
@ -146,27 +185,22 @@ class IncentivePointsLogRepairService
$added = 0;
$qualEnd = $incentive->qualification_end->copy()->endOfDay();
[$qualStart, $qualEnd] = $incentive->qualificationWindow();
$candidatesOt = UserAbo::query()
->where('is_for', 'ot')
->where('status', 2)
->where('member_id', $participant->user_id)
->whereBetween('created_at', [
$incentive->qualification_start,
$qualEnd,
])
->whereBetween('created_at', [$qualStart, $qualEnd])
->get();
$candidatesMe = UserAbo::query()
->where('is_for', 'me')
->where('status', 2)
->where('user_id', $participant->user_id)
->where(function ($q) use ($incentive, $qualEnd) {
$q->whereBetween('created_at', [
$incentive->qualification_start,
$qualEnd,
])->orWhere('created_at', '<', $incentive->qualification_start);
->where(function ($q) use ($qualStart, $qualEnd) {
$q->whereBetween('created_at', [$qualStart, $qualEnd])
->orWhere('created_at', '<', $qualStart);
})
->get();
@ -194,7 +228,6 @@ class IncentivePointsLogRepairService
->where('participant_id', $participant->id)
->where('user_abo_id', $userAbo->id)
->exists()) {
$qualStart = $incentive->qualification_start->copy()->startOfDay();
$activatedAt = $userAbo->created_at;
$logMonth = (int) $userAbo->created_at->month;
$logYear = (int) $userAbo->created_at->year;

View file

@ -37,8 +37,7 @@ class IncentiveTracker
$incentives = Incentive::query()
->active()
->whereDate('qualification_start', '<=', $registration_date)
->whereDate('qualification_end', '>=', $registration_date)
->qualificationWindowContains($registration_date)
->get();
foreach ($incentives as $incentive) {
@ -106,8 +105,7 @@ class IncentiveTracker
$incentives = Incentive::query()
->active()
->whereDate('qualification_start', '<=', $activation_date)
->whereDate('qualification_end', '>=', $activation_date)
->qualificationWindowContains($activation_date)
->get();
foreach ($incentives as $incentive) {

View file

@ -151,12 +151,15 @@ class Payment
]);
}
public static function addSponsorBuyingPoints(User $user, $product)
public static function addSponsorBuyingPoints(User $user, $product, ?ShoppingOrder $shopping_order = null)
{
if ($user->user_sponsor) {
$data = [
'user_id' => $user->user_sponsor->id,
// Order-Referenz macht die Buchung eindeutig identifizierbar —
// der Nacht-Repair dedupliziert darüber statt über den info-Text.
'shopping_order_id' => $shopping_order?->id,
'total_net' => 0,
'points' => $product->sponsor_buying_points_amount,
'info' => 'VP: '.$user->getFullName(false).' | '.$product->name,
@ -204,7 +207,7 @@ class Payment
self::addBuyingRestriction($user, $shopping_order_item->product->id);
}
if ($shopping_order_item->product->sponsor_buying_points) {
self::addSponsorBuyingPoints($user, $shopping_order_item->product);
self::addSponsorBuyingPoints($user, $shopping_order_item->product, $shopping_order);
}
if ($shopping_order_item->product->action) {
$send_link = true;

View file

@ -1,14 +1,47 @@
<?php
namespace App\Services;
abstract class SlimStatus {
abstract class SlimStatus
{
const FAILURE = 'failure';
const SUCCESS = 'success';
}
class Slim {
class Slim
{
/**
* Speichert ein Slim-Cropper-Upload (Base64-Payload aus getImages()) im
* public-Disk und liefert Dateiname + Metadaten — gemeinsame Logik für
* Shop- und Profilfoto-Upload. Null bei nicht erlaubtem Dateityp.
*
* @param array $image Ein Eintrag aus {@see getImages()}
* @return array{filename: string, originalname: string, ext: string, mime: string, size: mixed}|null
*/
public static function storeCroppedImage(array $image, string $directory): ?array
{
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
public static function getImages($inputName = 'slim') {
if (! isset($allowed[$image['output']['type']])) {
return null;
}
$name = uniqid().'_'.Slim::sanitizeFileName($image['output']['name']);
\Storage::disk('public')->put(rtrim($directory, '/').'/'.$name, $image['output']['data']);
return [
'filename' => $name,
'originalname' => $image['output']['name'],
'ext' => $allowed[$image['output']['type']],
'mime' => $image['output']['type'],
'size' => $image['input']['size'] ?? null,
];
}
public static function getImages($inputName = 'slim')
{
$values = Slim::getPostData($inputName);
@ -18,9 +51,9 @@ class Slim {
}
// determine if contains multiple input values, if is singular, put in array
$data = array();
if (!is_array($values)) {
$values = array($values);
$data = [];
if (! is_array($values)) {
$values = [$values];
}
// handle all posted fields
@ -37,10 +70,13 @@ class Slim {
}
// $value should be in JSON format
private static function parseInput($value) {
private static function parseInput($value)
{
// if no json received, exit, don't handle empty input values.
if (empty($value)) {return null;}
if (empty($value)) {
return null;
}
// If magic quotes enabled
// Magic quotes were removed in PHP 5.4, no need to check in PHP 8.2
@ -54,27 +90,26 @@ class Slim {
$output = null;
$meta = null;
if (isset ($data->input)) {
if (isset($data->input)) {
$inputData = null;
if (isset($data->input->image)) {
$inputData = Slim::getBase64Data($data->input->image);
}
else if (isset($data->input->field)) {
} elseif (isset($data->input->field)) {
$filename = $_FILES[$data->input->field]['tmp_name'];
if ($filename) {
$inputData = file_get_contents($filename);
}
}
$input = array(
$input = [
'data' => $inputData,
'name' => $data->input->name,
'type' => $data->input->type,
'size' => $data->input->size,
'width' => $data->input->width,
'height' => $data->input->height,
);
];
}
@ -83,41 +118,40 @@ class Slim {
$outputDate = null;
if (isset($data->output->image)) {
$outputData = Slim::getBase64Data($data->output->image);
}
else if (isset ($data->output->field)) {
} elseif (isset($data->output->field)) {
$filename = $_FILES[$data->output->field]['tmp_name'];
if ($filename) {
$outputData = file_get_contents($filename);
}
}
$output = array(
$output = [
'data' => $outputData,
'name' => $data->output->name,
'type' => $data->output->type,
'width' => $data->output->width,
'height' => $data->output->height
);
'height' => $data->output->height,
];
}
if (isset($data->actions)) {
$actions = array(
'crop' => $data->actions->crop ? array(
$actions = [
'crop' => $data->actions->crop ? [
'x' => $data->actions->crop->x,
'y' => $data->actions->crop->y,
'width' => $data->actions->crop->width,
'height' => $data->actions->crop->height,
'type' => $data->actions->crop->type
) : null,
'size' => $data->actions->size ? array(
'type' => $data->actions->crop->type,
] : null,
'size' => $data->actions->size ? [
'width' => $data->actions->size->width,
'height' => $data->actions->size->height
) : null,
'height' => $data->actions->size->height,
] : null,
'rotation' => $data->actions->rotation,
'filters' => $data->actions->filters ? array(
'sharpen' => $data->actions->filters->sharpen
) : null
);
'filters' => $data->actions->filters ? [
'sharpen' => $data->actions->filters->sharpen,
] : null,
];
}
if (isset($data->meta)) {
@ -125,16 +159,17 @@ class Slim {
}
// We've sanitized the base64data and will now return the clean file object
return array(
return [
'input' => $input,
'output' => $output,
'actions' => $actions,
'meta' => $meta
);
'meta' => $meta,
];
}
// $path should have trailing slash
public static function saveFile($data, $name, $path = 'tmp/', $uid = true) {
public static function saveFile($data, $name, $path = 'tmp/', $uid = true)
{
// Add trailing slash if omitted
if (substr($path, -1) !== '/') {
@ -142,7 +177,7 @@ class Slim {
}
// Test if directory already exists
if(!is_dir($path)){
if (! is_dir($path)) {
mkdir($path, 0755, true);
}
@ -151,41 +186,44 @@ class Slim {
// Let's put a unique id in front of the filename so we don't accidentally overwrite other files
if ($uid) {
$name = uniqid() . '_' . $name;
$name = uniqid().'_'.$name;
}
// Add name to path, we need the full path including the name to save the file
$path = $path . $name;
$path = $path.$name;
// store the file
Slim::save($data, $path);
// return the files new name and location
return array(
return [
'name' => $name,
'path' => $path
);
'path' => $path,
];
}
/**
* Get data from remote URL
* @param $url
*
* @return string
*/
public static function fetchURL($url, $maxFileSize) {
if (!ini_get('allow_url_fopen')) {
public static function fetchURL($url, $maxFileSize)
{
if (! ini_get('allow_url_fopen')) {
return null;
}
$content = null;
try {
$content = @file_get_contents($url, false, null, 0, $maxFileSize);
} catch(Exception $e) {
} catch (Exception $e) {
return false;
}
return $content;
}
public static function outputJSON($data) {
public static function outputJSON($data)
{
header('Content-Type: application/json');
echo json_encode($data);
}
@ -196,33 +234,34 @@ class Slim {
* or any of the following characters -_~,;[]().
* If you don't need to handle multi-byte characters
* you can use preg_replace rather than mb_ereg_replace
* @param $str
*
* @return string
*/
public static function sanitizeFileName($str) {
public static function sanitizeFileName($str)
{
// Basic clean up
$str = preg_replace('([^\w\s\d\-_~,;\[\]\(\).])', '', $str);
// Remove any runs of periods
$str = preg_replace('([\.]{2,})', '', $str);
$str = (strlen($str) > 33) ? substr($str,-33) : $str;
$str = (strlen($str) > 33) ? substr($str, -33) : $str;
return $str;
}
/**
* Gets the posted data from the POST or FILES object. If was using Slim to upload it will be in POST (as posted with hidden field) if not enhanced with Slim it'll be in FILES.
* @param $inputName
*
* @return array|bool
*/
private static function getPostData($inputName) {
private static function getPostData($inputName)
{
$values = array();
$values = [];
if (isset($_POST[$inputName])) {
$values = $_POST[$inputName];
}
else if (isset($_FILES[$inputName])) {
} elseif (isset($_FILES[$inputName])) {
// Slim was not used to upload this file
return false;
}
@ -232,24 +271,25 @@ class Slim {
/**
* Saves the data to a given location
* @param $data
* @param $path
*
* @return bool
*/
private static function save($data, $path) {
if (!file_put_contents($path, $data)) {
private static function save($data, $path)
{
if (! file_put_contents($path, $data)) {
return false;
}
return true;
}
/**
* Strips the "data:image..." part of the base64 data string so PHP can save the string as a file
* @param $data
*
* @return string
*/
private static function getBase64Data($data) {
private static function getBase64Data($data)
{
return base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $data));
}
}
}

View file

@ -0,0 +1,337 @@
<?php
namespace App\Services;
use App\Models\ShoppingOrder;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Nacht-Repair fuer Vertriebspartner-Punkte (VP) des Sponsors.
*
* Live werden die VP-Punkte nur im Zahlungs-Callback gebucht
* ({@see Payment::addSponsorBuyingPoints} via Payment::paymentStatusPaidAction),
* seit 2026-08 inklusive shopping_order_id. Laeuft dieser Pfad nicht durch
* (verspaetete PAYONE-Meldungen, Stripe-Sonderfaelle), fehlt die status=5-Zeile
* ("Registrierung") beim Sponsor dauerhaft, obwohl das Incentive-Tracking per
* incentive:calculate nachgezogen wird.
*
* Dieser Repair zieht fehlende VP-Zeilen idempotent nach. Gebucht wird immer in
* den Ziel-Monat (Entscheidung 2026-08: keine rueckwirkenden Buchungen in
* abgeschlossene Provisionsmonate); geprueft werden auch Bestellungen des
* VORmonats, damit Monatswechsel-Faelle (Bestellung 31., Zahlung/Callback im
* Folgemonat) nicht durchs Raster fallen.
*
* Duplikat-Erkennung: primaer ueber shopping_order_id (eindeutig, robust gegen
* Umbenennungen); fuer Alt-Zeilen ohne Order-Referenz zaehlend ueber den
* info-Text im Pruef-Fenster — eine Alt-Zeile "verbraucht" genau eine
* Bestellung, zwei gleiche Bestellungen werden also nicht mehr faelschlich als
* eine erkannt.
*/
class SponsorBuyingPointsRepairService
{
/**
* @return array{checked: int, skipped_existing: int, skipped_other_sponsor: array<int, array{order_id: int, buyer: string, current_sponsor_id: int, booked_sponsor_id: int}>, rebooked: array<int, array{order_id: int, sponsor_id: int, sponsor: string, buyer: string, product: string, points: int}>, created: array<int, array{order_id: int, sponsor_id: int, sponsor: string, buyer: string, product: string, points: int}>}
*/
public function repair(int $month, int $year, bool $dryRun = false): array
{
$start = Carbon::createFromDate($year, $month, 1)->startOfMonth();
$end = $start->copy()->endOfMonth();
$scanStart = $start->copy()->subMonth()->startOfMonth();
$orders = ShoppingOrder::query()
->whereNotNull('auth_user_id')
->where('paid', true)
->whereBetween('created_at', [$scanStart, $end])
->whereHas('shopping_order_items.product', function ($q) {
$q->where('sponsor_buying_points', true);
})
->with('shopping_order_items.product')
->orderBy('id')
->get();
$buyers = User::query()
->with(['account', 'user_sponsor.account'])
->whereIn('id', $orders->pluck('auth_user_id')->unique())
->get()
->keyBy('id');
// Kandidaten-Infos vorab (Format wie Payment::addSponsorBuyingPoints),
// damit Alt-Zeilen ohne Order-Referenz gebatcht geladen werden koennen.
$candidateInfos = $orders
->flatMap(function (ShoppingOrder $order) use ($buyers) {
$buyer = $buyers->get($order->auth_user_id);
return $buyer === null ? [] : $order->shopping_order_items
->filter(fn ($item) => $item->product?->sponsor_buying_points)
->map(fn ($item) => 'VP: '.$buyer->getFullName(false).' | '.$item->product->name);
})
->unique()
->values();
// Sponsor-UNABHAENGIG deduplizieren: liegt die Buchung (nach Umhaengung)
// bei einem anderen Sponsor, wird unterschieden — Firmenaccount hat nie
// Anspruch auf VP-Punkte (User-Fehler bei Registrierung ueber die
// Hauptwebseite, Kundenentscheid 2026-08-19) und wird automatisch auf den
// Berater umgebucht; Berater-zu-Berater bleibt manuelle Pruefung.
[$bookedOrderRows, $legacyInfoRows] = $this->existingBookings($orders->pluck('id'), $candidateInfos, $scanStart, $end);
$companyUserId = (int) config('mivita.company_user_id');
$created = [];
$rebooked = [];
$skippedExisting = 0;
$skippedOtherSponsor = [];
$affectedSponsorIds = [];
foreach ($orders as $order) {
$buyer = $buyers->get($order->auth_user_id);
$sponsor = $buyer?->user_sponsor;
if (! $buyer || ! $sponsor) {
continue;
}
foreach ($order->shopping_order_items as $item) {
$product = $item->product;
if (! $product || ! $product->sponsor_buying_points) {
continue;
}
$info = 'VP: '.$buyer->getFullName(false).' | '.$product->name;
$existingRow = $bookedOrderRows[$order->id] ?? null;
if ($existingRow === null && ($legacyInfoRows[$info] ?? []) !== []) {
// Alt-Zeile ohne Order-Referenz: deckt genau EINE Bestellung ab.
$existingRow = array_shift($legacyInfoRows[$info]);
}
if ($existingRow !== null) {
$existingSponsorId = (int) $existingRow['user_id'];
if ($existingSponsorId === (int) $sponsor->id) {
$skippedExisting++;
continue;
}
if ($existingSponsorId === $companyUserId && (int) $sponsor->id !== $companyUserId) {
$rebooked[] = [
'order_id' => $order->id,
'sponsor_id' => (int) $sponsor->id,
'sponsor' => $sponsor->getFullName(false),
'buyer' => $buyer->getFullName(false),
'product' => $product->name,
'points' => (int) $product->sponsor_buying_points_amount,
];
if (! $dryRun) {
$this->rebookFromCompany($existingRow, $order, $sponsor, $buyer, $product, $info, $month, $year, $affectedSponsorIds);
}
continue;
}
$skippedExisting++;
$skippedOtherSponsor[] = [
'order_id' => $order->id,
'buyer' => $buyer->getFullName(false),
'current_sponsor_id' => $sponsor->id,
'booked_sponsor_id' => $existingSponsorId,
];
Log::warning('SponsorBuyingPointsRepair: VP-Buchung liegt bei anderem Berater — manuell pruefen (Umhaengung?)', [
'shopping_order_id' => $order->id,
'buyer_id' => $buyer->id,
'current_sponsor_id' => $sponsor->id,
'booked_sponsor_id' => $existingSponsorId,
]);
continue;
}
$created[] = [
'order_id' => $order->id,
'sponsor_id' => $sponsor->id,
'sponsor' => $sponsor->getFullName(false),
'buyer' => $buyer->getFullName(false),
'product' => $product->name,
'points' => (int) $product->sponsor_buying_points_amount,
];
if ($dryRun) {
continue;
}
UserSalesVolume::create([
'user_id' => $sponsor->id,
'shopping_order_id' => $order->id,
'month' => $month,
'year' => $year,
'date' => now()->format('d.m.Y'),
'points' => (int) $product->sponsor_buying_points_amount,
'status_points' => 2,
'total_net' => 0,
'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Nacht-Repair)',
'info' => $info,
'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id],
'status' => 5,
]);
$bookedOrderRows[$order->id] = ['id' => 0, 'user_id' => (int) $sponsor->id, 'month' => $month, 'year' => $year];
$affectedSponsorIds[$sponsor->id] = true;
Log::info('SponsorBuyingPointsRepair: VP-Punkte nachgebucht', [
'shopping_order_id' => $order->id,
'sponsor_id' => $sponsor->id,
'buyer_id' => $buyer->id,
'points' => (int) $product->sponsor_buying_points_amount,
'month' => $month,
'year' => $year,
]);
}
}
foreach (array_keys($affectedSponsorIds) as $sponsorId) {
SalesPointsVolume::reCalculateSalesPointsVolume($sponsorId, $month, $year);
}
return [
'checked' => $orders->count(),
'skipped_existing' => $skippedExisting,
'skipped_other_sponsor' => $skippedOtherSponsor,
'rebooked' => $rebooked,
'created' => $created,
];
}
/**
* Umbuchung Firmenaccount → Berater (Kundenentscheid 2026-08-19: VP-Punkte
* stehen immer dem Berater zu; Landung beim Firmenaccount = User-Fehler bei
* der Registrierung ueber die Hauptwebseite).
*
* - Zeile im Ziel-Monat: in place umhaengen (eine Zeile, Syslog-Spur).
* - Zeile in einem abgeschlossenen Monat: der Monat bleibt unangetastet —
* neue Buchung beim Berater im Ziel-Monat plus Gegenbuchung (negative
* Punkte) beim Firmenaccount im Ziel-Monat, damit die Summen stimmen.
*
* @param array{id: int, user_id: int, month: int, year: int} $existingRow
* @param array<int, bool> $affectedSponsorIds
*/
private function rebookFromCompany(array $existingRow, ShoppingOrder $order, User $sponsor, User $buyer, $product, string $info, int $month, int $year, array &$affectedSponsorIds): void
{
$points = (int) $product->sponsor_buying_points_amount;
$isTargetMonth = (int) $existingRow['month'] === $month && (int) $existingRow['year'] === $year;
if ($isTargetMonth) {
$volume = UserSalesVolume::find($existingRow['id']);
if ($volume !== null) {
$syslog = $volume->syslog ?? [];
$syslog[now()->format('d.m.Y-h:i:s')] = 'sales:repair-sponsor-vp: Umbuchung Firmenaccount #'.$existingRow['user_id'].' -> Berater #'.$sponsor->id.' (order #'.$order->id.')';
$volume->user_id = $sponsor->id;
$volume->shopping_order_id = $order->id;
$volume->info = $info;
$volume->syslog = $syslog;
$volume->save();
}
} else {
UserSalesVolume::create([
'user_id' => $sponsor->id,
'shopping_order_id' => $order->id,
'month' => $month,
'year' => $year,
'date' => now()->format('d.m.Y'),
'points' => $points,
'status_points' => 2,
'total_net' => 0,
'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Umbuchung vom Firmenaccount)',
'info' => $info,
'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id.' Umbuchung von Firmenaccount #'.$existingRow['user_id'].' ('.$existingRow['month'].'/'.$existingRow['year'].')'],
'status' => 5,
]);
// Bewusst abweichendes info-Format ("VP-Storno"), damit die
// Gegenbuchung nie als Alt-Buchung einer Bestellung erkannt wird.
UserSalesVolume::create([
'user_id' => $existingRow['user_id'],
'shopping_order_id' => null,
'month' => $month,
'year' => $year,
'date' => now()->format('d.m.Y'),
'points' => -$points,
'status_points' => 2,
'total_net' => 0,
'message' => 'hinzugefügt: '.now()->format('d.m.Y').' (Gegenbuchung Umbuchung an Berater)',
'info' => 'VP-Storno: '.$buyer->getFullName(false).' | '.$product->name.' -> Berater #'.$sponsor->id,
'syslog' => [now()->format('d.m.Y-h:i:s') => 'sales:repair-sponsor-vp order #'.$order->id.' Gegenbuchung zu Zeile #'.$existingRow['id']],
'status' => 5,
]);
}
$affectedSponsorIds[$sponsor->id] = true;
$affectedSponsorIds[$existingRow['user_id']] = true;
Log::info('SponsorBuyingPointsRepair: VP-Punkte vom Firmenaccount auf Berater umgebucht', [
'shopping_order_id' => $order->id,
'buyer_id' => $buyer->id,
'company_user_id' => $existingRow['user_id'],
'sponsor_id' => $sponsor->id,
'points' => $points,
'in_place' => $isTargetMonth,
]);
}
/**
* Bestehende VP-Buchungen im Pruef-Fenster, einmal gebatcht und bewusst
* sponsor-unabhaengig (Umhaengungs-Faelle!): Order-Referenzen
* (monatsunabhaengig eindeutig) plus Alt-Zeilen ohne Referenz je info-Text —
* jede Alt-Zeile deckt genau EINE Bestellung ab (zaehlend, damit zwei
* gleiche Bestellungen nicht als eine erkannt werden).
*
* @param Collection<int, int> $orderIds
* @param Collection<int, string> $candidateInfos
* @return array{0: array<int, array{id: int, user_id: int, month: int, year: int}>, 1: array<string, array<int, array{id: int, user_id: int, month: int, year: int}>>}
*/
private function existingBookings(Collection $orderIds, Collection $candidateInfos, Carbon $scanStart, Carbon $end): array
{
if ($orderIds->isEmpty()) {
return [[], []];
}
$rows = UserSalesVolume::query()
->where('status', 5)
->where(function ($q) use ($orderIds, $candidateInfos, $scanStart, $end) {
$q->whereIn('shopping_order_id', $orderIds)
->orWhere(function ($qq) use ($candidateInfos, $scanStart, $end) {
$qq->whereNull('shopping_order_id')
->whereIn('info', $candidateInfos)
->where(function ($window) use ($scanStart, $end) {
$window->where(fn ($w) => $w->where('month', (int) $scanStart->month)->where('year', (int) $scanStart->year))
->orWhere(fn ($w) => $w->where('month', (int) $end->month)->where('year', (int) $end->year));
});
});
})
->get(['id', 'user_id', 'shopping_order_id', 'info', 'month', 'year']);
$bookedOrderRows = [];
$legacyInfoRows = [];
foreach ($rows as $row) {
$rowData = [
'id' => (int) $row->id,
'user_id' => (int) $row->user_id,
'month' => (int) $row->month,
'year' => (int) $row->year,
];
if ($row->shopping_order_id !== null) {
$bookedOrderRows[(int) $row->shopping_order_id] = $rowData;
} else {
$legacyInfoRows[$row->info][] = $rowData;
}
}
return [$bookedOrderRows, $legacyInfoRows];
}
}

View file

@ -0,0 +1,126 @@
<?php
namespace App\Services\Team;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Carbon\Carbon;
/**
* Jahresgebühr-Nachfassliste für den Team-Bereich (alle Berater, eigenes Team).
*
* Sammelt alle Teampartner der Downline, deren Account-Gültigkeit
* (`users.payment_account` = „bezahlt bis") überfällig ist oder innerhalb der
* nächsten drei Monate abläuft — gruppiert nach Dringlichkeit (überfällig zuerst,
* dann fällig in ≤ 1 / ≤ 2 / ≤ 3 Monaten). User ohne `payment_account` werden
* übersprungen. Wiederverwendet die Linien-Logik aus
* {@see BackofficeDashboardService} (keine Duplizierung der Baum-Traversierung).
*/
class AnnualFeeDueService
{
public const GROUP_OVERDUE = 'overdue';
public const GROUP_DUE_1_MONTH = 'due_1_month';
public const GROUP_DUE_2_MONTHS = 'due_2_months';
public const GROUP_DUE_3_MONTHS = 'due_3_months';
/**
* @var list<string> Gruppen in Anzeige-Reihenfolge (dringlichste zuerst)
*/
public const GROUP_ORDER = [
self::GROUP_OVERDUE,
self::GROUP_DUE_1_MONTH,
self::GROUP_DUE_2_MONTHS,
self::GROUP_DUE_3_MONTHS,
];
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* Liefert die Teampartner mit (bald) fälliger Jahresgebühr, gruppiert nach
* Dringlichkeit und innerhalb jeder Gruppe nach Fälligkeitsdatum aufsteigend.
*
* @return array<string, list<array{
* user_id: int,
* first_name: string,
* last_name: string,
* email: string,
* phone: string,
* mobil: string,
* line: int,
* level_name: string,
* due_date: Carbon
* }>>
*/
public function dueGroups(int $rootUserId, ?Carbon $reference = null): array
{
$now = ($reference ?? now())->copy();
$groups = array_fill_keys(self::GROUP_ORDER, []);
foreach ($this->dashboardService->lineBuckets($rootUserId) as $line => $users) {
foreach ($users as $user) {
if (! $user->payment_account) {
continue;
}
$dueDate = Carbon::parse($user->payment_account);
$groupKey = $this->groupKeyFor($dueDate, $now);
if ($groupKey === null) {
continue;
}
$groups[$groupKey][] = $this->buildRow($user, (int) $line, $dueDate);
}
}
foreach ($groups as $groupKey => $rows) {
usort($rows, fn (array $a, array $b): int => $a['due_date'] <=> $b['due_date']);
$groups[$groupKey] = $rows;
}
return $groups;
}
private function groupKeyFor(Carbon $dueDate, Carbon $now): ?string
{
return match (true) {
$dueDate->lte($now) => self::GROUP_OVERDUE,
$dueDate->lte($now->copy()->addMonth()) => self::GROUP_DUE_1_MONTH,
$dueDate->lte($now->copy()->addMonths(2)) => self::GROUP_DUE_2_MONTHS,
$dueDate->lte($now->copy()->addMonths(3)) => self::GROUP_DUE_3_MONTHS,
default => null,
};
}
/**
* @return array{
* user_id: int,
* first_name: string,
* last_name: string,
* email: string,
* phone: string,
* mobil: string,
* line: int,
* level_name: string,
* due_date: Carbon
* }
*/
private function buildRow(User $user, int $line, Carbon $dueDate): array
{
return [
'user_id' => (int) $user->id,
'first_name' => (string) ($user->account->first_name ?? ''),
'last_name' => (string) ($user->account->last_name ?? ''),
'email' => (string) $user->email,
'phone' => (string) ($user->account->phone ?? ''),
'mobil' => (string) ($user->account->mobil ?? ''),
'line' => $line,
'level_name' => (string) ($user->user_level?->getLang('name') ?? ''),
'due_date' => $dueDate,
];
}
}

View file

@ -0,0 +1,204 @@
<?php
namespace App\Services\Team;
use App\Models\UserAbo;
use App\Services\Abo\PaylineForecastService;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\SalesVolumePoints;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* "Die besten Ergebnisse des Monats" (N6): drei teaminterne Top-3-Ranglisten
* für das Home-Dashboard — persönliches Volumen, Neupartner, neue Abos.
*
* Basis ist die Downline des Betrachters ({@see BackofficeDashboardService::activeTeamUserIds()});
* der Betrachter selbst zählt nicht mit. Ergebnis wird 6 h gecacht
* (Muster payline_context_*, {@see PaylineForecastService}).
*/
class TopResultsService
{
/**
* Sichtbarkeits-Scope der Ranglisten. 'team' = Downline des Betrachters
* (Kundenentscheid 2026-08, teamintern). 'company' (firmenweit, EIN Ranking
* für alle) ist als spätere Option vorgesehen — dafür nur
* {@see scopedUserIds()} erweitern, die Ranglisten-Logik bleibt gleich.
*/
public const SCOPE = 'team';
private const CACHE_HOURS = 6;
private const TOP_LIMIT = 3;
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* @return array{
* top_volume: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>,
* top_recruiters: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>,
* top_new_abos: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>
* }
*/
public function monthlyTop3(User $user, int $month, int $year): array
{
$cacheKey = "top_results_{$user->id}_{$month}_{$year}";
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$teamUserIds = $this->scopedUserIds($user);
$result = [
'top_volume' => $this->rankRows($this->volumeByUserId($teamUserIds, $month, $year)),
'top_recruiters' => $this->rankRows($this->newPartnerCountBySponsorId($teamUserIds, $month, $year)),
'top_new_abos' => $this->rankRows($this->newAboCountByUserId($teamUserIds, $month, $year)),
];
Cache::put($cacheKey, $result, now()->addHours(self::CACHE_HOURS));
return $result;
}
/**
* Team-Ermittlung gekapselt für den Scope-Schalter {@see self::SCOPE}.
*
* @return int[]
*/
private function scopedUserIds(User $viewer): array
{
return $this->dashboardService->activeTeamUserIds($viewer->id);
}
/**
* Persönliches Monatsvolumen (Gesamtpunkte = eigene + Kundenpunkte) je
* Berater — eine gebatchte Query über user_sales_volumes.
*
* @param int[] $teamUserIds
* @return array<int, float> user_id => Punkte (> 0)
*/
private function volumeByUserId(array $teamUserIds, int $month, int $year): array
{
if ($teamUserIds === []) {
return [];
}
return SalesVolumePoints::totalPointsByUser($teamUserIds, $month, $year)
->filter(fn (float $points) => $points > 0)
->map(fn (float $points) => round($points, 2))
->all();
}
/**
* Direkt gesponserte Neupartner im Monat je Sponsor — Definition identisch
* zu {@see BackofficeDashboardService::newPartnersCount()} (m_level gesetzt,
* aktives Abrechnungskonto, active_date im Monat), Zuordnung über
* users.m_sponsor; nur Sponsoren aus dem Team.
*
* @param int[] $teamUserIds
* @return array<int, int> sponsor_user_id => Anzahl
*/
private function newPartnerCountBySponsorId(array $teamUserIds, int $month, int $year): array
{
if ($teamUserIds === []) {
return [];
}
return User::query()
->firstlinesOf($teamUserIds)
->newPartnerIn($month, $year)
->groupBy('m_sponsor')
->selectRaw('m_sponsor, COUNT(*) as value')
->pluck('value', 'm_sponsor')
->map(fn ($value) => (int) $value)
->all();
}
/**
* Neue aktive Abos im Monat je Berater: neue Kundenabos (member_id, is_for
* 'ot') plus neues Eigenabo (user_id, is_for 'me') — Kundenentscheid N6/F7b.
* Zählweise wie {@see BackofficeDashboardService::newAboCount()}.
*
* @param int[] $teamUserIds
* @return array<int, int> user_id => Anzahl
*/
private function newAboCountByUserId(array $teamUserIds, int $month, int $year): array
{
if ($teamUserIds === []) {
return [];
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
$newAboQuery = fn (string $userColumn, string $isFor) => UserAbo::query()
->activeAbo()
->whereBetween('start_date', [$startDate, $endDate])
->whereIn($userColumn, $teamUserIds)
->where('is_for', $isFor)
->groupBy($userColumn)
->selectRaw($userColumn.' as uid, COUNT(*) as value')
->pluck('value', 'uid');
$counts = [];
foreach ($newAboQuery('member_id', 'ot') as $userId => $value) {
$counts[(int) $userId] = ($counts[(int) $userId] ?? 0) + (int) $value;
}
foreach ($newAboQuery('user_id', 'me') as $userId => $value) {
$counts[(int) $userId] = ($counts[(int) $userId] ?? 0) + (int) $value;
}
return $counts;
}
/**
* Baut aus user_id=>Wert die Top-3-Zeilen (Foto/Name), sortiert nach Wert
* absteigend, bei Gleichstand nach Name. Es werden nur die Kandidaten
* hydriert, die die Top 3 noch erreichen können.
*
* @param array<int, float|int> $valuesByUserId
* @return array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>
*/
private function rankRows(array $valuesByUserId): array
{
$valuesByUserId = array_filter($valuesByUserId, fn ($value) => $value > 0);
if ($valuesByUserId === []) {
return [];
}
arsort($valuesByUserId);
// Kandidaten in Wert-Reihenfolge hydrieren — Zeilen ohne Account werden
// übersprungen und von Nachrückern aufgefüllt; der Puffer begrenzt die
// Hydrierung bei großen Teams.
$valuesByUserId = array_slice($valuesByUserId, 0, self::TOP_LIMIT + 22, true);
$users = User::query()
->with('account')
->whereIn('id', array_keys($valuesByUserId))
->get()
->keyBy('id');
$rows = [];
foreach ($valuesByUserId as $userId => $value) {
$user = $users->get($userId);
if (! $user || ! $user->account) {
continue;
}
$rows[] = [
'user_id' => (int) $userId,
'first_name' => (string) $user->account->first_name,
'last_name' => (string) $user->account->last_name,
'profile_image_url' => $user->account->getProfileImageUrl(),
'value' => $value,
];
}
usort($rows, fn (array $a, array $b) => [$b['value'], $a['first_name'], $a['last_name']]
<=> [$a['value'], $b['first_name'], $b['last_name']]);
return array_slice($rows, 0, self::TOP_LIMIT);
}
}

View file

@ -3,14 +3,30 @@
namespace App;
use App\Mail\MailResetPassword;
use App\Models\Account;
use App\Models\File;
use App\Models\PaymentMethod;
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserBusiness;
use App\Models\UserHistory;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\Models\UserShop;
use App\Models\UserUpdateEmail;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Mail;
use Laravel\Passport\Client;
use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
/**
* App\User
@ -23,7 +39,7 @@ use Laravel\Passport\HasApiTokens;
* @property string|null $token
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications
* @property-read DatabaseNotificationCollection|DatabaseNotification[] $notifications
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereEmail($value)
@ -47,8 +63,8 @@ use Laravel\Passport\HasApiTokens;
* @property string|null $notes
* @property string|null $last_login
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read \App\Models\Account $account
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserUpdateEmail[] $user_update_email
* @property-read Account $account
* @property-read Collection|UserUpdateEmail[] $user_update_email
*
* @method static bool|null forceDelete()
* @method static \Illuminate\Database\Query\Builder|\App\User onlyTrashed()
@ -81,7 +97,7 @@ use Laravel\Passport\HasApiTokens;
* @property string|null $payment_account
* @property string|null $payment_shop
* @property-read int|null $notifications_count
* @property-read \App\Models\UserShop $shop
* @property-read UserShop $shop
* @property-read int|null $user_update_email_count
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereBlocked($value)
@ -91,8 +107,8 @@ use Laravel\Passport\HasApiTokens;
*
* @property int|null $m_level
* @property int|null $m_sponsor
* @property-read \App\Models\UserLevel|null $user_level
* @property-read \App\User|null $user_sponsor
* @property-read UserLevel|null $user_level
* @property-read User|null $user_sponsor
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereMLevel($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereMSponsor($value)
@ -100,32 +116,32 @@ use Laravel\Passport\HasApiTokens;
* @property string|null $release_account
* @property int|null $payment_order_id
* @property int|null $abo_options
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\File[] $files
* @property-read Collection|File[] $files
* @property-read int|null $files_count
* @property-read \App\Models\Product|null $payment_order
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $shopping_orders
* @property-read Product|null $payment_order
* @property-read Collection|ShoppingOrder[] $shopping_orders
* @property-read int|null $shopping_orders_count
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereAboOptions($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentOrderId($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereReleaseAccount($value)
*
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserHistory[] $user_histories
* @property-read Collection|UserHistory[] $user_histories
* @property-read int|null $user_histories_count
* @property int|null $test_mode
* @property array|null $settings
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $member_shopping_orders
* @property-read Collection|ShoppingOrder[] $member_shopping_orders
* @property-read int|null $member_shopping_orders_count
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingUser[] $member_shopping_users
* @property-read Collection|ShoppingUser[] $member_shopping_users
* @property-read int|null $member_shopping_users_count
* @property-read \App\Models\Product|null $payment_order_product
* @property-read Product|null $payment_order_product
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereSettings($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\User whereTestMode($value)
*
* @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Client[] $clients
* @property-read Collection|Client[] $clients
* @property-read int|null $clients_count
* @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Token[] $tokens
* @property-read Collection|Token[] $tokens
* @property-read int|null $tokens_count
* @property array|null $payment_methods
*
@ -140,7 +156,7 @@ use Laravel\Passport\HasApiTokens;
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePreDeletedAt($value)
*
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserBusiness> $userBusiness
* @property-read Collection<int, UserBusiness> $userBusiness
* @property-read int|null $user_business_count
*
* @mixin \Eloquent
@ -200,6 +216,42 @@ class User extends Authenticatable
return $this->belongsTo('App\User', 'm_sponsor');
}
/**
* Firstlines (direkte Teampartner) eines oder mehrerer Sponsoren —
* die eine autoritative Definition für Dashboard-Kachel, Firstlines-Seite
* und Team-Auswertungen.
*
* @param Builder $query
* @param int|int[] $sponsorIds
* @return Builder
*/
public function scopeFirstlinesOf($query, int|array $sponsorIds)
{
return $query->whereIn('m_sponsor', (array) $sponsorIds)
->whereColumn('id', '!=', 'm_sponsor')
->whereNull('deleted_at');
}
/**
* Neupartner-Definition der Statistik (eine Regel für KPI, Recruiter-Ranking
* und Top-3-Karte): Mitgliedsebene gesetzt, Abrechnungskonto aktuell gültig,
* im Monat aktiv geworden. Achtung: `payment_account > now()` macht die
* Zählung rückwirkend kleiner, sobald Accounts ablaufen (bewusster Ist-Stand).
*
* @param Builder $query
* @return Builder
*/
public function scopeNewPartnerIn($query, int $month, int $year)
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
return $query->whereNotNull('m_level')
->whereNotNull('payment_account')
->where('payment_account', '>', now())
->whereBetween('active_date', [$startDate, $endDate]);
}
public function user_pre_sponsor()
{
return $this->belongsTo('App\User', 'pre_sponsor');