diff --git a/app/Console/Commands/IncentiveCalculate.php b/app/Console/Commands/IncentiveCalculate.php index c80b77f0..4883d09f 100644 --- a/app/Console/Commands/IncentiveCalculate.php +++ b/app/Console/Commands/IncentiveCalculate.php @@ -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(); diff --git a/app/Console/Commands/RepairSponsorBuyingPoints.php b/app/Console/Commands/RepairSponsorBuyingPoints.php new file mode 100644 index 00000000..1df18616 --- /dev/null +++ b/app/Console/Commands/RepairSponsorBuyingPoints.php @@ -0,0 +1,89 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 88f64968..0ee69b0b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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'); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 0f319e32..d7ef6bd2 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -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) diff --git a/app/Http/Controllers/ProfileImageController.php b/app/Http/Controllers/ProfileImageController.php new file mode 100644 index 00000000..661af996 --- /dev/null +++ b/app/Http/Controllers/ProfileImageController.php @@ -0,0 +1,78 @@ +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')); + } +} diff --git a/app/Http/Controllers/User/BackofficeStatisticsController.php b/app/Http/Controllers/User/BackofficeStatisticsController.php index 7b4432b1..9e3d28bc 100644 --- a/app/Http/Controllers/User/BackofficeStatisticsController.php +++ b/app/Http/Controllers/User/BackofficeStatisticsController.php @@ -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', '', '', '', '']; diff --git a/app/Http/Controllers/User/TeamController.php b/app/Http/Controllers/User/TeamController.php index 3987a233..8e8d7331 100644 --- a/app/Http/Controllers/User/TeamController.php +++ b/app/Http/Controllers/User/TeamController.php @@ -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 */ diff --git a/app/Http/Controllers/UserShopController.php b/app/Http/Controllers/UserShopController.php index 381c0aef..1fd1bcdd 100644 --- a/app/Http/Controllers/UserShopController.php +++ b/app/Http/Controllers/UserShopController.php @@ -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')); diff --git a/app/Http/Controllers/Web/CheckoutController.php b/app/Http/Controllers/Web/CheckoutController.php index df3c3404..5b493d97 100644 --- a/app/Http/Controllers/Web/CheckoutController.php +++ b/app/Http/Controllers/Web/CheckoutController.php @@ -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'), + ]); } /** diff --git a/app/Models/Incentive.php b/app/Models/Incentive.php index 11cbeb36..9bdf1b93 100644 --- a/app/Models/Incentive.php +++ b/app/Models/Incentive.php @@ -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 */ diff --git a/app/Models/IncentiveParticipant.php b/app/Models/IncentiveParticipant.php index 015294d9..0889df28 100644 --- a/app/Models/IncentiveParticipant.php +++ b/app/Models/IncentiveParticipant.php @@ -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) diff --git a/app/Models/UserAccount.php b/app/Models/UserAccount.php index 40d0c7b5..beea330f 100644 --- a/app/Models/UserAccount.php +++ b/app/Models/UserAccount.php @@ -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. * diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 90c49fab..2ff86724 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } diff --git a/app/Repositories/CheckoutRepository.php b/app/Repositories/CheckoutRepository.php index fa141f0e..528b28a8 100644 --- a/app/Repositories/CheckoutRepository.php +++ b/app/Repositories/CheckoutRepository.php @@ -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; } } diff --git a/app/Services/Abo/PaylineForecastService.php b/app/Services/Abo/PaylineForecastService.php index 742b2e5d..b0359333 100644 --- a/app/Services/Abo/PaylineForecastService.php +++ b/app/Services/Abo/PaylineForecastService.php @@ -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 + */ + 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 $lineByUserId + * @return array{personal: float, by_line: array} + */ + 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 $lineByUserId + * @return array + */ + 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 $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] */ diff --git a/app/Services/Abo/TeamAboOverviewService.php b/app/Services/Abo/TeamAboOverviewService.php index 309d84d2..da2cfd1f 100644 --- a/app/Services/Abo/TeamAboOverviewService.php +++ b/app/Services/Abo/TeamAboOverviewService.php @@ -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 diff --git a/app/Services/Backoffice/BackofficeDashboardService.php b/app/Services/Backoffice/BackofficeDashboardService.php index e1c244a6..f51f84b8 100644 --- a/app/Services/Backoffice/BackofficeDashboardService.php +++ b/app/Services/Backoffice/BackofficeDashboardService.php @@ -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>> + */ + private array $lineBucketsMemo = []; + /** * @return array */ @@ -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> $lineBuckets + * @return array}>}> + */ + 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 + */ + private function totalPointsByUser(array $userIds, int $month, int $year): Collection + { + return SalesVolumePoints::totalPointsByUser($userIds, $month, $year); + } + /** * Aktueller Bestand aktiver Kundenabos je Berater (member_id). * diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php index 5830cb6a..176a6904 100644 --- a/app/Services/Backoffice/BackofficeDrilldownService.php +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -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 + * @return Collection */ - 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, + ]); } /** diff --git a/app/Services/Backoffice/SalesVolumePoints.php b/app/Services/Backoffice/SalesVolumePoints.php index 00f35df1..0c3e1463 100644 --- a/app/Services/Backoffice/SalesVolumePoints.php +++ b/app/Services/Backoffice/SalesVolumePoints.php @@ -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 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. diff --git a/app/Services/BusinessPlan/LiveCommissionService.php b/app/Services/BusinessPlan/LiveCommissionService.php index e05bcc9a..6fa1d1fe 100644 --- a/app/Services/BusinessPlan/LiveCommissionService.php +++ b/app/Services/BusinessPlan/LiveCommissionService.php @@ -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|null, next_level: array|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|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|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|null $qualLevel + * @param array|null $nextLevel + * @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool, qual_level: array|null, next_level: array|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), ]; } } diff --git a/app/Services/BusinessPlan/SalesPointsVolume.php b/app/Services/BusinessPlan/SalesPointsVolume.php index ae1094d7..9c9110e1 100644 --- a/app/Services/BusinessPlan/SalesPointsVolume.php +++ b/app/Services/BusinessPlan/SalesPointsVolume.php @@ -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, diff --git a/app/Services/GdprDeletionService.php b/app/Services/GdprDeletionService.php index 885de012..21173210 100644 --- a/app/Services/GdprDeletionService.php +++ b/app/Services/GdprDeletionService.php @@ -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(); } diff --git a/app/Services/HTMLHelper.php b/app/Services/HTMLHelper.php index 64508758..7c12fef0 100644 --- a/app/Services/HTMLHelper.php +++ b/app/Services/HTMLHelper.php @@ -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 .= '\n'; foreach ($values as $key => $value) { diff --git a/app/Services/Incentive/IncentivePointsLogRepairService.php b/app/Services/Incentive/IncentivePointsLogRepairService.php index 6a85623c..a87265d0 100644 --- a/app/Services/Incentive/IncentivePointsLogRepairService.php +++ b/app/Services/Incentive/IncentivePointsLogRepairService.php @@ -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; diff --git a/app/Services/Incentive/IncentiveTracker.php b/app/Services/Incentive/IncentiveTracker.php index a640a8b1..3b638dc7 100644 --- a/app/Services/Incentive/IncentiveTracker.php +++ b/app/Services/Incentive/IncentiveTracker.php @@ -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) { diff --git a/app/Services/Payment.php b/app/Services/Payment.php index b22892a2..9c5fc3f5 100644 --- a/app/Services/Payment.php +++ b/app/Services/Payment.php @@ -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; diff --git a/app/Services/Slim.php b/app/Services/Slim.php index 10b281b9..ac7ec52b 100644 --- a/app/Services/Slim.php +++ b/app/Services/Slim.php @@ -1,14 +1,47 @@ '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)); } - -} \ No newline at end of file +} diff --git a/app/Services/SponsorBuyingPointsRepairService.php b/app/Services/SponsorBuyingPointsRepairService.php new file mode 100644 index 00000000..63b4cee1 --- /dev/null +++ b/app/Services/SponsorBuyingPointsRepairService.php @@ -0,0 +1,337 @@ +, rebooked: array, created: array} + */ + 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 $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 $orderIds + * @param Collection $candidateInfos + * @return array{0: array, 1: array>} + */ + 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]; + } +} diff --git a/app/Services/Team/AnnualFeeDueService.php b/app/Services/Team/AnnualFeeDueService.php new file mode 100644 index 00000000..33ab26b1 --- /dev/null +++ b/app/Services/Team/AnnualFeeDueService.php @@ -0,0 +1,126 @@ + 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> + */ + 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, + ]; + } +} diff --git a/app/Services/Team/TopResultsService.php b/app/Services/Team/TopResultsService.php new file mode 100644 index 00000000..0ceb10ee --- /dev/null +++ b/app/Services/Team/TopResultsService.php @@ -0,0 +1,204 @@ +, + * top_recruiters: array, + * top_new_abos: array + * } + */ + 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 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 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 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 $valuesByUserId + * @return array + */ + 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); + } +} diff --git a/app/User.php b/app/User.php index 8f917ad2..69151801 100644 --- a/app/User.php +++ b/app/User.php @@ -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|User wherePreDeletedAt($value) * - * @property-read \Illuminate\Database\Eloquent\Collection $userBusiness + * @property-read Collection $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'); diff --git a/config/mivita.php b/config/mivita.php index 213041b0..dc33a8f2 100755 --- a/config/mivita.php +++ b/config/mivita.php @@ -8,4 +8,9 @@ return [ 'remind_last_days' => env('MIVITA_REMIND_LAST_DAYS', '2'), 'edit_data_pass' => env('MIVITA_EDIT_DATA_PASS', 'iq1OeUUpTUaXwNUdh8xHmx7RXTif4='), 'add_number_id' => env('MIVITA_ADD_NUMBER_ID', '946'), + + // Firmenaccount (mivita care gmbh): Registrierungen, die hier statt beim + // Berater landen, sind User-Fehler über die Hauptwebseite — VP-Punkte + // stehen immer dem Berater zu (Kundenentscheid 2026-08-19). + 'company_user_id' => (int) env('MIVITA_COMPANY_USER_ID', 6), ]; diff --git a/database/migrations/2026_08_21_100000_add_profile_image_to_user_accounts_table.php b/database/migrations/2026_08_21_100000_add_profile_image_to_user_accounts_table.php new file mode 100644 index 00000000..2c3dd99e --- /dev/null +++ b/database/migrations/2026_08_21_100000_add_profile_image_to_user_accounts_table.php @@ -0,0 +1,31 @@ +string('profile_image')->nullable()->after('instagram'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_accounts', function (Blueprint $table) { + $table->dropColumn('profile_image'); + }); + } +}; diff --git a/dev/2026-07-22/Statistik-Aenderungen-19-08-2026.pdf b/dev/2026-07-22/Statistik-Aenderungen-19-08-2026.pdf new file mode 100644 index 00000000..134f2796 Binary files /dev/null and b/dev/2026-07-22/Statistik-Aenderungen-19-08-2026.pdf differ diff --git a/dev/2026-07-22/entwicklungsplan-2026-08-19.md b/dev/2026-07-22/entwicklungsplan-2026-08-19.md new file mode 100644 index 00000000..05ae8367 --- /dev/null +++ b/dev/2026-07-22/entwicklungsplan-2026-08-19.md @@ -0,0 +1,190 @@ +# Entwicklungsplan — Statistik-Änderungen vom 2026-08-19 + +Basis: `dev/2026-07-22/Statistik-Aenderungen-19-08-2026.pdf` (Kundin, Folge-Feedback zur Umsetzung vom 2026-07-24). +Ist-Stand-Analyse: 2026-08-19. + +> **Umsetzungsstand (2026-08-19): alle 8 Pakete gemäß den beantworteten F-Fragen umgesetzt** — Details je Paket im Abschnitt „Umgesetzt". Zusammenfassung: +> - **N7** ✅ USt-Optionen getauscht (Werte unverändert), Text „§ 19 UStG" repariert (de/fr). +> - **N5** ✅ Checkout: Dropdown raus, Freitext Pflichtfeld; Altdaten/Marker kompatibel; Admin-Fallback-Kette. Tests: `CheckoutOrderSourceValidationTest`. +> - **N8** ✅ Neue Seite „Jahresgebühr" für **jeden Berater** (F8: eigenes Team): `AnnualFeeDueService` + Route `user_team_annual_fee` + Sidenav-Eintrag, Gruppen überfällig/≤1/≤2/≤3 Monate. Tests: `AnnualFeeDueServiceTest`. +> - **N1** ✅ Monatsstatistik: neues „Team"-Cluster (Firstlines klickbar → neue Seite `user_team_firstlines`, Teampartner gesamt [nur aktive, F1], Teampartner Payline [aktive, konsistent], Abos Payline mit „+N neu") + Level-Block (Quali-Stufe des Monats, fehlende Payline-/persönliche Punkte, Karriere-Level als Zusatz, F2). Basis: `LiveCommissionService`-Snapshot v2 reicht jetzt `qual_level`/`next_level`/`payline_points_qual_kp`/`personal_points` durch (Cache-Key `live_business_v2_*`). +> - **N2** ✅ Abo-Kacheln monatsbezogen: `TeamAboOverviewService::aboStockCount()` rechnet für vergangene Monate den Bestand zum Monatsende zurück (start/cancel/deleted + Status-Historie); laufender Monat bleibt live; VIP-Statistik bleibt tagesaktuell (F3). Näherung vor 07/2026 dokumentiert. +> - **N3** ✅ „Recruiter des Monats" auf der VIP-Statistik (Neupartner-Definition wie KPI, F4a) mit rekursivem Ketten-Volumen als **Gesamtpunkte** (Starterpaket zählt damit); aufklappbare Detailzeilen; Neupartner-Drilldown + CSV zusätzlich mit Spalte „Punkte gesamt" (F4b). +> - **N4** ✅ Prognose-Block „Level & Provision laut Prognose (Stand 25.)" mit „unverbindlich"-Kennzeichnung (F5): `PaylineForecastService::levelAndCommissionForecast()` — Ebenen-aufgelöste offene Abo-Punkte + heutige TP-Laufstände, Level-Erreichung inkl. persönlichem Volumen, Provisionsschätzung mit den Sätzen des laut Prognose erreichten Levels (Shop/WB fortgeschrieben). +> - **N6** ✅ Profilfoto (`user_accounts.profile_image`, Upload mit Einwilligungs-Checkbox im Profil, DSGVO-Löschung integriert; Einwilligungstext als Sprachkey-Platzhalter — **finalen Text liefert die Kundin nach**, F7c) + Karte „Die besten Ergebnisse des Monats" **teamintern** (F7a) auf dem Home-Dashboard: Top 3 persönliches Volumen / Neupartner / neue Abos (Kundenabos + Eigenabo, F7b) mit Foto bzw. Initialen-Avatar, 6-h-Cache, Scope-Schalter (`TopResultsService::SCOPE`) für eine spätere firmenweite Variante. Tests: `TopResultsServiceTest`. + +## Wie ich das Briefing verstehe + +Das Feedback betrifft diesmal überwiegend die **Monatsstatistik-Card auf dem Home-Dashboard** (für alle Berater), dazu drei Punkte außerhalb der Statistik (Checkout-Herkunftsfeld, Steuerdaten-Formular, Jahresgebühr-Nachfassliste). Es zerfällt in 8 Pakete: + +| # | Paket | Bereich | Einstufung | Aufwand | +|---|---|---|---|---| +| N1 | Monatsstatistik: 7 neue Kennzahlen (Firstlines klickbar, Teampartner, Payline-Zahlen, Level) | Home | Erweiterung | mittel | +| N2 | Abo-Kacheln beim Monatswechsel monatsbezogen statt eingefroren | Home | Verhaltensänderung | mittel | +| N3 | „Recruiter des Monats" — Neupartner-Ranking je Sponsor inkl. Volumen + Starterpaket | VIP-Statistik | neue Liste + Zählweise-Fix | mittel | +| N4 | Prognose: „fehlen zum nächsten Level X Punkte" + voraussichtliche Provision | Home | Erweiterung, **komplexester Punkt** | groß | +| N5 | Checkout-Herkunft: Dropdown raus, nur Freitext | Shop-Checkout | kleine Änderung | klein | +| N6 | Teampartner-Foto + „Die besten Ergebnisse des Monats" (3× Top 3) | Home + Profil | neues Feature | mittel–groß | +| N7 | Steuerdaten: Reihenfolge der USt-Optionen tauschen | Profil/Wizard | Mini-Änderung | sehr klein | +| N8 | Jahresgebühr-Nachfassliste (fällig in 3/2/1 Monaten + überfällig) | VIP-Statistik | neue Liste | klein–mittel | + +Empfohlene Reihenfolge: **N7 → N5 → N8 → N1 → N3 → N2 → N6 → N4** (Quick-Wins zuerst; N4 zuletzt, weil er auf N1-Bausteinen aufsetzt und die meisten Klärungen braucht). + +--- + +## N1 — Monatsstatistik: neue Kennzahlen + +**Briefing:** Übersichtsliste ergänzen um: Firstlines (Anzahl, klickbar → Liste mit Umsatz, Telefon, Mail), Teampartner insgesamt, Teampartner in der Payline, Abos insgesamt Payline, Neue Abos in der Payline, aktuelles Level, „zum nächsten Level fehlen … Punkte" (+ ggf. fehlendes persönliches Volumen und was die Provision dann wäre). + +**Ist-Stand (gut vorbereitet):** +- Firstline-IDs werden im [HomeController.php:127-132](app/Http/Controllers/HomeController.php#L127-L132) bereits berechnet (für „Direkte Neupartner"), die Anzahl wird aber nicht angezeigt. +- Teampartner gesamt: `BackofficeDashboardService::teamUserIds()` / `activeTeamUserIds()` vorhanden. +- Payline-Mitglieder + Abo-Zähler: `PaylineForecastService::aboTurnover()` liefert seit Juli `members`, `team_abo_count`, `team_customer_abo_count` — wird vom Home-Dashboard **nur nicht aufgerufen** (nur die Team-Abo-Seite nutzt es). +- „Neue Abos in der Payline": existiert nicht, aber trivial — `newAboCount()`-Logik auf die Payline-Mitglieder-IDs einschränken (Eigen-Abos über `user_id`, Kundenabos über `member_id`, `start_date` im Monat). +- Level: `LiveCommissionService::snapshot()` berechnet die Quali-Daten (`qual_user_level`, `next_qual/next_can_user_level` mit `_calculated_*`-Feldern) bereits im Live-Lauf, **wirft sie aber weg** — nur `paylines` wird durchgereicht ([LiveCommissionService.php:117-128](app/Services/BusinessPlan/LiveCommissionService.php#L117-L128)). Eine „Punkte bis zum nächsten Level"-Differenz wird heute nirgends berechnet (auch der Marketingplan zeigt nur die statischen Anforderungen). + +**Umsetzungsvorschlag:** +1. Neues KPI-Cluster „Team" auf der Monatsstatistik-Card: Firstlines · Teampartner gesamt · Teampartner Payline · Abos Payline (Teamabos + Kundenabos) · „+N neue Abos Payline" — alle Werte aus vorhandenen Services, gecacht im bestehenden 6-h-Muster. +2. **Firstlines klickbar:** neue leichte Liste (Modal oder eigene Seite unter `user/team`) für **alle** Berater — Spalten: Name, Karriere-Level, Umsatz (Monats-Gesamtpunkte), Telefon, E-Mail, aktiv seit / Account-Status. Die VIP-Drilldown-Infrastruktur ist VIP-only; für Firstlines (eigene direkte Partner) ist eine eigene, nicht-VIP-Route unbedenklich. +3. **Level-Kachel:** „Aktuelles Level (Monat): BRONZE — zum nächsten Level SILBER fehlen X Payline-Punkte" + falls die persönliche Anforderung (`qual_kp`) noch nicht erfüllt ist: „dafür fehlen außerdem Y Punkte persönliches Volumen". Rechnung aus dem Snapshot: `nextLevel.qual_pp − payline_points_qual_kp` bzw. `nextLevel.qual_kp − sales_volume_points_KP_sum`. Dazu Anzeige der Provisionssätze des nächsten Levels („dann z. B. Ebene 1–3 je 7 %") — die exakte „was wäre die Provision"-Rechnung gehört zu N4. +4. Voraussetzung: `LiveCommissionService::buildSnapshot()` um die Level-/Punktefelder erweitern (kein zweiter Baum-Lauf, nur Durchreichen). + +**Offene Fragen:** F1 (aktive vs. alle Teampartner), F2 (welches „Level" gemeint ist — siehe unten). + +## N2 — Abo-Kacheln beim Monatswechsel monatsbezogen + +**Briefing:** Beim Monatswechsel auf der Monatsstatistik passen sich die Kennzahlen an, die 4 Abo-Kacheln (Eigene Abos, Eigene Kundenabos, Teamabos, Team-Kundenabos) aber nicht — sie sollen die Zahlen des gewählten Monats zeigen. + +**Befund bestätigt:** `TeamAboOverviewService::teamKpis()`/`personalAboKpis()` zählen den **heutigen** `activeAbo`-Bestand; `month`/`year` fließen nur in die „+N neu"-Badges. Auch die Team-Mitgliederbasis ist der heutige Strukturbaum. Es gibt keinerlei Home-Snapshot. + +**Wichtiger Kontext:** Für die **VIP-Statistik** wurde 2026-07 das **Gegenteil** entschieden (F7: Abo-Bestände immer tagesaktuell, Snapshot wird live überlagert). Der neue Wunsch gilt dem Home-Dashboard — das ist kein Widerspruch, sollte aber bewusst entschieden werden (→ F3). + +**Umsetzungsvorschlag (Variante A, empfohlen): Rückrechnung über Abo-Daten** — Bestand zum Monatsende M: +`start_date <= Ende(M)` UND nicht vor Ende(M) beendet (`cancel_date`/`deleted_at`/`user_abo_status_histories` nach Ende(M) oder Abo heute aktiv). Das ist ohne neue Tabellen möglich und für die Vergangenheit eine gute Näherung; seit Juli 2026 protokolliert `user_abo_status_histories` Statuswechsel, wodurch die Rückrechnung ab da exakt wird. Einschränkung dokumentieren: Für Monate vor Juli 2026 können Deaktivierungen ohne `cancel_date` fehlen (Zahlen dann leicht zu hoch). +**Variante B (verworfen):** monatliche Bestands-Snapshots je User — für ALLE Berater (Home ist nicht VIP-only) unverhältnismäßig teuer. +Umsetzung: `teamKpis()`/`personalAboKpis()` bekommen einen „Stichtag"-Modus (laufender Monat = live wie bisher; vergangener Monat = Rückrechnung zum Monatsende). Struktur (Team-Mitglieder) bleibt pragmatisch die heutige. + +## N3 — „Recruiter des Monats" (VIP-Statistik) + +**Briefing:** Neben „Neue Kundenabos" eine Liste der neuen Teampartner je Sponsor, sortiert nach Anzahl: `Name | Anzahl gesponserte Neupartner | neues Volumen`. Klick auf die Anzahl → wer wurde gesponsert + dessen aktueller Umsatz; hat der Neue selbst schon Neupartner geworben, zählt deren Umsatz dazu. **Und: die Starterpaket-Punkte zählen zum Volumen — „werden jetzt noch nicht gezählt".** + +**Ist-Stand / Aufklärung Starterpaket:** +- Ein Sponsor-Ranking existiert nicht; Blaupause ist `newCustomerAboRanking()`. +- Die Starterpaket-Punkte des Neupartners **werden** systemisch verbucht (Status 1, voll umsatz- und payline-wirksam, 200/400 P. je nach Kit). Was die Kundin vermutlich sieht: Die Spalte „Kundenpunkte" im Neupartner-Drilldown nutzt `externalPoints()` (nur Shop-/Kundenumsatz, Status 2) — die **Eigenbestellung Starterpaket (Status 1) fällt dort raus**. → Fix: im Drilldown und im neuen Ranking „Volumen" als **Gesamtpunkte** (eigene + Kundenpunkte, `totalPoints()`-Logik) rechnen, dann ist das Starterpaket automatisch drin. +- Zwei konkurrierende Neupartner-Definitionen im System: Statistik (`active_date` im Monat + aktiver Account) vs. Incentive (bezahlte Starterpaket-Bestellung, `created_at`). → F4. + +**Umsetzungsvorschlag:** +1. Neue Karte „Recruiter des Monats" auf der VIP-Statistik (Scope: eigenes Team, inkl. „ich" wie bei den anderen Listen): je Sponsor Anzahl Neupartner des Monats + Volumen = Summe der Monats-**Gesamtpunkte** aller seiner Neupartner, **plus rekursiv** die Gesamtpunkte der von diesen Neupartnern im selben Monat geworbenen weiteren Neupartner (Kettenlogik wie im Beispiel der Kundin). +2. Drilldown (Klick auf Anzahl): Liste der geworbenen Personen mit Monats-Gesamtpunkten, darunter eingerückt deren eigene Neupartner mit Punkten (eine Rekursionsebene sichtbar, Summe vollständig rekursiv). +3. Nebenbei: Neupartner-Drilldown-Spalte „Kundenpunkte" um „Punkte gesamt (inkl. Starterpaket)" ergänzen oder ersetzen (→ F4b). +4. Landet mit im Monats-Snapshot (monatsbezogene Liste, kein Live-Overlay nötig). + +## N4 — Prognose: nächstes Level + voraussichtliche Provision + +**Briefing:** Bei der Prognose ein Feld „laut Prognose fehlen zum nächsten Level (z. B. Bronze Member) noch 2300 Punkte" und „voraussichtliche Provision laut Prognose: 1200 € (sofern das persönliche Volumen von Y erreicht wird)". + +**Ist-Stand:** Die Prognose-Kacheln rechnen `basePoints (Kundenumsatz + Payline-Punkte heute) + offene Abo-Punkte bis Termin` — als **eine Gesamtzahl**, ohne Linien-/Ebenen-Zuordnung. Die Provisionsrechnung braucht aber Punkte **pro Ebene** (Sätze `pr_line_1..8` je Level) und die Level-Qualifikation braucht die Trennung „persönliches Volumen (qual_kp)" vs. „Payline-Punkte (qual_pp)". + +**Umsetzungsvorschlag (zweistufig):** +1. **Stufe 1 — Level-Prognose (solide machbar):** Die offenen Abo-Ausführungen sind Payline-Mitgliedern zuordenbar (Ebene bekannt aus `lineBuckets`), eigene/eigene Kundenabos zählen aufs persönliche Volumen. Damit: prognostizierte `payline_points_qual_kp` und prognostiziertes persönliches Volumen zum 25. → daraus „laut Prognose fehlen zum nächsten Level X noch N Punkte" bzw. „nächstes Level wird laut Prognose erreicht, sofern das persönliche Volumen von Y P. erreicht wird". +2. **Stufe 2 — Provisions-Prognose (Näherung, klar kennzeichnen):** prognostizierte Punkte je Ebene × Provisionssätze des laut Prognose erreichten Levels (+ ggf. Wachstumsbonus ausklammern oder mit heutigem Wert fortschreiben). Das ist eine **Schätzung** — Rangverschiebungen im Team, 60%-Regeln etc. bleiben außen vor. Im UI als „voraussichtlich, unverbindlich" kennzeichnen (wie die bestehenden Live-Badges). +3. Technisch: Erweiterung von `PaylineForecastService` (Ebenen-aufgelöste `upcoming`-Punkte) + `LiveCommissionService` (Level-Daten durchreichen, s. N1.4). Kein neuer Baum-Lauf. + +**Risiko/Empfehlung:** größter Einzelaufwand des Pakets; erst nach N1 umsetzen und die Genauigkeitserwartung vorher mit der Kundin abstimmen (→ F5). + +## N5 — Checkout-Herkunft: nur noch Freitext + +**Briefing:** Statt Dropdown („Sonstiges/Event-Messe sagt uns nichts") nur das Freitextfeld stehen lassen, damit Kunden den konkreten Namen/die konkrete Messe eintragen. + +**Ist-Stand:** Kunden-Checkout hat Dropdown `customer_order_source` (7 Optionen, Pflicht) **plus optionales Freitextfeld** `customer_order_source_comment` — beides an `shopping_orders` ([checkout.blade.php:556-578](resources/views/web/templates/checkout.blade.php#L556-L578), Optionen in [ShoppingOrder.php:192-200](app/Models/ShoppingOrder.php#L192-L200)). Es gibt **keine** Auswertung/Statistik dazu; Admin zeigt den Wert nur in der Bestell-Detailansicht. Achtung: `CreateAboReplacementOrder` nutzt das Comment-Feld als technischen Marker — Spalten dürfen nicht entfallen. + +**Umsetzungsvorschlag:** Dropdown aus dem Checkout entfernen, Freitextfeld wird das (einzige) Herkunftsfeld — als Pflichtfeld mit sprechendem Label („Wie bist du auf uns aufmerksam geworden? Bitte konkret: Name der Person, Messe, …"). DB-seitig: `customer_order_source` bleibt bestehen (Bestandsdaten + Marker), neue Bestellungen speichern nur noch den Freitext. Admin-Detailansicht zeigt künftig den Freitext. Aufwand klein. → F6 (Pflichtfeld ja/nein). + +## N6 — Teampartner-Foto + „Die besten Ergebnisse des Monats" + +**Briefing:** (a) Feld, in dem jeder Teampartner ein Foto hochlädt; (b) auf der Startseite „Die besten Ergebnisse des Monats": Top 3 persönliches Volumen, Top 3 meiste Neupartner, Top 3 meiste neue Abos — je mit Foto + Name + Wert. Teamintern oder firmenintern ist **laut Kundin noch offen**. + +**Ist-Stand:** Es gibt **kein** Berater-Profilbild. Das einzige Foto ist das Shop-Bild (`user_shops.filename`, Slim-Cropper-Upload, nur mit eigenem Shop, Anzeige nur im öffentlichen Shop). Ranglisten-Datenquellen existieren teilweise (Neupartner je Sponsor → N3; neue Kundenabos je Berater → vorhandene Liste; persönliches Monatsvolumen je User → `user_sales_volumes`-Aggregat, firmenweit bisher nirgends). + +**Umsetzungsvorschlag:** +1. **Profilfoto** als eigenes Feature (unabhängig vom Shop): Spalte an `user_accounts` (oder kleine Tabelle), Upload im Profil (`user_form`/Wizard) mit dem vorhandenen Cropper-Muster aus `UserShopController`, Fallback Initialen-Avatar, DSGVO: Aufnahme in `GdprDeletionService` + Hinweis, dass das Foto team-/firmenintern angezeigt wird (Einwilligung beim Upload). +2. **Top-3-Karte** auf dem Home-Dashboard: drei Spalten (Volumen / Neupartner / neue Abos), je Top 3 mit Foto, Vor-/Nachname, Wert. Datenlogik: persönliches Volumen = Monats-Gesamtpunkte je Berater; Neupartner = N3-Zählung; neue Abos = neue Kundenabos + neues Eigenabo je Berater (→ F7b). Nächtliche Berechnung/Cache (firmenweit ein Ranking für alle) statt Live-Query pro Aufruf. +3. **Scope-Schalter teamintern/firmenintern** von Anfang an vorsehen (eine Konstante/Setting), Start laut Rückfrage-Ergebnis (→ F7a). + +## N7 — Steuerdaten: Reihenfolge der USt-Optionen + +**Briefing:** „nicht umsatzsteuerpflichtig" soll an erster Stelle stehen. + +**Ist-Stand:** Hardcodiertes Array in [HTMLHelper.php:399-411](app/Services/HTMLHelper.php#L399-L411) (`1 => umsatzsteuerpflichtig, 2 => nicht umsatzsteuerpflichtig`), gespeichert als `user_accounts.taxable_sales`. Reihenfolge tauschen = Array-Reihenfolge ändern, **Werte bleiben unverändert** (1/2 werden downstream von Gutschriften/DATEV interpretiert, Wert 3 wird intern für Ausland gesetzt — nicht anfassen). + +**Mitzunehmende Nebenbefunde:** Der Text `taxable_sales_2` ist abgeschnitten und nutzt `$` statt `§` („Kleinunternehmer im Sinne von $ 19") → korrigieren auf „… im Sinne von § 19 UStG)". Aufwand: Minuten. Keine offenen Fragen. + +## N8 — Jahresgebühr-Nachfassliste + +**Briefing:** Liste aller Teampartner (komplettes Team), bei denen die Jahresgebühr in 3/2/1 Monaten fällig oder überfällig ist — Spalten Vorname, Nachname, Mail, Telefon, Ebene, Level — „damit wir nachfassen können". + +**Ist-Stand:** `users.payment_account` = „bezahlt bis"; Verlängerung +1 Jahr bei Zahlung; Erinnerungs-Mails macht der Cron `payments:check-accounts` (21/14/2 Tage vorher), 2 Wochen nach Ablauf deaktiviert `user:cleanup`, nach 2 Monaten wird konvertiert/gelöscht. **Einen „läuft bald ab"-Report gibt es weder für Admin noch Berater** — nur binäre aktiv/inaktiv-Filter in der Team-Liste. + +**Umsetzungsvorschlag:** Neue Karte/Unterseite auf der VIP-Statistik „Jahresgebühr ausstehend": komplettes Team, gruppiert in **überfällig / fällig ≤ 1 Monat / ≤ 2 Monate / ≤ 3 Monate** (aus `payment_account`), Spalten Vorname, Nachname, Mail, Telefon (aus `user_accounts`), Ebene (Linie aus `lineBuckets`), Karriere-Level, Fälligkeitsdatum; CSV-Export wie bei den anderen Drilldowns. Wichtig: Anzeige „überfällig" nur bis zur Cleanup-Grenze (nach 2 Monaten verschwinden die User systemisch) — mit Hinweis darauf. → F8 (nur VIP oder jeder Berater fürs eigene Team?). + +--- + +## Offene Fragen an die Kundin (vor bzw. während der Umsetzung) + +| # | Frage | Empfehlung | +|---|---|---| +| F1 | N1: „Teampartner insgesamt" — nur aktive Berater (wie die „Berater"-Spalte der Statistik) oder alle inkl. inaktiver? | aktive | +nur Aktive +| F2 | N1/N4: „Aktuelles Level" — die monatliche Qualifikationsstufe (bestimmt Payline/Provision, dazu passt „es fehlen X Punkte") oder das Karriere-Level (steht bereits in der Mitgliedschafts-Card)? | Quali-Stufe des Monats, mit Karriere-Level als Zusatzinfo | +JA +| F3 | N2: Home zeigt künftig Monats-Bestand (Rückrechnung; vor 07/2026 Näherung) — VIP-Statistik bleibt wie 07/2026 entschieden tagesaktuell? | ja, so lassen; sonst in einem Zug mit umstellen | +JA +| F4 | N3: Neupartner-Definition fürs Ranking — wie die Statistik-KPI (`aktiv geworden im Monat`) oder wie das Incentive (bezahltes Starterpaket im Monat)? (b) Neupartner-Drilldown: Spalte „Kundenpunkte" ersetzen durch „Punkte gesamt" oder beide zeigen? | (a) wie Statistik-KPI, damit Zahlen zusammenpassen; (b) beide | +a Wie KPI +| F5 | N4: Provisions-Prognose ist eine gekennzeichnete Schätzung (Rangverschiebungen/Boni nicht exakt) — reicht das? | ja, mit „unverbindlich"-Kennzeichnung | +Ja mit Kennzeichnung +| F6 | N5: Freitext-Herkunft als Pflichtfeld? | ja, Pflicht mit Beispiel-Platzhalter | +Ja +| F7 | N6: (a) Top 3 teamintern oder firmenintern? (b) „meiste neue Abos" = neue Kundenabos (+ Eigenabo) je Berater? (c) Foto-Einwilligungstext | (a) firmenintern hinter Schalter, (b) neue Kundenabos + Eigenabo, (c) Einwilligung beim Upload | +a teamintern +b neue Kundenabos + Eigenabo +c Einwilligung beim Upload - lagern wird aber nach +| F8 | N8: Jahresgebühr-Liste nur VIP-Statistik oder für jeden Berater (eigenes Team)? | zunächst VIP | +eigenes team + + +## Kontroll-Review (2026-08-19, /code-review high mit Fix-Anwendung) + +Nach der Umsetzung lief eine mehrstufige Review (8 Finder-Perspektiven) über den gesamten Diff. **Angewendete Fixes:** + +**Korrektheit:** +- **Level-Kachel/Prognose zeigten für Normalfälle „Höchstes Level erreicht"**: `qual_user_level_next` ist im Steady-State (Quali == Karriere-Level) bewusst null — `LiveCommissionService::resolveNextLevel()` fällt jetzt auf `next_qual_user_level`/`next_can_user_level` (direkt nächster Karriere-Level, inkl. `_calculated_*`) zurück. Verifiziert an Steady-State-User (Berater → Aktiv Berater). +- **„fehlen X Punkte" bei mehr Paylines im nächsten Level überhöht**: Basis ist jetzt `_calculated_payline_points_qual_kp` des NÄCHSTEN Levels (rechnet mit dessen Paylines/KP-Überschuss). +- **Historischer Abo-Bestand (N2) konnte deaktivierte Abos „wiederbeleben"**, wenn ein späteres Ereignis (z. B. Löschung) nach dem Stichtag lag: die Rückrechnung prüft jetzt zusätzlich, ob das letzte Statusereignis bis zum Stichtag deaktivierend war (inkl. Reaktivierungs-Logik). Test erweitert. +- **Top-3-Karte schrumpfte**, wenn ein Top-Kandidat keinen Account-Datensatz hat: Nachrücker füllen jetzt auf. +- **Payline-Abo-Kachel widersprach beim Monatswechsel den N2-Kacheln** (live vs. Monatsbestand): zählt jetzt ebenfalls monatsbezogen über `aboStockCount()`. +- Checkout-Pflichtfeld-Meldung (N5) war hardcodiert deutsch → Sprachkey `order.source_comment_required` (de/en/es/fr). + +**Performance:** +- `lineBuckets()`-Baum-Traversierung lief pro Dashboard-Request 3–5×: jetzt Singleton + Prozess-Memo (`flushLineBucketsMemo()` für Batch/Struktur-Änderungen; `storeSnapshot()` flusht selbst). +- `levelAndCommissionForecast()` war als einzige neue Kennzahl ungecacht → 6-h-Cache (`level_forecast_*`). +- Snapshot-Cache-Key auf `live_business_v3_*` (Payload-Format geändert). + +**Wiederverwendung/Konventionen:** +- Neue User-Scopes `firstlinesOf()` und `newPartnerIn()` — EINE Definition für Firstlines (Kachel, Seite, Top 3) und Neupartner (KPI, Recruiter-Ranking, Top 3) statt 3–4 Kopien. +- `SalesVolumePoints::totalPointsByUser()` als gemeinsamer Batch-Helper (Firstlines-Seite, Recruiter-Volumen, Top-3-Volumen). +- Slim-Cropper-Speicherlogik nach `Slim::storeCroppedImage()` extrahiert (Profilfoto + beide Shop-Uploads nutzen sie). +- `activeAbo`-Scope statt handgeschriebener Prädikate (Payline-Zähler, Top 3, N2-Rückrechnung); `formatDate()`/`getFullName(false)` statt Inline-Kopien; Firstlines-Filter auf `Form::open` (Projektregel); Return-Types ergänzt. +- Neue Tests: `TeamFirstlinesPageTest`, `ProfileImageTest` (die einzigen bis dahin ungetesteten neuen Endpunkte). + +**Nachtrag (gleicher Tag, vom Kunden freigegeben) — die drei gemeldeten Punkte sind umgesetzt:** +1. ✅ **VP-Nachtreparatur gehärtet:** Der Live-Pfad (`Payment::addSponsorBuyingPoints` → `SalesPointsVolume::addSalesPointsVolume`) schreibt jetzt die `shopping_order_id` mit; der Repair dedupliziert primär darüber (immun gegen Umbenennungen), Alt-Zeilen ohne Referenz werden **zählend** je info-Text verbraucht (zwei gleiche Bestellungen ≠ eine). Scan-Fenster umfasst den **Vormonat** (Monatswechsel-Fälle heilen automatisch, Buchung weiterhin nur in den Zielmonat). Dedup ist **sponsor-unabhängig** mit zwei Fällen (Kundenentscheid 2026-08-19): Liegt die Buchung beim **Firmenaccount** (`config('mivita.company_user_id')`, User 6 — Registrierung über die Hauptwebseite ist ein User-Fehler, Punkte stehen immer dem Berater zu), wird **automatisch umgebucht** — im Zielmonat in place (eine Zeile, Syslog-Spur), aus einem abgeschlossenen Monat per neuer Buchung beim Berater + Gegenbuchung („VP-Storno") beim Firmenaccount im Zielmonat (der alte Monat bleibt unangetastet). Liegt sie bei einem anderen **Berater** (echte Umhängung), wird weiterhin nur gewarnt (`skipped_other_sponsor` + Command-Tabelle + Log). Am Echtfall Anna Schall (Order 58797) auf dem Testsystem ausgeführt: Juli-Zeile bei #6 unverändert, Christina Schuder (#1241) +200 P. im August mit Order-Referenz, Firmenaccount −200 P. im August; Folgelauf idempotent. Käufer/Sponsoren und Bestandszeilen werden gebatcht geladen (N+1 weg). 5 neue Tests, 12/12 grün. +2. ✅ **Incentive-Reparatur gehärtet:** `removeStaleSponsorTrackings()` überspringt `m_sponsor = NULL` (nur echter Sponsorwechsel löscht, mit Log-Spur); `repairForeignKeys()` läuft jetzt VOR der Stale-Bereinigung (Waisen-Logs ohne FK werden erst angebunden und dann konsistent mitbereinigt — vorher Doppelzählung möglich); N+1-Queries in Stale-Check und `rebuildFromSourceTables()` durch gebatchte Lookups ersetzt; Qualifikationsfenster zentral über neue Helper `Incentive::qualificationWindow()`/`qualificationWindowContains()` + Scope (vorher 4 unabhängige Kopien — die Fehlerklasse des Dino-Portaro-Randtag-Bugs). Kleine dokumentierte Abweichung: `registered_at` beim Rebuild = `MIN(created_at)` der Starterkit-Orders (statt „erste nach id"). 6 neue Tests (u. a. NULL-Sponsor bleibt erhalten, Randtag 23:15 zählt, End-to-End ohne Doppelzählung nach Umhängung). +3. ✅ **`.env` aus dem Git-Tracking entfernt** (`git rm --cached`; `.gitignore` enthielt sie bereits, `.env.example` existiert). Die Datei bleibt lokal/auf dem Server liegen — Pflege wie bisher per SFTP (vom Kunden bestätigt). Empfehlung bleibt: die bereits historisierten Keys (PAYONE, Stripe-Test, DHL) bei Gelegenheit rotieren. + +## Technische Querschnittsthemen + +- **`LiveCommissionService::buildSnapshot()` erweitern** (Level-/Punktefelder durchreichen) ist gemeinsame Voraussetzung für N1 und N4 — einmal bauen, zweimal nutzen. Cache-Key-Version erhöhen, damit alte 6-h-Caches nicht mit schmalem Payload kollidieren. +- **Performance Home-Dashboard:** alle neuen Kennzahlen in die bestehenden 6-h-Caches integrieren (`home_payline_*`-Muster); keine zusätzlichen Baum-Läufe. +- **Tests:** je Paket Pest-Feature-Tests analog der Juli-Suite (`BackofficeStatisticsListsTest`-Muster, Helfer mit eindeutigem Prefix, keine `route()`-Nutzung). +- **Unkommittetes WIP beachten:** `SponsorBuyingPointsRepairService` + `RepairSponsorBuyingPoints` (Nacht-Repair der Sponsor-VP-Zeilen) liegen ungetrackt im Arbeitsverzeichnis und berühren dieselbe Status-5-Logik wie N3 — vor Beginn von N3 committen/abstimmen. diff --git a/dev/2026-08-12-intensiv-tickets/README.md b/dev/2026-08-12-intensiv-tickets/README.md index 408e9e17..ca1017b8 100644 --- a/dev/2026-08-12-intensiv-tickets/README.md +++ b/dev/2026-08-12-intensiv-tickets/README.md @@ -92,6 +92,38 @@ Stolperfalle dabei: `BusinessUserItem(Optimized)` hat `__get` ohne `__isset` – `$item->active_date ?? null` liefert dort **immer** null. Deshalb direkter Property-Zugriff in `sortByActivation()`. +## Nachtrag 2026-08-19: VP-Punkte-Nacht-Repair (Ticket Jasmin Grund, Teil 2) + +Folgeticket: Madeleine Schrade war im Incentive-Ranking sichtbar (Nacht-Cron 11.08.), +aber nicht im **Punkteranking für August** (Team → Punkte). Ursache: Die +Vertriebspartner-Punkte des Sponsors (200 P, `user_sales_volumes` status=5 +„Registrierung") schreibt nur `Payment::addSponsorBuyingPoints()` im +Zahlungs-Callback – ohne Repair-Mechanismus. Scan über den Incentive-Zeitraum: +**7 fehlende VP-Zeilen** (5× Juni/PAYONE, Anna Schall Juli/PAYONE, Madeleine +Schrade August/Stripe). + +**Entscheidung (Kevin):** Keine rückwirkenden Buchungen in abgeschlossene +Provisionsmonate (Juni/Juli bleiben wie sie sind). Stattdessen dauerhaftes +Nacht-Repair, das den **laufenden Monat** heilt – damit wurde auch der +August-Fall (Madeleine → Jasmin) nachgebucht. + +Umsetzung: + +- `app/Services/SponsorBuyingPointsRepairService.php` – idempotenter Repair: + bezahlte Bestellungen des Ziel-Monats mit `sponsor_buying_points`-Produkt, + Sponsor vorhanden, aber keine VP-Zeile (Dedup: `shopping_order_id` der + Repair-Zeilen bzw. exaktes Live-Info-Format `VP: | ` im Monat). + Repair-Zeilen tragen `shopping_order_id` + Message-Suffix „(Nacht-Repair)". + Danach `reCalculateSalesPointsVolume` je Sponsor. +- `app/Console/Commands/RepairSponsorBuyingPoints.php` – `sales:repair-sponsor-vp` + mit `--dry-run`; fremde Monate nur mit `--force` (Schutz vor Retro-Buchungen). +- Schedule: täglich **02:30** (vor `business:store-optimized` um 03:00), Kernel.php. +- Tests: `tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php` (7 Tests; + neues Verzeichnis in `tests/Pest.php` registriert). + +Verifiziert auf der Live-Kopie (19.08.): 13 August-Bestellungen geprüft, 12 bereits +gebucht, genau 1 nachgebucht (Order 59900, Jasmin Grund +200 P), Juni/Juli unberührt. + ## Geänderte Dateien | Datei | Änderung | diff --git a/dev/2026-08-12-intensiv-tickets/juli-anerkennungen.md b/dev/2026-08-12-intensiv-tickets/juli-anerkennungen.md new file mode 100644 index 00000000..6c75219e --- /dev/null +++ b/dev/2026-08-12-intensiv-tickets/juli-anerkennungen.md @@ -0,0 +1,72 @@ +Unsere MIVITA-Erfolge im Juli – Herzlichen Glückwunsch! 🎉 + +Hallo liebes MIVITA-Team, + +auch heute freuen wir uns ganz besonders, euch unsere monatlichen Top-Ergebnisse des vergangenen Monats präsentieren zu dürfen. + +Hinter jedem einzelnen Erfolg stehen Freude, Engagement, Begeisterung, Ausdauer und die Bereitschaft, jeden Tag sein Bestes zu geben. Genau das macht unser MIVITA-Team so besonders! 💙 + +Aus diesem Grund möchten wir auch in diesem Monat allen Partnern ganz herzlich danken und gratulieren, die Besonderes erreicht und mit ihrem Einsatz großartige Ergebnisse erzielt haben. + +Herzlichen Glückwunsch an alle Ausgezeichneten! 👏🎉 + +🏆 Top-Recruiter – Juli + +Wir gratulieren ganz herzlich unseren Top-Recruitern des Monats Juli zu dieser großartigen Leistung! + +🥇 Nina Fleschhut (3) +🥇 Jasmin Grund (3) +🥇 Tanja Longerich (3) + +Alle drei haben mit jeweils 3 das gleiche hervorragende Ergebnis erreicht – herzlichen Glückwunsch zu dieser starken Leistung! 👏🎉 + +🌟 Die meisten neuen Kunden-Abos im Juli + +Diese Partner haben im Juli die meisten neuen Kunden-Abos gewonnen. Herzlichen Glückwunsch zu diesem hervorragenden Erfolg! + +🥇 Platz 1: Nina Fleschhut (8) +🥈 Platz 2: Jasmin Grund (7) +🥉 Platz 3: Christina Schuder + Annette Harsch (5) + +💙 Höchster Umsatz mit Kunden-Abos + +Besonders gratulieren wir unseren Partnern mit dem höchsten Umsatz durch Kunden-Abos im Monat Juli. + +🥇 Platz 1: Tanja Longerich – 1.195 Punkte +🥈 Platz 2: Cornelia Böhm-Sodan – 971 Punkte +🥉 Platz 3: Annette Harsch – 835 Punkte + +⭐ Die meisten Kunden-Abos insgesamt + +Auch in dieser Kategorie möchten wir unseren erfolgreichsten Partnern ganz herzlich gratulieren. + +🥇 Platz 1: Annette Harsch (17) +🥈 Platz 2: Nina Fleschhut (16) +🥉 Platz 3: Melanie Hoffmann-Gebhard, Tanja Longerich + Cornelia Böhm-Sodan (je 13) + +🚀 Top 3 durch Kundenumsatz & Recruiting – 1.000-Punkte-Shop + +Diese Partner haben im Juli durch Kundenumsatz, Eigenumsatz und Recruiting die höchste Punktezahl erreicht – eine großartige Leistung! + +🥇 Platz 1: Daniel Biallowons (5.206 P) +🥈 Platz 2: Tanja Longerich (3.698 P) +🥉 Platz 3: Marlene Mathis (3.334 P) +Platz 4: Jasmin Grund (3.068 P) + +Wir freuen uns riesig über eure Erfolge und sind sehr stolz auf euch! 💙 + +Jeder einzelne Erfolg zeigt, was möglich ist, wenn man konsequent an seinen Zielen arbeitet, dranbleibt und seine Chancen nutzt. Vielleicht steht schon im nächsten Monat dein Name auf einer unserer Top-Listen. 😊 + +🇲🇪 Montenegro wartet! 🌴☀️ + +Gib jetzt noch einmal alles – denn es ist noch alles möglich für diejenigen, die wirklich wollen! + +Wir freuen uns schon sehr darauf, gemeinsam mit euch ein paar wunderschöne Tage in Montenegro zu verbringen und dort unsere Erfolge zu feiern. 🥂✨ + +Und wer weiß – vielleicht können wir dort nicht nur auf die bisherigen Erfolge anstoßen, sondern auch schon auf viele neue Namen, neue Geschichten und neue persönliche Meilensteine! + +Lasst uns gemeinsam weiterhin Menschen für unsere Produkte begeistern, unsere Vision leben und Monat für Monat neue Erfolge feiern. + +Wir wünschen euch weiterhin ganz viel Begeisterung, Energie, Freude und vor allem ganz viel Erfolg! 🚀💙 + +Euer MIVITA-Team 💙 \ No newline at end of file diff --git a/resources/lang/de/account.php b/resources/lang/de/account.php index 69b714c7..118f630a 100644 --- a/resources/lang/de/account.php +++ b/resources/lang/de/account.php @@ -27,7 +27,7 @@ return [ 'reverse_charge_procedure' => 'Reverse Charge Verfahren', 'tax_number' => 'Steuernummer', 'taxable_sales_1' => 'umsatzsteuerpflichtig (Ich mache eine UST-Voranmeldung pro Monat / Quartal / Jahr ans Finanzamt)', - 'taxable_sales_2' => 'nicht umsatzsteuerpflichtig (Kleinunternehmer im Sinne von $ 19', + 'taxable_sales_2' => 'nicht umsatzsteuerpflichtig (Kleinunternehmer im Sinne von § 19 UStG)', 'validator_creditcard' => 'Geben Sie bitte eine gültige Kreditkarten-Nummer ein', 'validator_date' => 'Bitte geben Sie ein gültiges Datum ein.', 'validator_digits' => 'Geben Sie bitte nur Ziffern ein.', @@ -45,4 +45,12 @@ return [ 'language_settings' => 'Spracheinstellungen', 'preferred_language' => 'Bevorzugte Sprache', 'language_hint' => 'Diese Sprache wird für Ihre Rechnungen, Provisionsabrechnungen und Lieferscheine verwendet.', + + // N6: Berater-Profilfoto. Der finale Einwilligungstext wird von der Kundin + // nachgeliefert — profile_image_consent ist bis dahin ein Platzhaltertext. + 'profile_image' => 'Profilfoto', + 'profile_image_copy' => 'Dein Foto wird teamintern angezeigt, z. B. in Ranglisten und Übersichten.', + 'profile_image_consent' => 'Ich bin einverstanden, dass mein Foto teamintern in Ranglisten und Übersichten angezeigt wird.', + 'profile_image_consent_required' => 'Bitte bestätige zuerst die Einwilligung zur teaminternen Anzeige deines Fotos.', + 'profile_image_delete' => 'Foto löschen', ]; diff --git a/resources/lang/de/home.php b/resources/lang/de/home.php index 5a1fa24d..7ed0091f 100644 --- a/resources/lang/de/home.php +++ b/resources/lang/de/home.php @@ -70,4 +70,32 @@ return [ 'own' => 'Eigene', 'live_calculation_hint' => 'Live-Berechnung (noch nicht abgeschlossen)', 'live_calculation_hint_text' => 'Wird erst zum Monatsende berechnet.', + 'stats_group_team' => 'Team', + 'firstlines_count' => 'Firstlines', + 'team_partner_total' => 'Teampartner gesamt', + 'payline_partner_count' => 'Teampartner in der Payline', + 'payline_abo_count' => 'Abos in der Payline', + 'level_current' => 'Aktuelles Level', + 'level_career' => 'Karriere-Level', + 'level_next_missing' => 'Zum nächsten Level :level fehlen noch :points Payline-Punkte.', + 'level_next_missing_personal' => 'Zusätzlich fehlen :points Punkte persönliches Volumen.', + 'level_top_reached' => 'Höchstes Level erreicht.', + 'level_hint' => 'Monatliche Qualifikationsstufe aus dem Live-Lauf. Payline-Punkte = Teampunkte deiner bezahlten Ebenen inkl. deines Kundenpunkte-Überschusses.', + 'forecast_level_title' => 'Level & Provision laut Prognose (Stand 25.)', + 'forecast_unverbindlich' => 'unverbindlich', + 'forecast_level_disclaimer' => 'Schätzung auf Basis der heutigen Punkte plus der noch offenen Abo-Lieferungen. Rang-/Strukturänderungen im Team sind nicht berücksichtigt - verbindlich ist erst der Monatsabschluss.', + 'forecast_level_reached' => 'Laut Prognose erreichst du das nächste Level :level - sofern dein persönliches Volumen von :volume Punkten erreicht wird.', + 'forecast_level_missing' => 'Laut Prognose fehlen zum nächsten Level :level noch :points Payline-Punkte.', + 'forecast_level_missing_personal' => 'Zusätzlich fehlen :points Punkte persönliches Volumen.', + 'forecast_commission' => 'Voraussichtliche Provision laut Prognose: :amount (auf Basis Level :level).', + + // N6: "Die besten Ergebnisse des Monats" (teamintern) + 'top_results_title' => 'Die besten Ergebnisse des Monats (Team)', + 'top_results_volume' => 'Top 3 persönliches Volumen', + 'top_results_recruiters' => 'Top 3 Neupartner', + 'top_results_new_abos' => 'Top 3 neue Abos', + 'top_results_empty' => 'Noch keine Ergebnisse in diesem Monat.', + 'top_results_unit_points' => 'Punkte', + 'top_results_unit_partners' => 'Neupartner', + 'top_results_unit_abos' => 'Abos', ]; diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index 4fb34a42..cd5b449c 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -77,6 +77,7 @@ return [ 'dhl_cockpit' => 'DHL Cockpit', 'revenue' => 'Umsatz', 'level_reports' => 'Level Reports', + 'annual_fee' => 'Jahresgebühr', 'dashboard_news' => 'Dashboard News', 'teamabos' => 'Team Abos', 'team_customer_abos' => 'Team Kunden-Abos', diff --git a/resources/lang/de/order.php b/resources/lang/de/order.php index f87c92d6..533d1105 100644 --- a/resources/lang/de/order.php +++ b/resources/lang/de/order.php @@ -126,4 +126,5 @@ return [ 'free_shipping_reached' => 'Ab :amount € versandkostenfrei', 'free_shipping_info' => 'Noch :missing € bis zum versandkostenfreien Versand (ab :amount €)', + 'source_comment_required' => 'Bitte schreib uns kurz, wie du auf uns aufmerksam geworden bist - z. B. der Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung.', ]; diff --git a/resources/lang/de/team.php b/resources/lang/de/team.php index d1393d06..bd71431b 100644 --- a/resources/lang/de/team.php +++ b/resources/lang/de/team.php @@ -154,4 +154,19 @@ return [ 'not_available' => 'N/A', 'growth_bonus_details_description' => 'L1 = Direkte Partner (bekommt immer den Tiefenbonus), L2 = Members (ab hier wird der Tiefenbonus geteilt, wenn der Member qualifiziert ist), etc. | Zeigt Provision (€) und Volumen (Pkt) pro Ebene.', + + // Jahresgebühr-Nachfassliste (N8) + 'annual_fee_title' => 'Jahresgebühr ausstehend', + 'annual_fee_hint' => 'Hinweis: Teampartner mit abgelaufenem Account werden ca. 2 Wochen nach Ablauf automatisch deaktiviert und nach 2 Monaten aus dem System entfernt. Überfällige Einträge siehst du hier daher nur für begrenzte Zeit — fasse am besten frühzeitig nach.', + 'annual_fee_no_data' => 'Aktuell läuft bei keinem deiner Teampartner die Account-Gültigkeit in den nächsten 3 Monaten ab.', + 'annual_fee_group_overdue' => 'Überfällig', + 'annual_fee_group_due_1_month' => 'Fällig in ≤ 1 Monat', + 'annual_fee_group_due_2_months' => 'Fällig in ≤ 2 Monaten', + 'annual_fee_group_due_3_months' => 'Fällig in ≤ 3 Monaten', + 'firstlines' => 'Firstlines', + 'firstlines_copy' => 'Deine direkten Teampartner mit Umsatz und Kontaktdaten - sortiert nach Punkten des gewählten Monats.', + 'firstlines_none' => 'Du hast noch keine direkten Teampartner.', + 'firstlines_points' => 'Umsatz (Punkte)', + 'active_since' => 'Aktiv seit', + 'account_inactive' => 'Account inaktiv', ]; diff --git a/resources/lang/en/account.php b/resources/lang/en/account.php index 1d94db09..06d07d46 100644 --- a/resources/lang/en/account.php +++ b/resources/lang/en/account.php @@ -45,4 +45,12 @@ return [ 'preferred_language' => 'Preferred language', 'language_hint' => 'This language will be used for your invoices, commission statements and delivery notes.', '' => '', + + // N6: consultant profile photo. Final consent wording will be provided by + // the customer — profile_image_consent is a placeholder text until then. + 'profile_image' => 'Profile photo', + 'profile_image_copy' => 'Your photo is shown within your team, e.g. in rankings and overviews.', + 'profile_image_consent' => 'I agree that my photo may be shown within my team in rankings and overviews.', + 'profile_image_consent_required' => 'Please confirm your consent to showing your photo within your team first.', + 'profile_image_delete' => 'Delete photo', ]; diff --git a/resources/lang/en/home.php b/resources/lang/en/home.php index dafa7177..ef21d29f 100644 --- a/resources/lang/en/home.php +++ b/resources/lang/en/home.php @@ -70,4 +70,32 @@ return [ 'live_calculation_hint' => 'Live calculation (not yet finalized)', 'live_calculation_hint_text' => 'Will be calculated at the end of the month.', '' => '', + 'stats_group_team' => 'Team', + 'firstlines_count' => 'Firstlines', + 'team_partner_total' => 'Team partners total', + 'payline_partner_count' => 'Team partners in the payline', + 'payline_abo_count' => 'Subscriptions in the payline', + 'level_current' => 'Current level', + 'level_career' => 'Career level', + 'level_next_missing' => ':points payline points missing to reach the next level :level.', + 'level_next_missing_personal' => 'Additionally :points points of personal volume are missing.', + 'level_top_reached' => 'Highest level reached.', + 'level_hint' => 'Monthly qualification level from the live run. Payline points = team points of your paid levels including your customer points surplus.', + 'forecast_level_title' => 'Level & commission according to forecast (as of the 25th)', + 'forecast_unverbindlich' => 'non-binding', + 'forecast_level_disclaimer' => 'Estimate based on today\'s points plus the still open subscription deliveries. Rank/structure changes in the team are not taken into account - only the monthly closing is binding.', + 'forecast_level_reached' => 'According to the forecast you will reach the next level :level - provided your personal volume of :volume points is reached.', + 'forecast_level_missing' => 'According to the forecast, :points payline points are still missing for the next level :level.', + 'forecast_level_missing_personal' => 'Additionally :points points of personal volume are missing.', + 'forecast_commission' => 'Expected commission according to forecast: :amount (based on level :level).', + + // N6: "Best results of the month" (team-internal) + 'top_results_title' => 'Best results of the month (team)', + 'top_results_volume' => 'Top 3 personal volume', + 'top_results_recruiters' => 'Top 3 new partners', + 'top_results_new_abos' => 'Top 3 new subscriptions', + 'top_results_empty' => 'No results yet this month.', + 'top_results_unit_points' => 'points', + 'top_results_unit_partners' => 'new partners', + 'top_results_unit_abos' => 'subscriptions', ]; diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index 6ba60daa..dd333de7 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -73,6 +73,7 @@ return [ 'dhl_cockpit' => 'DHL Cockpit', 'revenue' => 'Revenue', 'level_reports' => 'Level Reports', + 'annual_fee' => 'Annual Fee', 'marketingplan' => 'Marketing Plan', 'dashboard' => 'Dashboard', 'dashboard_news' => 'Dashboard News', diff --git a/resources/lang/en/order.php b/resources/lang/en/order.php index 1f1dcafa..9ed1f6fb 100644 --- a/resources/lang/en/order.php +++ b/resources/lang/en/order.php @@ -125,4 +125,5 @@ return [ 'reorder_info_2' => 'Your delivery country is: :country
If you want a different country, update your billing or delivery address under My details', 'reorder_abo_not_allowed' => 'Subscription orders cannot be repeated via “Reorder”. Please use your subscription area or the shop for one-off orders.', 'number' => 'Item no.', + 'source_comment_required' => 'Please tell us briefly how you heard about us - e.g. the name of the person who recommended us or the exact trade fair/event.', ]; diff --git a/resources/lang/en/team.php b/resources/lang/en/team.php index 21bdc010..b1e5a87b 100644 --- a/resources/lang/en/team.php +++ b/resources/lang/en/team.php @@ -154,4 +154,19 @@ return [ 'growth_bonus_details_description' => 'L1 = Direct Partner (gets the growth bonus always), L2 = Members (from here the growth bonus is divided if the member is qualified), etc. | Shows commission (€) and volume (points) per level.', '' => '', 'open' => 'Open', + + // Jahresgebühr-Nachfassliste (N8) + 'annual_fee_title' => 'Annual fee due', + 'annual_fee_hint' => 'Note: Team partners with an expired account are automatically deactivated about 2 weeks after expiry and removed from the system after 2 months. Overdue entries therefore only appear here for a limited time — best follow up early.', + 'annual_fee_no_data' => 'Currently none of your team partners has an account validity expiring within the next 3 months.', + 'annual_fee_group_overdue' => 'Overdue', + 'annual_fee_group_due_1_month' => 'Due in ≤ 1 month', + 'annual_fee_group_due_2_months' => 'Due in ≤ 2 months', + 'annual_fee_group_due_3_months' => 'Due in ≤ 3 months', + 'firstlines' => 'Firstlines', + 'firstlines_copy' => 'Your direct team partners with turnover and contact details - sorted by points of the selected month.', + 'firstlines_none' => 'You do not have any direct team partners yet.', + 'firstlines_points' => 'Turnover (points)', + 'active_since' => 'Active since', + 'account_inactive' => 'Account inactive', ]; diff --git a/resources/lang/es/account.php b/resources/lang/es/account.php index dddcec5c..e3c4b111 100644 --- a/resources/lang/es/account.php +++ b/resources/lang/es/account.php @@ -45,4 +45,12 @@ return [ 'preferred_language' => 'Idioma preferido', 'language_hint' => 'Este idioma se utilizará para sus facturas, extractos de comisiones y albaranes.', '' => '', + + // N6: foto de perfil. El texto de consentimiento definitivo lo entregará la + // clienta — profile_image_consent es un texto provisional hasta entonces. + 'profile_image' => 'Foto de perfil', + 'profile_image_copy' => 'Tu foto se muestra internamente en tu equipo, p. ej. en clasificaciones y resúmenes.', + 'profile_image_consent' => 'Acepto que mi foto se muestre internamente en mi equipo en clasificaciones y resúmenes.', + 'profile_image_consent_required' => 'Confirma primero el consentimiento para mostrar tu foto dentro de tu equipo.', + 'profile_image_delete' => 'Eliminar foto', ]; diff --git a/resources/lang/es/home.php b/resources/lang/es/home.php index 47672ed5..844835ba 100644 --- a/resources/lang/es/home.php +++ b/resources/lang/es/home.php @@ -70,4 +70,32 @@ return [ 'live_calculation_hint' => 'Cálculo en vivo (no finalizado)', 'live_calculation_hint_text' => 'Se calculará al final del mes.', '' => '', + 'stats_group_team' => 'Equipo', + 'firstlines_count' => 'Firstlines', + 'team_partner_total' => 'Socios en total', + 'payline_partner_count' => 'Socios en la payline', + 'payline_abo_count' => 'Suscripciones en la payline', + 'level_current' => 'Nivel actual', + 'level_career' => 'Nivel de carrera', + 'level_next_missing' => 'Para el siguiente nivel :level faltan :points puntos de payline.', + 'level_next_missing_personal' => 'Además faltan :points puntos de volumen personal.', + 'level_top_reached' => 'Nivel más alto alcanzado.', + 'level_hint' => 'Nivel de calificación mensual del cálculo en vivo. Puntos de payline = puntos de equipo de tus niveles pagados incluyendo tu excedente de puntos de clientes.', + 'forecast_level_title' => 'Nivel y comisión según la previsión (al día 25)', + 'forecast_unverbindlich' => 'sin compromiso', + 'forecast_level_disclaimer' => 'Estimación basada en los puntos de hoy más las entregas de suscripción pendientes. No se consideran cambios de rango/estructura en el equipo - solo el cierre mensual es vinculante.', + 'forecast_level_reached' => 'Según la previsión alcanzarás el siguiente nivel :level - siempre que se alcance tu volumen personal de :volume puntos.', + 'forecast_level_missing' => 'Según la previsión faltan :points puntos de payline para el siguiente nivel :level.', + 'forecast_level_missing_personal' => 'Además faltan :points puntos de volumen personal.', + 'forecast_commission' => 'Comisión prevista según la previsión: :amount (según nivel :level).', + + // N6: "Los mejores resultados del mes" (interno del equipo) + 'top_results_title' => 'Los mejores resultados del mes (equipo)', + 'top_results_volume' => 'Top 3 volumen personal', + 'top_results_recruiters' => 'Top 3 nuevos socios', + 'top_results_new_abos' => 'Top 3 nuevas suscripciones', + 'top_results_empty' => 'Aún no hay resultados este mes.', + 'top_results_unit_points' => 'puntos', + 'top_results_unit_partners' => 'nuevos socios', + 'top_results_unit_abos' => 'suscripciones', ]; diff --git a/resources/lang/es/navigation.php b/resources/lang/es/navigation.php index 61a9c993..b9248dee 100644 --- a/resources/lang/es/navigation.php +++ b/resources/lang/es/navigation.php @@ -70,6 +70,7 @@ return [ 'dhl_cockpit' => 'Cabina DHL', 'revenue' => 'Ingresos', 'level_reports' => 'Informes de Nivel', + 'annual_fee' => 'Cuota anual', 'marketingplan' => 'Plan de Marketing', 'dashboard' => 'Panel', 'dashboard_news' => 'Noticias del Panel', diff --git a/resources/lang/es/order.php b/resources/lang/es/order.php index 6e4eb1e9..2d50b2bf 100644 --- a/resources/lang/es/order.php +++ b/resources/lang/es/order.php @@ -126,4 +126,5 @@ return [ 'reorder_info' => '¿Quieres volver a pedir este artículo?
Al hacer clic en el botón, los artículos se añadirán de nuevo al carrito y serás redirigido a la página del carrito.', 'reorder_info_2' => 'Tu país de entrega es: :country
Si quieres que tu pedido se entregue en otro país, cambia tu dirección de facturación o de entrega en Mis datos', 'reorder_abo_not_allowed' => 'Los pedidos de suscripción no se pueden repetir mediante «Volver a pedir». Utiliza la gestión de tu suscripción o la tienda para pedidos individuales.', + 'source_comment_required' => 'Cuéntanos brevemente cómo nos conociste - p. ej. el nombre de la persona que te recomendó o la feria/el evento exacto.', ]; diff --git a/resources/lang/es/team.php b/resources/lang/es/team.php index f4c0bd65..31a0d970 100644 --- a/resources/lang/es/team.php +++ b/resources/lang/es/team.php @@ -154,4 +154,19 @@ return [ 'growth_bonus_details_description' => 'L1 = Socio Directo (obtiene el bono de crecimiento siempre), L2 = Miembros (a partir de aquí el bono de crecimiento se divide si el miembro está calificado), etc. | Muestra comisión (€) y volumen (puntos) por nivel.', '' => '', 'open' => 'Abierto', + + // Jahresgebühr-Nachfassliste (N8) + 'annual_fee_title' => 'Cuota anual pendiente', + 'annual_fee_hint' => 'Nota: Los socios del equipo con una cuenta caducada se desactivan automáticamente unas 2 semanas después del vencimiento y se eliminan del sistema después de 2 meses. Por eso, las entradas vencidas solo aparecen aquí durante un tiempo limitado — es mejor hacer el seguimiento pronto.', + 'annual_fee_no_data' => 'Actualmente, la validez de la cuenta de ninguno de tus socios del equipo caduca en los próximos 3 meses.', + 'annual_fee_group_overdue' => 'Vencida', + 'annual_fee_group_due_1_month' => 'Vence en ≤ 1 mes', + 'annual_fee_group_due_2_months' => 'Vence en ≤ 2 meses', + 'annual_fee_group_due_3_months' => 'Vence en ≤ 3 meses', + 'firstlines' => 'Firstlines', + 'firstlines_copy' => 'Tus socios directos con volumen y datos de contacto - ordenados por puntos del mes seleccionado.', + 'firstlines_none' => 'Aún no tienes socios directos.', + 'firstlines_points' => 'Volumen (puntos)', + 'active_since' => 'Activo desde', + 'account_inactive' => 'Cuenta inactiva', ]; diff --git a/resources/lang/fr/account.php b/resources/lang/fr/account.php index 0c9225ff..e49752ac 100644 --- a/resources/lang/fr/account.php +++ b/resources/lang/fr/account.php @@ -27,7 +27,7 @@ return [ 'reverse_charge_procedure' => 'Procédure d’autoliquidation', 'tax_number' => 'Numéro fiscal', 'taxable_sales_1' => 'assujetti à la TVA (je dépose une déclaration de TVA mensuelle / trimestrielle / annuelle auprès du fisc)', - 'taxable_sales_2' => 'non assujetti à la TVA (petite entreprise au sens du § 19', + 'taxable_sales_2' => 'non assujetti à la TVA (petite entreprise au sens du § 19 UStG)', 'validator_creditcard' => 'Veuillez saisir un numéro de carte de crédit valide', 'validator_date' => 'Veuillez saisir une date valide.', 'validator_digits' => 'Veuillez saisir uniquement des chiffres.', @@ -45,4 +45,12 @@ return [ 'language_settings' => 'Paramètres de langue', 'preferred_language' => 'Langue préférée', 'language_hint' => 'Cette langue est utilisée pour vos factures, relevés de commissions et bons de livraison.', + + // N6 : photo de profil. Le texte de consentement définitif sera fourni par + // la cliente — profile_image_consent est un texte provisoire d'ici là. + 'profile_image' => 'Photo de profil', + 'profile_image_copy' => 'Ta photo est affichée en interne dans ton équipe, p. ex. dans les classements et les aperçus.', + 'profile_image_consent' => "J'accepte que ma photo soit affichée en interne dans mon équipe dans les classements et les aperçus.", + 'profile_image_consent_required' => "Merci de confirmer d'abord le consentement à l'affichage de ta photo au sein de ton équipe.", + 'profile_image_delete' => 'Supprimer la photo', ]; diff --git a/resources/lang/fr/home.php b/resources/lang/fr/home.php index 4517c1da..afb7bbf9 100644 --- a/resources/lang/fr/home.php +++ b/resources/lang/fr/home.php @@ -70,4 +70,32 @@ return [ 'own' => 'Propres', 'live_calculation_hint' => 'Calcul en direct (pas encore terminé)', 'live_calculation_hint_text' => 'Ne sera calculé qu’à la fin du mois.', + 'stats_group_team' => 'Équipe', + 'firstlines_count' => 'Firstlines', + 'team_partner_total' => 'Partenaires au total', + 'payline_partner_count' => 'Partenaires dans la payline', + 'payline_abo_count' => 'Abonnements dans la payline', + 'level_current' => 'Niveau actuel', + 'level_career' => 'Niveau de carrière', + 'level_next_missing' => 'Pour le niveau suivant :level, il manque encore :points points de payline.', + 'level_next_missing_personal' => 'Il manque en plus :points points de volume personnel.', + 'level_top_reached' => 'Niveau le plus élevé atteint.', + 'level_hint' => 'Niveau de qualification mensuel issu du calcul en direct. Points de payline = points d’équipe de tes niveaux payés, y compris ton excédent de points clients.', + 'forecast_level_title' => 'Niveau et commission selon la prévision (au 25)', + 'forecast_unverbindlich' => 'sans engagement', + 'forecast_level_disclaimer' => 'Estimation basée sur les points actuels plus les livraisons d’abonnement encore ouvertes. Les changements de rang/structure dans l’équipe ne sont pas pris en compte - seule la clôture mensuelle fait foi.', + 'forecast_level_reached' => 'Selon la prévision, tu atteindras le niveau suivant :level - à condition que ton volume personnel de :volume points soit atteint.', + 'forecast_level_missing' => 'Selon la prévision, il manque encore :points points de payline pour le niveau suivant :level.', + 'forecast_level_missing_personal' => 'Il manque en plus :points points de volume personnel.', + 'forecast_commission' => 'Commission prévisionnelle selon la prévision : :amount (sur la base du niveau :level).', + + // N6 : « Les meilleurs résultats du mois » (interne à l'équipe) + 'top_results_title' => 'Les meilleurs résultats du mois (équipe)', + 'top_results_volume' => 'Top 3 volume personnel', + 'top_results_recruiters' => 'Top 3 nouveaux partenaires', + 'top_results_new_abos' => 'Top 3 nouveaux abonnements', + 'top_results_empty' => 'Pas encore de résultats ce mois-ci.', + 'top_results_unit_points' => 'points', + 'top_results_unit_partners' => 'nouveaux partenaires', + 'top_results_unit_abos' => 'abonnements', ]; diff --git a/resources/lang/fr/navigation.php b/resources/lang/fr/navigation.php index 5c3cf04c..9a06675e 100644 --- a/resources/lang/fr/navigation.php +++ b/resources/lang/fr/navigation.php @@ -74,6 +74,7 @@ return [ 'dhl_cockpit' => 'Cockpit DHL', 'revenue' => 'Chiffre d’affaires', 'level_reports' => 'Rapports de niveau', + 'annual_fee' => 'Cotisation annuelle', 'dashboard_news' => 'Actualités du tableau de bord', 'teamabos' => 'Abonnements équipe', 'team_customer_abos' => 'Abonnements clients de l’équipe', diff --git a/resources/lang/fr/order.php b/resources/lang/fr/order.php index 02ab1548..8491d7f5 100644 --- a/resources/lang/fr/order.php +++ b/resources/lang/fr/order.php @@ -125,4 +125,5 @@ return [ 'free_shipping' => 'Livraison gratuite', 'free_shipping_reached' => 'Livraison gratuite à partir de :amount €', 'free_shipping_info' => 'Encore :missing € jusqu’à la livraison gratuite (à partir de :amount €)', + 'source_comment_required' => 'Dis-nous brièvement comment tu as entendu parler de nous - p. ex. le nom de la personne qui t\'a recommandé ou le salon/l\'événement exact.', ]; diff --git a/resources/lang/fr/team.php b/resources/lang/fr/team.php index 2f86d9a2..fc471734 100644 --- a/resources/lang/fr/team.php +++ b/resources/lang/fr/team.php @@ -133,4 +133,19 @@ return [ 'no' => 'Non', 'not_available' => 'N/A', 'growth_bonus_details_description' => 'L1 = partenaires directs (reçoit toujours le bonus de profondeur), L2 = membres (à partir d’ici, le bonus de profondeur est partagé si le membre est qualifié), etc. | Affiche la commission (€) et le volume (pts) par niveau.', + + // Jahresgebühr-Nachfassliste (N8) + 'annual_fee_title' => 'Cotisation annuelle en attente', + 'annual_fee_hint' => 'Remarque : les partenaires dont le compte a expiré sont automatiquement désactivés environ 2 semaines après l’expiration et supprimés du système après 2 mois. Les entrées en retard n’apparaissent donc ici que pour une durée limitée — mieux vaut relancer tôt.', + 'annual_fee_no_data' => 'Actuellement, la validité du compte d’aucun de tes partenaires n’expire dans les 3 prochains mois.', + 'annual_fee_group_overdue' => 'En retard', + 'annual_fee_group_due_1_month' => 'Échéance dans ≤ 1 mois', + 'annual_fee_group_due_2_months' => 'Échéance dans ≤ 2 mois', + 'annual_fee_group_due_3_months' => 'Échéance dans ≤ 3 mois', + 'firstlines' => 'Firstlines', + 'firstlines_copy' => 'Tes partenaires directs avec chiffre d’affaires et coordonnées - triés par points du mois sélectionné.', + 'firstlines_none' => 'Tu n’as pas encore de partenaires directs.', + 'firstlines_points' => 'Chiffre d’affaires (points)', + 'active_since' => 'Actif depuis', + 'account_inactive' => 'Compte inactif', ]; diff --git a/resources/views/admin/sales/_detail.blade.php b/resources/views/admin/sales/_detail.blade.php index c844949a..1472d4a1 100644 --- a/resources/views/admin/sales/_detail.blade.php +++ b/resources/views/admin/sales/_detail.blade.php @@ -209,12 +209,16 @@
{{ __('order.points_total') }}
{{ $shopping_order->getFormattedPoints() }} - @if ((int) $shopping_order->payment_for === 6 && $shopping_order->customer_order_source) + @if ((int) $shopping_order->payment_for === 6 && ($shopping_order->customer_order_source_comment || $shopping_order->customer_order_source))
Herkunft
- {{ $shopping_order->getCustomerOrderSourceLabel() }} - @if ($shopping_order->customer_order_source_comment) -
{{ $shopping_order->customer_order_source_comment }}
+ @if ($shopping_order->customer_order_source) + {{ $shopping_order->getCustomerOrderSourceLabel() }} + @if ($shopping_order->customer_order_source_comment) +
{{ $shopping_order->customer_order_source_comment }}
+ @endif + @else + {{ $shopping_order->customer_order_source_comment }} @endif
@endif diff --git a/resources/views/components/_avatar.blade.php b/resources/views/components/_avatar.blade.php new file mode 100644 index 00000000..2dbd243b --- /dev/null +++ b/resources/views/components/_avatar.blade.php @@ -0,0 +1,15 @@ +{{-- Wiederverwendbarer runder Avatar (N6): Profilfoto oder Initialen-Fallback. + Parameter: imageUrl (string|null), firstName, lastName, size (px, default 48). --}} +@php + $avatarSize = (int) ($size ?? 48); + $avatarInitials = mb_strtoupper(mb_substr(trim($firstName ?? ''), 0, 1) . mb_substr(trim($lastName ?? ''), 0, 1)); + $avatarInitials = $avatarInitials !== '' ? $avatarInitials : '?'; +@endphp +@if (!empty($imageUrl)) + {{ trim(($firstName ?? '') . ' ' . ($lastName ?? '')) }} +@else +
+ {{ $avatarInitials }}
+@endif diff --git a/resources/views/dashboard/_statistics.blade.php b/resources/views/dashboard/_statistics.blade.php index 6051fd6d..37a79486 100644 --- a/resources/views/dashboard/_statistics.blade.php +++ b/resources/views/dashboard/_statistics.blade.php @@ -59,6 +59,30 @@ ], ], ], + [ + 'title' => __('home.stats_group_team'), + 'col' => 'col-sm-6 col-lg-3', + 'tiles' => [ + [ + 'label' => __('home.firstlines_count'), 'value' => $fmt($s['firstline_count'] ?? 0), + 'icon' => 'ion-md-git-branch', 'color' => 'info', + 'link' => route('user_team_firstlines', ['month' => $m, 'year' => $y]), 'new' => null, 'live' => false, + ], + [ + 'label' => __('home.team_partner_total'), 'value' => $fmt($s['team_partner_count'] ?? 0), + 'icon' => 'ion-md-people', 'color' => 'secondary', 'link' => null, 'new' => null, 'live' => false, + ], + [ + 'label' => __('home.payline_partner_count'), 'value' => $fmt($s['payline_member_count'] ?? 0), + 'icon' => 'ion-md-podium', 'color' => 'success', 'link' => null, 'new' => null, 'live' => false, + ], + [ + 'label' => __('home.payline_abo_count'), 'value' => $fmt($s['payline_abo_count'] ?? 0), + 'icon' => 'ion-md-repeat', 'color' => 'success', 'link' => $teamShow, + 'new' => $s['payline_abos_new'] ?? 0, 'live' => false, + ], + ], + ], [ 'title' => __('home.stats_group_subscriptions'), 'col' => 'col-sm-6 col-lg-3', @@ -161,6 +185,42 @@ @endforeach + @if (! empty($s['level'])) + @php($level = $s['level']) +
+ {{ __('home.level_current') }} + @if ($level['is_live']) + Live + @endif + +
+
+ + {{ $level['name'] }} + +
+ @if ($level['next_name'] !== null && $level['next_name'] !== '') +
+ {!! __('home.level_next_missing', [ + 'level' => ''.e($level['next_name']).'', + 'points' => ''.$fmt($level['missing_pp']).'', + ]) !!} +
+ @if ($level['missing_personal'] > 0) +
+ {{ __('home.level_next_missing_personal', ['points' => $fmt($level['missing_personal'])]) }} +
+ @endif + @else +
{{ __('home.level_top_reached') }}
+ @endif + @if ($level['career_name'] !== '') +
{{ __('home.level_career') }}: {{ $level['career_name'] }}
+ @endif +
+
+ @endif + @if (! empty($s['payline_forecast']['days']))
{{ __('home.payline_forecast_title') }} @@ -190,6 +250,43 @@ @endforeach + + @if (! empty($s['level_forecast'])) + @php($lf = $s['level_forecast']) +
+
+ {{ __('home.forecast_level_title') }} + {{ __('home.forecast_unverbindlich') }} +
+ @if ($lf['next_name'] !== null && $lf['next_name'] !== '') + @if ($lf['reached']) +
+ {!! __('home.forecast_level_reached', [ + 'level' => ''.e($lf['next_name']).'', + 'volume' => ''.$fmt($lf['personal_required']).'', + ]) !!} +
+ @else +
+ {!! __('home.forecast_level_missing', [ + 'level' => ''.e($lf['next_name']).'', + 'points' => ''.$fmt($lf['missing_pp']).'', + ]) !!} + @if ($lf['missing_personal'] > 0) + {{ __('home.forecast_level_missing_personal', ['points' => $fmt($lf['missing_personal'])]) }} + @endif +
+ @endif + @endif +
+ {!! __('home.forecast_commission', [ + 'amount' => ''.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €', + 'level' => e($lf['estimated_level_name']), + ]) !!} +
+
+ @endif @endif diff --git a/resources/views/dashboard/_top_results.blade.php b/resources/views/dashboard/_top_results.blade.php new file mode 100644 index 00000000..7cc9a69c --- /dev/null +++ b/resources/views/dashboard/_top_results.blade.php @@ -0,0 +1,59 @@ +{{-- "Die besten Ergebnisse des Monats" (N6, teamintern): drei Top-3-Ranglisten + aus TopResultsService via $monthlyStats['top_results']. Karte erscheint nur, + wenn mindestens eine Liste Einträge hat. --}} +@php + $topResults = $monthlyStats['top_results'] ?? []; + $topColumns = [ + ['key' => 'top_volume', 'title' => __('home.top_results_volume'), 'unit' => __('home.top_results_unit_points'), 'decimals' => 0], + ['key' => 'top_recruiters', 'title' => __('home.top_results_recruiters'), 'unit' => __('home.top_results_unit_partners'), 'decimals' => 0], + ['key' => 'top_new_abos', 'title' => __('home.top_results_new_abos'), 'unit' => __('home.top_results_unit_abos'), 'decimals' => 0], + ]; + $hasTopResults = collect($topColumns)->contains(fn($column) => !empty($topResults[$column['key']])); +@endphp + +@if (!empty($monthlyStats) && $hasTopResults) +
+
+
+ {{ __('home.top_results_title') }} - + {{ HTMLHelper::getMonth($monthlyStats['month']) }} {{ $monthlyStats['year'] }} +
+
+
+ @foreach ($topColumns as $column) +
+
{{ $column['title'] }} +
+ @forelse ($topResults[$column['key']] ?? [] as $row) +
+
+ @include('components._avatar', [ + 'imageUrl' => $row['profile_image_url'], + 'firstName' => $row['first_name'], + 'lastName' => $row['last_name'], + 'size' => 48, + ]) + @if ($loop->first) + + @endif +
+
+
+ {{ $row['first_name'] }} {{ $row['last_name'] }}
+
+ {{ number_format((float) $row['value'], $column['decimals'], ',', '.') }} + {{ $column['unit'] }}
+
+
+ @empty +
{{ __('home.top_results_empty') }}
+ @endforelse +
+ @endforeach +
+
+
+
+@endif diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index aacafd92..d5aa9120 100755 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -46,6 +46,8 @@ @if (Auth::user()->isActiveAccount()) @include('dashboard._statistics') + @include('dashboard._top_results') + @include('dashboard._points') @include('dashboard._activities') diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index e68851b1..565e95ad 100755 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -105,6 +105,12 @@
{{ __('navigation.level_reports') }}
+
  • + +
    {{ __('navigation.annual_fee') }}
    +
    +
  • diff --git a/resources/views/user/backoffice/statistics/details.blade.php b/resources/views/user/backoffice/statistics/details.blade.php index f1d51d9b..20435003 100644 --- a/resources/views/user/backoffice/statistics/details.blade.php +++ b/resources/views/user/backoffice/statistics/details.blade.php @@ -102,6 +102,7 @@ @elseif ($details['metric'] === 'new_partners') Sponsor Kundenpunkte + Punkte gesamt Aktiv seit Account gültig bis @else @@ -169,6 +170,7 @@ @elseif ($details['metric'] === 'new_partners') {{ $row['sponsor'] ?? '-' }} {{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }} + {{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }} {{ $row['active_date'] ?? '-' }} {{ $row['payment_account'] ?? '-' }} @else @@ -211,6 +213,7 @@ @elseif ($details['metric'] === 'new_partners') {{ \App\Services\Util::formatNumber($details['summary']['customer_points'] ?? 0) }} + {{ \App\Services\Util::formatNumber($details['summary']['total_points'] ?? 0) }} @else diff --git a/resources/views/user/backoffice/statistics/index.blade.php b/resources/views/user/backoffice/statistics/index.blade.php index 4762975f..96df20dc 100644 --- a/resources/views/user/backoffice/statistics/index.blade.php +++ b/resources/views/user/backoffice/statistics/index.blade.php @@ -432,6 +432,109 @@ + @php + $recruiterRanking = $statistics['recruiter_ranking'] ?? null; + @endphp + +
    +
    +
    +
    +
    +
    Recruiter des Monats
    +
    + {{ $selectedMonthName }} {{ $selectedYear }} · nach Anzahl gesponserter Neupartner + +
    +
    + + {{ number_format((float) ($statistics['totals']['new_partners'] ?? 0), 0, ',', '.') }} + +
    + @if ($recruiterRanking === null) +
    +

    + Für diesen Monat liegt noch ein älterer Snapshot ohne diese Liste vor. +

    +
    + @elseif (count($recruiterRanking) === 0) +
    +

    In diesem Monat wurden keine Neupartner gesponsert.

    +
    + @else +
    + + + + + + + + + + @foreach ($recruiterRanking as $row) + + + + + + + + + @endforeach + +
    SponsorNeupartnerneues Volumen
    +
    + {{ $row['name'] }} + @if (!empty($row['is_self'])) + ich + @endif +
    +
    {{ !empty($row['is_self']) ? 'Eigene Neupartner' : 'Linie '.$row['line'] }} · {{ $row['career_level'] }}
    +
    + + +{{ number_format((float) $row['new_partner_count'], 0, ',', '.') }} + + + {{ \App\Services\Util::formatNumber($row['volume_points']) }} P. +
    +
      + @foreach ($row['partners'] ?? [] as $partner) +
    • + {{ $partner['name'] }} + · {{ $partner['career_level'] }} + · {{ \App\Services\Util::formatNumber($partner['total_points']) }} P. + @if (!empty($partner['children'])) +
        + @foreach ($partner['children'] as $child) +
      • + ↳ {{ $child['name'] }} + · {{ $child['career_level'] }} + · {{ \App\Services\Util::formatNumber($child['total_points']) }} P. +
      • + @endforeach +
      + @endif +
    • + @endforeach +
    +
    +
    +
    +

    + neues Volumen = Monats-Gesamtpunkte (Eigen- und Kundenpunkte, inkl. Starterpaket) + der geworbenen Neupartner — plus rekursiv die Punkte der Neupartner, die diese im selben Monat + selbst geworben haben. Klick auf eine Zeile zeigt, wer geworben wurde. +

    +
    + @endif +
    +
    +
    + @php $topCustomerAbos = $statistics['top_customer_abos'] ?? null; @endphp diff --git a/resources/views/user/components/user_profile_image.blade.php b/resources/views/user/components/user_profile_image.blade.php new file mode 100644 index 00000000..98a27996 --- /dev/null +++ b/resources/views/user/components/user_profile_image.blade.php @@ -0,0 +1,65 @@ +{{-- Berater-Profilfoto (N6): eigener Upload-Block (eigene Form, daher außerhalb + des Profil-Formulars eingebunden). Slim-Cropper-Muster wie beim Shop-Bild. --}} +
    +
    {{ __('account.profile_image') }}
    + {{ __('account.profile_image_copy') }} +
    +
    + @if ($user->account->getProfileImageUrl()) + + @else +
    + @csrf +
    +

    ({{ __('actions.search_file_or_drag_drop') }})' + data-size="400,400" + data-min-size="200,200" + data-max-file-size="10" + data-status-image-too-small="{{ __('actions.image_too_small') }}" + data-status-file-type="{{ __('actions.invalid_file') }}" + data-status-file-size="{{ __('actions.file_is_too_big') }}" + data-button-confirm-label="{{ __('actions.confirm') }}" + data-button-cancel-label="{{ __('actions.cancel') }}" + data-button-confirm-title="{{ __('actions.confirm') }}" + data-button-cancel-title="{{ __('actions.cancel') }}" + data-button-rotate-title="{{ __('actions.rotate') }}" + data-ratio="1:1"> + +
    + +
    + +
    + + +
    +
    + + @endif +
    +
    diff --git a/resources/views/user/edit.blade.php b/resources/views/user/edit.blade.php index 4beaf3d2..be9fcfe6 100644 --- a/resources/views/user/edit.blade.php +++ b/resources/views/user/edit.blade.php @@ -18,6 +18,10 @@

    {{ __('navigation.my_data') }}

    + + {{-- Profilfoto (N6): eigene Upload-Form, daher außerhalb des Profil-Formulars --}} + @include('user.components.user_profile_image') + {!! Form::open(['action' => route('user_edit'), 'method' => 'POST', 'class' => 'form-horizontal', 'id'=>'lead-form-validation']) !!} @include('user.user_form') diff --git a/resources/views/user/team/_annual_fee_groups.blade.php b/resources/views/user/team/_annual_fee_groups.blade.php new file mode 100644 index 00000000..bbf50997 --- /dev/null +++ b/resources/views/user/team/_annual_fee_groups.blade.php @@ -0,0 +1,68 @@ +
    +
    + {{ __('team.annual_fee_title') }} + {{ $totalCount }} +
    + +
    +
    + + {{ __('team.annual_fee_hint') }} +
    + + @if ($totalCount === 0) +
    + {{ __('team.annual_fee_no_data') }} +
    + @else + @php + $groupMeta = [ + \App\Services\Team\AnnualFeeDueService::GROUP_OVERDUE => ['label' => __('team.annual_fee_group_overdue'), 'badge' => 'badge-danger'], + \App\Services\Team\AnnualFeeDueService::GROUP_DUE_1_MONTH => ['label' => __('team.annual_fee_group_due_1_month'), 'badge' => 'badge-warning'], + \App\Services\Team\AnnualFeeDueService::GROUP_DUE_2_MONTHS => ['label' => __('team.annual_fee_group_due_2_months'), 'badge' => 'badge-info'], + \App\Services\Team\AnnualFeeDueService::GROUP_DUE_3_MONTHS => ['label' => __('team.annual_fee_group_due_3_months'), 'badge' => 'badge-secondary'], + ]; + @endphp + + @foreach (\App\Services\Team\AnnualFeeDueService::GROUP_ORDER as $groupKey) + @php $rows = $groups[$groupKey] ?? []; @endphp + @if (count($rows) > 0) +
    + {{ $groupMeta[$groupKey]['label'] }} + {{ count($rows) }} +
    +
    + + + + + + + + + + + + + + + @foreach ($rows as $row) + + + + + + + + + + + @endforeach + +
    {{ __('tables.firstname') }}{{ __('tables.lastname') }}{{ __('tables.email') }}{{ __('tables.phone') }}{{ __('tables.mobil') }}{{ __('tables.line') }}{{ __('tables.level') }}{{ __('tables.account_to') }}
    {{ $row['first_name'] }}{{ $row['last_name'] }}{{ $row['email'] }}{{ $row['phone'] !== '' ? $row['phone'] : '-' }}{{ $row['mobil'] !== '' ? $row['mobil'] : '-' }}{{ $row['line'] }}{{ $row['level_name'] !== '' ? $row['level_name'] : '-' }}{{ formatDate($row['due_date']) }}
    +
    + @endif + @endforeach + @endif +
    +
    diff --git a/resources/views/user/team/annual-fee.blade.php b/resources/views/user/team/annual-fee.blade.php new file mode 100644 index 00000000..d65ef637 --- /dev/null +++ b/resources/views/user/team/annual-fee.blade.php @@ -0,0 +1,5 @@ +@extends('layouts.layout-2') + +@section('content') + @include('user.team._annual_fee_groups', ['groups' => $groups, 'totalCount' => $totalCount]) +@endsection diff --git a/resources/views/user/team/firstlines.blade.php b/resources/views/user/team/firstlines.blade.php new file mode 100644 index 00000000..a5d123af --- /dev/null +++ b/resources/views/user/team/firstlines.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.layout-2') + +@section('content') +

    +
    {{ __('team.firstlines') }}
    +

    + +
    +
    +
    +
    + {{ __('team.firstlines') }} ({{ count($rows) }}) +
    {{ __('team.firstlines_copy') }}
    +
    +
    + {!! Form::open([ + 'url' => route('user_team_firstlines'), + 'method' => 'GET', + 'class' => 'form-inline justify-content-md-end', + ]) !!} + {!! Form::select('month', $filterMonths, (int) $selectedMonth, ['class' => 'form-control custom-select form-control-sm mr-2', 'onchange' => 'this.form.submit()']) !!} + {!! Form::select('year', array_combine($filterYears, $filterYears), (int) $selectedYear, ['class' => 'form-control custom-select form-control-sm', 'onchange' => 'this.form.submit()']) !!} + + {!! Form::close() !!} +
    +
    +
    + @if (count($rows) === 0) +
    +

    {{ __('team.firstlines_none') }}

    +
    + @else +
    + + + + + + + + + + + + + @foreach ($rows as $row) + + + + + + + + + @endforeach + +
    {{ __('tables.adviser') }}{{ __('team.career_level') }}{{ __('team.firstlines_points') }}{{ __('tables.phone') }}{{ __('tables.email') }}{{ __('team.active_since') }}
    +
    {{ $row['name'] }}
    + @unless ($row['is_active_account']) + {{ __('team.account_inactive') }} + @endunless +
    {{ $row['career_level'] }} + {{ \App\Services\Util::formatNumber($row['total_points']) }} + + @if ($row['phone'] !== '') + {{ $row['phone'] }} + @else + - + @endif + + {{ $row['email'] }} + + {{ $row['active_since'] ? formatDate($row['active_since']) : '-' }} +
    +
    + @endif +
    +@endsection diff --git a/resources/views/web/templates/checkout.blade.php b/resources/views/web/templates/checkout.blade.php index adbc0408..b47425eb 100644 --- a/resources/views/web/templates/checkout.blade.php +++ b/resources/views/web/templates/checkout.blade.php @@ -553,24 +553,10 @@
    -
    -
    - - - @if ($errors->has('customer_order_source')) - - @endif -
    -
    - - {!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500]) !!} + + {!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500, 'required' => 'required', 'placeholder' => 'Bitte konkret: z. B. Name der Person, die dich empfohlen hat, oder die genaue Messe/Veranstaltung']) !!} @if ($errors->has('customer_order_source_comment')) @endif diff --git a/routes/domains/crm.php b/routes/domains/crm.php index fa7d825e..e0fb6b46 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -87,6 +87,10 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::post('/user/edit', 'UserDataController@userEditStore')->name('user_edit'); Route::post('/user/data/store', 'UserDataController@userDataStore')->name('user_data_store'); + // Berater-Profilfoto (N6) + Route::post('/user/profile/upload/image', 'ProfileImageController@uploadImage')->name('user_profile_upload_image'); + Route::get('/user/profile/delete/image', 'ProfileImageController@deleteImage')->name('user_profile_delete_image'); + Route::get('/user/update_password', 'UserUpdatePasswordController@updatePassword')->name('user_update_password'); Route::post('/user/update_password', 'UserUpdatePasswordController@updatePasswordStore')->name('user_update_password'); @@ -149,6 +153,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::get('/user/team/export', 'User\TeamController@export')->name('user_team_export'); Route::post('/user/team/export/download', 'User\TeamController@userTeamExport')->name('user_team_export_download'); + Route::get('/user/team/firstlines', 'User\TeamController@firstlines')->name('user_team_firstlines'); Route::get('/user/team/points', 'User\TeamController@points')->name('user_team_points'); Route::get('/user/team/datatablePoints', 'User\TeamController@datatablePoints')->name('user_team_datatable_points'); Route::post('/user/team/load', 'User\TeamController@load')->name('user_team_load'); @@ -158,6 +163,8 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::get('/user/team/level-reports', 'User\TeamController@levelReports')->name('user_team_level_reports'); Route::get('/user/team/level-reports/export', 'User\TeamController@levelReportsExport')->name('user_team_level_reports_export'); + Route::get('/user/team/annual-fee', 'User\TeamController@annualFee')->name('user_team_annual_fee'); + Route::get('/user/abos/{view}', 'User\AboController@index')->name('user_abos'); Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail'); Route::post('/user/abos/update/{view}/{id}', 'User\AboController@update')->name('user_abos_update'); diff --git a/tests/Feature/AnnualFeeDueServiceTest.php b/tests/Feature/AnnualFeeDueServiceTest.php new file mode 100644 index 00000000..0ec09ca0 --- /dev/null +++ b/tests/Feature/AnnualFeeDueServiceTest.php @@ -0,0 +1,151 @@ + $name, + 'pos' => 1, + 'active' => 1, + 'default' => 0, + ]); +} + +function afdMakeUser(?int $sponsorId, ?string $paymentAccount, ?int $levelId = null, array $accountAttributes = []): User +{ + $account = UserAccount::create(array_merge([ + 'first_name' => 'Vorname', + 'last_name' => 'Nachname', + 'phone' => '', + 'mobil' => '', + ], $accountAttributes)); + + return User::forceCreate([ + 'email' => 'afd-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'account_id' => $account->id, + 'm_level' => $levelId ?? 1, + 'm_sponsor' => $sponsorId, + 'payment_account' => $paymentAccount, + ]); +} + +it('gruppiert Teampartner nach Jahresgebühr-Dringlichkeit mit korrekter Linie und Sortierung', function (): void { + $reference = Carbon::parse('2026-08-15 12:00:00'); + $level = afdMakeLevel('Berater'); + + $root = afdMakeUser(null, '2027-05-01', $level->id); + + // Linie 1 + $overdueLater = afdMakeUser($root->id, '2026-08-10', $level->id, [ + 'first_name' => 'Anna', 'last_name' => 'Alt', 'phone' => '030 123', 'mobil' => '0171 111', + ]); + $overdueEarlier = afdMakeUser($root->id, '2026-06-20', $level->id, [ + 'first_name' => 'Bernd', 'last_name' => 'Blum', + ]); + $dueOneMonth = afdMakeUser($root->id, '2026-09-10', $level->id, [ + 'first_name' => 'Clara', 'last_name' => 'Cato', + ]); + $outsideWindow = afdMakeUser($root->id, '2026-12-15', $level->id); // +4 Monate: nicht anzeigen + $withoutPaymentAccount = afdMakeUser($root->id, null, $level->id); // ohne payment_account: weglassen + + // Linie 2 (unter Anna) + $dueTwoMonths = afdMakeUser($overdueLater->id, '2026-10-10', $level->id, [ + 'first_name' => 'Doris', 'last_name' => 'Dahl', + ]); + $dueThreeMonths = afdMakeUser($overdueLater->id, '2026-11-10', $level->id, [ + 'first_name' => 'Emil', 'last_name' => 'Eck', + ]); + + $groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference); + + // Gruppen-Reihenfolge: überfällig zuerst + expect(array_keys($groups))->toBe([ + AnnualFeeDueService::GROUP_OVERDUE, + AnnualFeeDueService::GROUP_DUE_1_MONTH, + AnnualFeeDueService::GROUP_DUE_2_MONTHS, + AnnualFeeDueService::GROUP_DUE_3_MONTHS, + ]); + + // Überfällig: beide User, sortiert nach Fälligkeitsdatum aufsteigend (ältestes zuerst) + $overdue = $groups[AnnualFeeDueService::GROUP_OVERDUE]; + expect($overdue)->toHaveCount(2) + ->and($overdue[0]['user_id'])->toBe($overdueEarlier->id) + ->and($overdue[1]['user_id'])->toBe($overdueLater->id) + ->and($overdue[1]['first_name'])->toBe('Anna') + ->and($overdue[1]['last_name'])->toBe('Alt') + ->and($overdue[1]['email'])->toBe($overdueLater->email) + ->and($overdue[1]['phone'])->toBe('030 123') + ->and($overdue[1]['mobil'])->toBe('0171 111') + ->and($overdue[1]['line'])->toBe(1) + ->and($overdue[1]['level_name'])->toBe('Berater') + ->and($overdue[1]['due_date']->format('Y-m-d'))->toBe('2026-08-10'); + + // ≤ 1 Monat + $dueOne = $groups[AnnualFeeDueService::GROUP_DUE_1_MONTH]; + expect($dueOne)->toHaveCount(1) + ->and($dueOne[0]['user_id'])->toBe($dueOneMonth->id) + ->and($dueOne[0]['line'])->toBe(1); + + // ≤ 2 Monate (Linie 2) + $dueTwo = $groups[AnnualFeeDueService::GROUP_DUE_2_MONTHS]; + expect($dueTwo)->toHaveCount(1) + ->and($dueTwo[0]['user_id'])->toBe($dueTwoMonths->id) + ->and($dueTwo[0]['line'])->toBe(2); + + // ≤ 3 Monate (Linie 2) + $dueThree = $groups[AnnualFeeDueService::GROUP_DUE_3_MONTHS]; + expect($dueThree)->toHaveCount(1) + ->and($dueThree[0]['user_id'])->toBe($dueThreeMonths->id) + ->and($dueThree[0]['line'])->toBe(2); + + // +4 Monate und ohne payment_account tauchen nirgends auf + $allUserIds = collect($groups)->flatten(1)->pluck('user_id'); + expect($allUserIds)->not->toContain($outsideWindow->id) + ->and($allUserIds)->not->toContain($withoutPaymentAccount->id) + ->and($allUserIds)->not->toContain($root->id); +}); + +it('liefert leere Gruppen für ein Team ohne fällige Jahresgebühren', function (): void { + $reference = Carbon::parse('2026-08-15 12:00:00'); + $root = afdMakeUser(null, '2027-05-01'); + afdMakeUser($root->id, '2027-04-01'); + + $groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference); + + expect(collect($groups)->flatten(1))->toBeEmpty() + ->and(array_keys($groups))->toBe(AnnualFeeDueService::GROUP_ORDER); +}); + +it('rendert die Jahresgebühr-Seite mit Gruppen und Hinweistext', function (): void { + $reference = Carbon::parse('2026-08-15 12:00:00'); + $level = afdMakeLevel('Berater'); + $root = afdMakeUser(null, '2027-05-01', $level->id); + afdMakeUser($root->id, '2026-08-10', $level->id, [ + 'first_name' => 'Anna', 'last_name' => 'Alt', 'phone' => '030 123', + ]); + + $groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference); + + $html = view('user.team._annual_fee_groups', [ + 'groups' => $groups, + 'totalCount' => array_sum(array_map('count', $groups)), + ])->render(); + + expect($html)->toContain(__('team.annual_fee_title')) + ->toContain(__('team.annual_fee_hint')) + ->toContain(__('team.annual_fee_group_overdue')) + ->toContain('Anna') + ->toContain('Alt') + ->toContain('030 123'); +}); diff --git a/tests/Feature/BackofficeStatisticsAccessTest.php b/tests/Feature/BackofficeStatisticsAccessTest.php index b4574d26..f473f537 100644 --- a/tests/Feature/BackofficeStatisticsAccessTest.php +++ b/tests/Feature/BackofficeStatisticsAccessTest.php @@ -382,13 +382,16 @@ it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () { $checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php')); $orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php')); - expect($checkoutView)->toContain('customer_order_source'); + // Kundenentscheid F6: nur noch Freitext-Pflichtfeld, kein Dropdown mehr + expect($checkoutView)->toContain('customer_order_source_comment'); expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?'); + expect($checkoutView)->not->toContain('name="customer_order_source"'); expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'"); - expect($checkoutController)->toContain('customer_order_source'); + expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'required|string|max:500'"); expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\''); expect($checkoutRepository)->toContain('customer_order_source_comment'); expect($orderDetail)->toContain('getCustomerOrderSourceLabel'); + expect($orderDetail)->toContain('customer_order_source_comment'); $shoppingOrder = (new ShoppingOrder)->forceFill([ 'customer_order_source' => 'social_media', diff --git a/tests/Feature/BackofficeStatisticsDrilldownTest.php b/tests/Feature/BackofficeStatisticsDrilldownTest.php index 6bc692b2..d03e30ee 100644 --- a/tests/Feature/BackofficeStatisticsDrilldownTest.php +++ b/tests/Feature/BackofficeStatisticsDrilldownTest.php @@ -223,6 +223,7 @@ it('zaehlt den Berater selbst bei den Dashboard-KPIs zum 1000-Punkte-Shop mit', $partner = boMakeUser($root->id); boMakeSalesVolume($partner->id, 6, 2026, 1300, 2); + app(BackofficeDashboardService::class)->flushLineBucketsMemo(); // Struktur hat sich im selben Prozess geändert $kpis = app(TeamAboOverviewService::class)->teamKpis($root->fresh(), 6, 2026); expect($kpis['shop_1000'])->toBe(2); }); diff --git a/tests/Feature/BackofficeStatisticsListsTest.php b/tests/Feature/BackofficeStatisticsListsTest.php index 7138a14e..241daad5 100644 --- a/tests/Feature/BackofficeStatisticsListsTest.php +++ b/tests/Feature/BackofficeStatisticsListsTest.php @@ -7,6 +7,7 @@ use App\Models\UserAboItem; use App\Models\UserLevel; use App\Models\UserSalesVolume; use App\Services\Backoffice\BackofficeDashboardService; +use App\Services\Backoffice\BackofficeDrilldownService; use App\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -31,7 +32,7 @@ function bslMakeLevel(string $name, int $pos): UserLevel ]); } -function bslMakeUser(?int $sponsorId = null, ?int $levelId = null): User +function bslMakeUser(?int $sponsorId = null, ?int $levelId = null, ?string $activeDate = null): User { return User::forceCreate([ 'email' => 'bsl-'.uniqid('', true).'@example.com', @@ -39,7 +40,7 @@ function bslMakeUser(?int $sponsorId = null, ?int $levelId = null): User 'lang' => 'de', 'm_level' => $levelId ?? 1, 'm_sponsor' => $sponsorId, - 'active_date' => '2026-01-15', + 'active_date' => $activeDate ?? '2026-01-15', 'payment_account' => now()->addYear(), ]); } @@ -288,6 +289,8 @@ it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden bslMakeAbo($ben->id, 'me', '2026-07-01'); bslMakeAbo($ben->id, 'ot', '2026-07-02'); + $service->flushLineBucketsMemo(); // Struktur hat sich im selben Prozess geändert + $overview = $service->overview($root, 5, 2026); expect($overview['_meta']['source'])->toBe('snapshot') @@ -433,3 +436,103 @@ it('ignoriert Abo-Punkte geloeschter Produkte im Soll der Top-10-Umsatzliste', f ->and($byPoints[0]['user_id'])->toBe($anna->id) ->and($byPoints[0]['points_target'])->toBe(40.0); }); + +it('rankt Recruiter des Monats nach gesponserten Neupartnern inklusive ich', function (): void { + $root = bslMakeUser(); // active_date Januar — selbst kein Neupartner + $anna = bslMakeUser($root->id, null, '2026-06-05'); + $ben = bslMakeUser($root->id, null, '2026-06-10'); + $carla = bslMakeUser($anna->id, null, '2026-06-12'); // von Anna im selben Monat geworben + bslMakeUser($anna->id, null, '2026-05-12'); // Mai, zaehlt nicht + bslMakeUser($root->id); // Januar-Bestand, zaehlt nicht + + // abgelaufener Account: zaehlt nicht als Neupartner + $expired = bslMakeUser($root->id, null, '2026-06-20'); + $expired->forceFill(['payment_account' => now()->subDay()])->save(); + + $ranking = app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking']; + + expect($ranking)->toHaveCount(2) + ->and($ranking[0]['user_id'])->toBe($root->id) + ->and($ranking[0]['is_self'])->toBeTrue() + ->and($ranking[0]['line'])->toBe(0) + ->and($ranking[0]['new_partner_count'])->toBe(2) + ->and(collect($ranking[0]['partners'])->pluck('user_id')->sort()->values()->all()) + ->toBe(collect([$anna->id, $ben->id])->sort()->values()->all()) + ->and($ranking[1]['user_id'])->toBe($anna->id) + ->and($ranking[1]['is_self'])->toBeFalse() + ->and($ranking[1]['line'])->toBe(1) + ->and($ranking[1]['new_partner_count'])->toBe(1) + ->and($ranking[1]['partners'][0]['user_id'])->toBe($carla->id); + + // Carla erscheint in Roots Detailliste eingerueckt unter Anna + $annaEntry = collect($ranking[0]['partners'])->firstWhere('user_id', $anna->id); + expect($annaEntry['new_partner_count'])->toBe(1) + ->and($annaEntry['children'][0]['user_id'])->toBe($carla->id); +}); + +it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Kettenvolumen', function (): void { + $root = bslMakeUser(); + $anna = bslMakeUser($root->id, null, '2026-06-05'); // Neupartner von Root + $ben = bslMakeUser($anna->id, null, '2026-06-15'); // Neupartner von Anna (Kette) + + // Anna: Starterpaket (Status 1, Eigenpunkte) + Kundenumsatz (Status 2) + UserSalesVolume::forceCreate([ + 'user_id' => $anna->id, + 'month' => 6, + 'year' => 2026, + 'points' => 400, + 'status' => 1, + ]); + UserSalesVolume::forceCreate([ + 'user_id' => $anna->id, + 'month' => 6, + 'year' => 2026, + 'points' => 100, + 'status' => 2, + ]); + // Ben: nur Starterpaket + UserSalesVolume::forceCreate([ + 'user_id' => $ben->id, + 'month' => 6, + 'year' => 2026, + 'points' => 200, + 'status' => 1, + ]); + + $ranking = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'])->keyBy('user_id'); + + // Root: Annas 500 (400 eigene + 100 Kunden) + rekursiv Bens 200 + expect($ranking->get($root->id)['volume_points'])->toBe(700.0) + ->and($ranking->get($root->id)['partners'][0]['total_points'])->toBe(500.0) + ->and($ranking->get($root->id)['partners'][0]['children'][0]['total_points'])->toBe(200.0) + // Anna selbst: nur Bens Punkte + ->and($ranking->get($anna->id)['volume_points'])->toBe(200.0); +}); + +it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', function (): void { + $root = bslMakeUser(); + $partner = bslMakeUser($root->id, null, '2026-06-15'); + + UserSalesVolume::forceCreate([ + 'user_id' => $partner->id, + 'month' => 6, + 'year' => 2026, + 'points' => 100, + 'status' => 1, // Starterpaket / Eigenpunkte + ]); + UserSalesVolume::forceCreate([ + 'user_id' => $partner->id, + 'month' => 6, + 'year' => 2026, + 'points' => 250, + 'status' => 2, // Kundenpunkte + ]); + + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026); + + expect($details['rows'])->toHaveCount(1) + ->and($details['rows'][0]['customer_points'])->toBe(250.0) + ->and($details['rows'][0]['total_points'])->toBe(350.0) + ->and($details['summary']['customer_points'])->toBe(250.0) + ->and($details['summary']['total_points'])->toBe(350.0); +}); diff --git a/tests/Feature/CheckoutOrderSourceValidationTest.php b/tests/Feature/CheckoutOrderSourceValidationTest.php new file mode 100644 index 00000000..38ef8f14 --- /dev/null +++ b/tests/Feature/CheckoutOrderSourceValidationTest.php @@ -0,0 +1,72 @@ +replace($input); + + $method = new ReflectionMethod(CheckoutController::class, 'validateCheckoutData'); + + return $method->invoke(app(CheckoutController::class)); +} + +/** + * @return array + */ +function cosvBaseCheckoutInput(array $overrides = []): array +{ + return array_merge([ + 'is_from' => 'shopping', + 'billing_salutation' => 'Frau', + 'billing_firstname' => 'Erika', + 'billing_lastname' => 'Muster', + 'billing_email' => 'erika@example.com', + 'billing_address' => 'Musterweg 1', + 'billing_zipcode' => '12345', + 'billing_city' => 'Berlin', + 'accepted_data_checkbox' => '1', + ], $overrides); +} + +it('verlangt den Herkunfts-Freitext bei Shop-Kundenbestellungen', function () { + $validator = cosvValidateCheckout(cosvBaseCheckoutInput()); + + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue(); + expect($validator->errors()->first('customer_order_source_comment'))->toContain('aufmerksam geworden'); +}); + +it('akzeptiert den Checkout ohne das frühere Herkunfts-Dropdown', function () { + $validator = cosvValidateCheckout(cosvBaseCheckoutInput([ + 'customer_order_source_comment' => 'Empfehlung von Maria Mustermann', + ])); + + expect($validator->fails())->toBeFalse(); + expect($validator->errors()->has('customer_order_source'))->toBeFalse(); +}); + +it('begrenzt den Herkunfts-Freitext auf 500 Zeichen', function () { + $validator = cosvValidateCheckout(cosvBaseCheckoutInput([ + 'customer_order_source_comment' => str_repeat('a', 501), + ])); + + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue(); +}); + +it('verlangt keinen Herkunfts-Freitext außerhalb des Shop-Checkouts', function () { + $validator = cosvValidateCheckout(cosvBaseCheckoutInput([ + 'is_from' => 'user', + ])); + + expect($validator->fails())->toBeFalse(); +}); diff --git a/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php b/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php index 82437e85..5ca0b395 100644 --- a/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php +++ b/tests/Feature/Incentive/IncentivePartnerRepairSponsorChangeTest.php @@ -11,6 +11,7 @@ use App\Models\ShippingCountry; use App\Models\ShoppingOrder; use App\Models\ShoppingOrderItem; use App\Models\ShoppingUser; +use App\Models\UserSalesVolume; use App\Models\UserShop; use App\Services\Incentive\IncentivePointsLogRepairService; use App\Services\Incentive\IncentiveTracker; @@ -188,6 +189,25 @@ it('trackNewPartner zaehlt Bestellungen am letzten Tag des Qualifikationszeitrau expect(IncentiveNewPartner::where('user_id', $partner->id)->exists())->toBeTrue(); }); +it('syncMissingTrackingPartners zaehlt Bestellungen am letzten Tag des Qualifikationszeitraums (Randtag-Fall)', function () { + Carbon::setTestNow(Carbon::parse('2026-08-01 04:00:00')); + + $incentive = repairTestCreateIncentive(); + $sponsor = repairTestCreateUser(); + $participant = IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $sponsor->id, + ]); + + $partner = repairTestCreateUser($sponsor->id); + repairTestCreateStarterKitOrder($partner, $sponsor, '2026-07-31 23:15:00'); + + $added = app(IncentivePointsLogRepairService::class)->syncMissingTrackingPartners($participant); + + expect($added)->toBe(1) + ->and(IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $partner->id)->exists())->toBeTrue(); +}); + it('removeStaleSponsorTrackings verschiebt Partner nach Sponsorwechsel zum aktuellen Sponsor', function () { $incentive = repairTestCreateIncentive(); @@ -246,6 +266,145 @@ it('removeStaleSponsorTrackings verschiebt Partner nach Sponsorwechsel zum aktue expect((int) $participantNew->fresh()->qualified_partners)->toBe(1); }); +it('removeStaleSponsorTrackings behaelt Tracking und Punkte-Logs bei leerem Sponsor (m_sponsor = NULL)', function () { + $incentive = repairTestCreateIncentive(); + $sponsor = repairTestCreateUser(); + $participant = IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $sponsor->id, + ]); + + $partner = repairTestCreateUser($sponsor->id); + $tracking = IncentiveNewPartner::create([ + 'participant_id' => $participant->id, + 'user_id' => $partner->id, + 'registered_at' => Carbon::parse('2026-05-10 09:00:00'), + ]); + IncentivePointsLog::create([ + 'participant_id' => $participant->id, + 'type' => 'partner', + 'source_type' => User::class, + 'source_id' => $partner->id, + 'source_label' => 'Partner', + 'month' => 5, + 'year' => 2026, + 'points_onetime' => 600, + 'points_accumulated' => 0, + 'incentive_new_partner_id' => $tracking->id, + ]); + + // Admin-Umbau in Arbeit: Sponsor temporaer entfernt — kein Sponsorwechsel + $partner->m_sponsor = null; + $partner->save(); + + $removed = app(IncentivePointsLogRepairService::class)->removeStaleSponsorTrackings($participant); + + expect($removed)->toBe(0) + ->and(IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $partner->id)->exists())->toBeTrue() + ->and(IncentivePointsLog::where('participant_id', $participant->id)->count())->toBe(1); + + $participant->recalculateFromTrackingTables()->save(); + expect((int) $participant->fresh()->total_points)->toBe(600); +}); + +it('removeStaleSponsorTrackings behaelt Trackings geloeschter User (wie bisher)', function () { + $incentive = repairTestCreateIncentive(); + $sponsor = repairTestCreateUser(); + $participant = IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $sponsor->id, + ]); + + $partner = repairTestCreateUser($sponsor->id); + IncentiveNewPartner::create([ + 'participant_id' => $participant->id, + 'user_id' => $partner->id, + 'registered_at' => now(), + ]); + + $partner->delete(); + + $removed = app(IncentivePointsLogRepairService::class)->removeStaleSponsorTrackings($participant); + + expect($removed)->toBe(0) + ->and(IncentiveNewPartner::where('participant_id', $participant->id)->exists())->toBeTrue(); +}); + +it('incentive:calculate repariert FKs vor der Stale-Bereinigung — keine Waisen-Logs beim alten Sponsor nach Umhaengen', function () { + $incentive = repairTestCreateIncentive(); + + $oldSponsor = repairTestCreateUser(); + $newSponsor = repairTestCreateUser(); + + $participantOld = IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $oldSponsor->id, + ]); + $participantNew = IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $newSponsor->id, + ]); + + // Partner wurde unter dem alten Sponsor getrackt, haengt inzwischen unter dem neuen + $partner = repairTestCreateUser($newSponsor->id); + repairTestCreateStarterKitOrder($partner, $newSponsor, '2026-05-10 09:00:00'); + + $staleTracking = IncentiveNewPartner::create([ + 'participant_id' => $participantOld->id, + 'user_id' => $partner->id, + 'registered_at' => Carbon::parse('2026-05-10 09:00:00'), + ]); + IncentivePointsLog::create([ + 'participant_id' => $participantOld->id, + 'type' => 'partner', + 'source_type' => User::class, + 'source_id' => $partner->id, + 'source_label' => 'Partner', + 'month' => 5, + 'year' => 2026, + 'points_onetime' => 600, + 'points_accumulated' => 0, + 'incentive_new_partner_id' => $staleTracking->id, + ]); + + // Umsatz-Log OHNE FK (Altbestand): ueberlebte frueher die Stale-Bereinigung als Waise + $sv = UserSalesVolume::create([ + 'user_id' => $partner->id, + 'month' => 5, + 'year' => 2026, + 'date' => '12.05.2026', + 'points' => 25, + 'total_net' => 90, + 'status_points' => 1, + 'status' => 2, + 'message' => 'SV 5/2026', + ]); + IncentivePointsLog::create([ + 'participant_id' => $participantOld->id, + 'type' => 'partner', + 'source_type' => UserSalesVolume::class, + 'source_id' => $sv->id, + 'source_label' => 'SV 5/2026', + 'month' => 5, + 'year' => 2026, + 'points_onetime' => 0, + 'points_accumulated' => 25, + 'user_sales_volume_id' => $sv->id, + 'incentive_new_partner_id' => null, + ]); + + $this->artisan('incentive:calculate', ['incentive_id' => $incentive->id])->assertExitCode(0); + + // Alter Sponsor: Tracking + ALLE Logs weg (auch der Waisen-Log ohne FK), Punkte 0 + expect(IncentiveNewPartner::where('participant_id', $participantOld->id)->exists())->toBeFalse() + ->and(IncentivePointsLog::where('participant_id', $participantOld->id)->count())->toBe(0) + ->and((int) $participantOld->fresh()->total_points)->toBe(0); + + // Neuer Sponsor: Partner nachgezogen inkl. Einmal- und Umsatzpunkten (keine Doppelzaehlung) + expect((int) $participantNew->fresh()->qualified_partners)->toBe(1) + ->and((int) $participantNew->fresh()->total_points)->toBe(625); +}); + it('removeStaleSponsorTrackings laesst korrekte Trackings unangetastet', function () { $incentive = repairTestCreateIncentive(); $sponsor = repairTestCreateUser(); diff --git a/tests/Feature/LiveCommissionServiceTest.php b/tests/Feature/LiveCommissionServiceTest.php index 29228acf..952d1421 100644 --- a/tests/Feature/LiveCommissionServiceTest.php +++ b/tests/Feature/LiveCommissionServiceTest.php @@ -62,7 +62,7 @@ it('berechnet den Live-Snapshot ueber den gemeinsamen Lauf und cached ihn', func ->and($snapshot['failed'])->toBeFalse() ->and($snapshot['payline_points'])->toBe(0.0) ->and($snapshot['paylines'])->toBe(0) - ->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeTrue(); + ->and(Cache::has("live_business_v3_{$user->id}_7_2026"))->toBeTrue(); }); it('faellt bei fehlschlagender Live-Berechnung auf Null-Werte zurueck und cached den Fehler nicht', function (): void { @@ -75,14 +75,14 @@ it('faellt bei fehlschlagender Live-Berechnung auf Null-Werte zurueck und cached expect($commission['total'])->toBe(0.0) ->and($commission['is_live'])->toBeTrue() - ->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeFalse(); + ->and(Cache::has("live_business_v3_{$user->id}_7_2026"))->toBeFalse(); }); it('cached die Live-Provision fuer Folgeaufrufe', function (): void { $user = lcsMakeUser(); Cache::put( - "live_business_{$user->id}_7_2026", + "live_business_v3_{$user->id}_7_2026", ['payline_points' => 12.5, 'paylines' => 2, 'pp' => 99.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true, 'failed' => false], now()->addHour() ); @@ -149,3 +149,35 @@ it('rendert die Provisions-Kachel auf der Monatsstatistik-Card', function (): vo ->toContain(__('home.commission_today')) ->toContain('160,75 €'); }); + +it('reicht Level- und Quali-Daten im Snapshot durch (N1/N4)', function (): void { + foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) { + if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) { + Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) { + $type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable(); + }); + } + } + + $user = lcsMakeUser(); + + UserBusiness::forceCreate([ + 'user_id' => $user->id, + 'month' => 6, + 'year' => 2026, + 'version' => 2, + 'payline_points' => 900.0, + 'payline_points_qual_kp' => 950.0, + 'sales_volume_points_KP_sum' => 300.0, + 'qual_user_level' => ['name' => 'Gold', 'paylines' => 3], + 'qual_user_level_next' => ['name' => 'Diamant', 'qual_pp' => 2000, 'qual_kp' => 600], + ]); + + $snapshot = app(LiveCommissionService::class)->snapshot($user, 6, 2026); + + expect($snapshot['qual_level']['name'])->toBe('Gold') + ->and($snapshot['next_level']['name'])->toBe('Diamant') + ->and($snapshot['payline_points_qual_kp'])->toBe(950.0) + ->and($snapshot['personal_points'])->toBe(300.0) + ->and($snapshot['paylines'])->toBe(3); +}); diff --git a/tests/Feature/PaylineForecastServiceTest.php b/tests/Feature/PaylineForecastServiceTest.php index e150e977..fe9efced 100644 --- a/tests/Feature/PaylineForecastServiceTest.php +++ b/tests/Feature/PaylineForecastServiceTest.php @@ -252,3 +252,78 @@ it('rendert die Payline-Prognose auf der Monatsstatistik-Card', function (): voi ->toContain('560') ->toContain(__('home.forecast_done')); }); + +it('zaehlt neue Abos der Payline-Mitglieder im Monat', function (): void { + $root = pflMakeUser(); + $anna = pflMakeUser($root->id); + $fremd = pflMakeUser(); + + pflSeedContext($root, [$anna->id]); + + pflMakeAbo($anna->id, 'me', '2026-07-10', 20)->update(['start_date' => '2026-07-03']); // neu im Juli + pflMakeAbo($anna->id, 'ot', '2026-07-25', 30)->update(['start_date' => '2026-07-05']); // neu im Juli + pflMakeAbo($anna->id, 'ot', '2026-07-25', 40)->update(['start_date' => '2026-06-10']); // Bestand, nicht neu + pflMakeAbo($fremd->id, 'me', '2026-07-10', 99)->update(['start_date' => '2026-07-04']); // ausserhalb Payline + + expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2); +}); + +it('prognostiziert Level und Provision zum letzten Liefertag', function (): void { + // Test-Schema hinkt der Produktions-Tabelle hinterher (bekannte Stolperfalle) + foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) { + if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) { + Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) { + $type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable(); + }); + } + } + + $root = pflMakeUser(); + $anna = pflMakeUser($root->id); // Ebene 1 + + pflSeedContext($root, [$anna->id]); + + $levelBase = [ + 'qual_kp' => 0, 'growth_bonus' => 0, + 'pr_line_1' => 7, 'pr_line_2' => 5, 'pr_line_3' => 0, 'pr_line_4' => 0, + 'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0, + ]; + UserBusiness::forceCreate([ + 'user_id' => $root->id, + 'month' => 7, + 'year' => 2026, + 'version' => 1, + 'payline_points' => 800.0, + 'payline_points_qual_kp' => 800.0, + 'sales_volume_points_KP_sum' => 500.0, + 'commission_pp_total' => 0, + 'commission_shop_sales' => 0, + 'commission_growth_total' => 0, + 'qual_user_level' => array_merge($levelBase, ['name' => 'Gold', 'paylines' => 2, 'qual_pp' => 500]), + 'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 2000, 'qual_kp' => 600]), + ]); + + // Heutiger TP-Laufstand von Anna (Ebene 1): 800 Punkte + UserSalesVolume::forceCreate([ + 'user_id' => $anna->id, 'month' => 7, 'year' => 2026, + 'points' => 800, 'status' => 1, 'month_TP_points' => 800, 'month_shop_points' => 0, + ]); + + // Offene Ausfuehrungen bis zum 25.: Anna +100 (Ebene 1), eigenes Kundenabo +50 (persoenlich) + pflMakeAbo($anna->id, 'ot', '2026-07-20', 100); + pflMakeAbo($root->id, 'ot', '2026-07-10', 50); + + $forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026); + + // Persoenlich: 500 + 50 = 550 -> zu Diamant (600) fehlen 50 + // Payline: Ebene 1 = 800 + 100 = 900, kein KP-Ueberschuss -> zu 2000 fehlen 1100 + // Provision (Level Gold, da nicht erreicht): 900 x 7 % = 63 + expect($forecast)->not->toBeNull() + ->and($forecast['next_name'])->toBe('Diamant') + ->and($forecast['reached'])->toBeFalse() + ->and($forecast['missing_pp'])->toBe(1100.0) + ->and($forecast['missing_personal'])->toBe(50.0) + ->and($forecast['personal_forecast'])->toBe(550.0) + ->and($forecast['estimated_level_name'])->toBe('Gold') + ->and($forecast['estimated_commission'])->toBe(63.0); +}); diff --git a/tests/Feature/ProfileImageTest.php b/tests/Feature/ProfileImageTest.php new file mode 100644 index 00000000..416a1d22 --- /dev/null +++ b/tests/Feature/ProfileImageTest.php @@ -0,0 +1,79 @@ + 'Pia', + 'last_name' => 'Probe', + ]); + + return User::forceCreate([ + 'email' => 'pi-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'account_id' => $account->id, + 'payment_account' => now()->addYear(), + ]); +} + +it('verweigert den Foto-Upload ohne Einwilligung', function (): void { + URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); + Storage::fake('public'); + + $user = piMakeUser(); + $this->actingAs($user); + Request::merge(['profile_image_consent' => null]); + + app(ProfileImageController::class)->uploadImage(); + + expect(session('alert-danger'))->toBe(__('account.profile_image_consent_required')) + ->and($user->account->fresh()->profile_image)->toBeNull(); +}); + +it('speichert ein Slim-Upload nur fuer jpg/png und liefert die Metadaten', function (): void { + Storage::fake('public'); + + $image = [ + 'input' => ['size' => 1234], + 'output' => ['data' => 'BINARYDATA', 'name' => 'Mein Foto!.jpg', 'type' => 'image/jpeg'], + ]; + + $stored = Slim::storeCroppedImage($image, 'images/profile'); + + expect($stored)->not->toBeNull() + ->and($stored['ext'])->toBe('jpg') + ->and($stored['mime'])->toBe('image/jpeg') + ->and($stored['size'])->toBe(1234); + Storage::disk('public')->assertExists('images/profile/'.$stored['filename']); + + $gif = ['input' => [], 'output' => ['data' => 'X', 'name' => 'a.gif', 'type' => 'image/gif']]; + expect(Slim::storeCroppedImage($gif, 'images/profile'))->toBeNull(); +}); + +it('loescht das Profilfoto samt Datei', function (): void { + URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); + Storage::fake('public'); + + $user = piMakeUser(); + Storage::disk('public')->put('images/profile/test_pic.jpg', 'BINARY'); + $user->account->forceFill(['profile_image' => 'test_pic.jpg'])->save(); + + $this->actingAs($user); + app(ProfileImageController::class)->deleteImage(); + + expect($user->account->fresh()->profile_image)->toBeNull(); + Storage::disk('public')->assertMissing('images/profile/test_pic.jpg'); +}); diff --git a/tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php b/tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php new file mode 100644 index 00000000..0a1c6fa5 --- /dev/null +++ b/tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php @@ -0,0 +1,433 @@ + 'vp-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_sponsor' => $sponsorId, + ]); + + $accountId = DB::table('user_accounts')->insertGetId([ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $user->account_id = $accountId; + $user->save(); + + return $user->fresh(); +} + +function vpRepairCreateVpProduct(int $vpPoints = 200): Product +{ + $id = DB::table('products')->insertGetId([ + 'name' => 'Starterkit '.uniqid(), + 'title' => 'T', + 'is_membership_only' => false, + 'sponsor_buying_points' => true, + 'sponsor_buying_points_amount' => $vpPoints, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return Product::findOrFail($id); +} + +function vpRepairCreatePaidOrder(User $buyer, User $shopOwner, Product $product, bool $paid = true, ?string $createdAt = null): ShoppingOrder +{ + $country = Country::firstOrCreate(['code' => 'DE'], [ + 'phone' => '49', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Германия', + ]); + $shipping = Shipping::create(['name' => 'S', 'active' => true]); + $shippingCountry = ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]); + $userShop = UserShop::create([ + 'user_id' => $shopOwner->id, + 'name' => 'T'.substr(md5(uniqid('', true)), 0, 10), + 'slug' => 't-'.substr(md5(uniqid('', true)), 0, 10), + 'active' => true, + ]); + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $buyer->id, + 'member_id' => $shopOwner->id, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_email' => 'su-'.uniqid('', true).'@example.com', + 'is_from' => 'wizard', + ]); + + $order = ShoppingOrder::create([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $buyer->id, + 'country_id' => $shippingCountry->id, + 'user_shop_id' => $userShop->id, + 'payment_for' => 1, + 'paid' => $paid, + 'txaction' => $paid ? 'paid' : 'appointed', + 'mode' => 'test', + 'total' => 100, + 'subtotal' => 90, + ]); + ShoppingOrderItem::create([ + 'shopping_order_id' => $order->id, + 'product_id' => $product->id, + 'qty' => 1, + 'price' => 50, + ]); + + if ($createdAt) { + $order->created_at = Carbon::parse($createdAt); + $order->save(); + } + + return $order->fresh(); +} + +it('bucht fehlende VP-Punkte des Sponsors für bezahlte Bestellungen des Monats nach', function () { + $sponsor = vpRepairCreateUser(null, 'Jasmin', 'Grund'); + $buyer = vpRepairCreateUser($sponsor->id, 'Madeleine', 'Schrade'); + $product = vpRepairCreateVpProduct(200); + $order = vpRepairCreatePaidOrder($buyer, $sponsor, $product); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['created'])->toHaveCount(1); + + $row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first(); + expect($row)->not->toBeNull(); + expect((int) $row->points)->toBe(200); + expect((int) $row->status_points)->toBe(2); + expect((int) $row->month)->toBe(8); + expect((int) $row->year)->toBe(2026); + expect($row->shopping_order_id)->toBe($order->id); + expect($row->info)->toBe('VP: Madeleine Schrade | '.$product->name); +}); + +it('ist idempotent - zweiter Lauf bucht nichts doppelt', function () { + $sponsor = vpRepairCreateUser(); + $buyer = vpRepairCreateUser($sponsor->id); + vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct()); + + $service = app(SponsorBuyingPointsRepairService::class); + $service->repair(8, 2026); + $second = $service->repair(8, 2026); + + expect($second['created'])->toHaveCount(0); + expect($second['skipped_existing'])->toBe(1); + expect(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1); +}); + +it('erkennt vorhandene Live-Buchungen (ohne shopping_order_id) und bucht nicht doppelt', function () { + $sponsor = vpRepairCreateUser(null, 'Steffi', 'Karrer'); + $buyer = vpRepairCreateUser($sponsor->id, 'Dino', 'Portaro'); + $product = vpRepairCreateVpProduct(200); + vpRepairCreatePaidOrder($buyer, $sponsor, $product); + + // Live-Zeile wie Payment::addSponsorBuyingPoints (shopping_order_id = null) + UserSalesVolume::create([ + 'user_id' => $sponsor->id, + 'shopping_order_id' => null, + 'month' => 8, + 'year' => 2026, + 'points' => 200, + 'status_points' => 2, + 'total_net' => 0, + 'info' => 'VP: Dino Portaro | '.$product->name, + 'status' => 5, + ]); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['created'])->toHaveCount(0); + expect($result['skipped_existing'])->toBe(1); + expect(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1); +}); + +it('ignoriert unbezahlte Bestellungen und Käufer ohne Sponsor', function () { + $sponsor = vpRepairCreateUser(); + $product = vpRepairCreateVpProduct(); + + $buyerUnpaid = vpRepairCreateUser($sponsor->id); + vpRepairCreatePaidOrder($buyerUnpaid, $sponsor, $product, paid: false); + + $buyerNoSponsor = vpRepairCreateUser(null); + vpRepairCreatePaidOrder($buyerNoSponsor, $sponsor, $product); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['created'])->toHaveCount(0); + expect(UserSalesVolume::where('status', 5)->count())->toBe(0); +}); + +it('lässt Bestellungen aus anderen Monaten unangetastet', function () { + $sponsor = vpRepairCreateUser(); + $buyer = vpRepairCreateUser($sponsor->id); + vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct(), createdAt: '2026-06-19 09:32:05'); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['checked'])->toBe(0); + expect(UserSalesVolume::where('status', 5)->count())->toBe(0); +}); + +it('bucht im dry-run nichts, listet aber die Kandidaten', function () { + $sponsor = vpRepairCreateUser(); + $buyer = vpRepairCreateUser($sponsor->id); + vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct()); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026, dryRun: true); + + expect($result['created'])->toHaveCount(1); + expect(UserSalesVolume::where('status', 5)->count())->toBe(0); +}); + +it('verweigert Buchungen in fremde Monate ohne --force', function () { + $this->artisan('sales:repair-sponsor-vp', ['--month' => 6, '--year' => 2026]) + ->assertFailed(); + + $this->artisan('sales:repair-sponsor-vp', ['--month' => 6, '--year' => 2026, '--dry-run' => true]) + ->assertSuccessful(); +}); + +it('heilt den Monatswechsel: Vormonats-Bestellung ohne Buchung wird in den Zielmonat nachgebucht', function () { + $sponsor = vpRepairCreateUser(null, 'Sina', 'Sponsor'); + $buyer = vpRepairCreateUser($sponsor->id, 'Britta', 'Boundary'); + $product = vpRepairCreateVpProduct(200); + vpRepairCreatePaidOrder($buyer, $sponsor, $product, true, '2026-07-31 23:50:00'); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['created'])->toHaveCount(1); + $row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first(); + expect((int) $row->month)->toBe(8)->and((int) $row->year)->toBe(2026); +}); + +it('erkennt Alt-Zeilen zaehlend: zwei gleiche Bestellungen, eine Alt-Buchung - die zweite wird nachgebucht', function () { + $sponsor = vpRepairCreateUser(null, 'Sonja', 'Sponsor'); + $buyer = vpRepairCreateUser($sponsor->id, 'Doppel', 'Besteller'); + $product = vpRepairCreateVpProduct(200); + vpRepairCreatePaidOrder($buyer, $sponsor, $product); + vpRepairCreatePaidOrder($buyer, $sponsor, $product); + + // Alt-Zeile aus dem frueheren Live-Pfad: ohne Order-Referenz, gleicher info-Text + UserSalesVolume::forceCreate([ + 'user_id' => $sponsor->id, + 'shopping_order_id' => null, + 'month' => 8, + 'year' => 2026, + 'points' => 200, + 'status_points' => 2, + 'status' => 5, + 'info' => 'VP: Doppel Besteller | '.$product->name, + ]); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['created'])->toHaveCount(1) + ->and($result['skipped_existing'])->toBe(1) + ->and(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(2); +}); + +it('ist gegen Umbenennungen immun, wenn die Live-Buchung eine Order-Referenz traegt', function () { + $sponsor = vpRepairCreateUser(null, 'Rita', 'Sponsor'); + $buyer = vpRepairCreateUser($sponsor->id, 'Madelaine', 'Schrade'); + $product = vpRepairCreateVpProduct(200); + $order = vpRepairCreatePaidOrder($buyer, $sponsor, $product); + + // Live-Buchung MIT Order-Referenz, info mit altem (Tippfehler-)Namen + UserSalesVolume::forceCreate([ + 'user_id' => $sponsor->id, + 'shopping_order_id' => $order->id, + 'month' => 8, + 'year' => 2026, + 'points' => 200, + 'status_points' => 2, + 'status' => 5, + 'info' => 'VP: Madelaine Schrade | '.$product->name, + ]); + + // Name wird korrigiert - der info-Text passt nicht mehr, die Order-Referenz schon + $buyer->account->forceFill(['first_name' => 'Madeleine'])->save(); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['created'])->toHaveCount(0) + ->and($result['skipped_existing'])->toBe(1) + ->and(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1); +}); + +it('schreibt im Live-Pfad die Order-Referenz mit', function () { + // Test-Schema hinkt der Produktion hinterher: status_turnover ist dort nullable + Schema::table('user_sales_volumes', function ($table) { + $table->integer('status_turnover')->nullable()->change(); + }); + + $sponsor = vpRepairCreateUser(null, 'Lena', 'Live'); + $buyer = vpRepairCreateUser($sponsor->id, 'Nora', 'Neu'); + $product = vpRepairCreateVpProduct(400); + $order = vpRepairCreatePaidOrder($buyer, $sponsor, $product); + + Payment::addSponsorBuyingPoints($buyer->fresh(), $product, $order); + + $row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first(); + expect($row)->not->toBeNull() + ->and($row->shopping_order_id)->toBe($order->id) + ->and((int) $row->points)->toBe(400); + + // Repair danach: nichts doppelt + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + expect($result['created'])->toHaveCount(0); +}); + +it('bucht nach Umhaengung nicht doppelt, sondern meldet den anderen Sponsor', function () { + $oldSponsor = vpRepairCreateUser(null, 'Alte', 'Firma'); + $newSponsor = vpRepairCreateUser(null, 'Christina', 'Schuder'); + $buyer = vpRepairCreateUser($oldSponsor->id, 'Anna', 'Schall'); + $product = vpRepairCreateVpProduct(200); + vpRepairCreatePaidOrder($buyer, $oldSponsor, $product); + + // Live-Buchung lief beim ALTEN Sponsor (ohne Order-Referenz, Vormonat) + UserSalesVolume::forceCreate([ + 'user_id' => $oldSponsor->id, + 'shopping_order_id' => null, + 'month' => 7, + 'year' => 2026, + 'points' => 200, + 'status_points' => 2, + 'status' => 5, + 'info' => 'VP: Anna Schall | '.$product->name, + ]); + + // Danach wurde Anna umgehaengt + $buyer->forceFill(['m_sponsor' => $newSponsor->id])->save(); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['created'])->toHaveCount(0) + ->and($result['skipped_existing'])->toBe(1) + ->and($result['skipped_other_sponsor'])->toHaveCount(1) + ->and($result['skipped_other_sponsor'][0]['booked_sponsor_id'])->toBe($oldSponsor->id) + ->and($result['skipped_other_sponsor'][0]['current_sponsor_id'])->toBe($newSponsor->id) + ->and(UserSalesVolume::where('status', 5)->count())->toBe(1); +}); + +it('bucht VP-Punkte vom Firmenaccount automatisch auf den Berater um (Zielmonat: in place)', function () { + config(['mivita.company_user_id' => 999999]); + $company = vpRepairCreateUser(null, 'mivita', 'care gmbh'); + $company->forceFill(['id' => 999999])->save(); + $company = User::find(999999); + + $sponsor = vpRepairCreateUser(null, 'Christina', 'Schuder'); + $buyer = vpRepairCreateUser($sponsor->id, 'Anna', 'Schall'); + $product = vpRepairCreateVpProduct(200); + $order = vpRepairCreatePaidOrder($buyer, $sponsor, $product); + + // Live-Buchung landete beim Firmenaccount (gleicher Monat, ohne Order-Referenz) + UserSalesVolume::forceCreate([ + 'user_id' => $company->id, + 'shopping_order_id' => null, + 'month' => 8, + 'year' => 2026, + 'points' => 200, + 'status_points' => 2, + 'status' => 5, + 'info' => 'VP: Anna Schall | '.$product->name, + ]); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['rebooked'])->toHaveCount(1) + ->and($result['created'])->toHaveCount(0) + ->and($result['skipped_other_sponsor'])->toHaveCount(0); + + // Genau EINE Zeile, jetzt beim Berater und mit Order-Referenz + expect(UserSalesVolume::where('status', 5)->count())->toBe(1); + $row = UserSalesVolume::where('status', 5)->first(); + expect($row->user_id)->toBe($sponsor->id) + ->and($row->shopping_order_id)->toBe($order->id) + ->and((int) $row->points)->toBe(200); +}); + +it('bucht aus abgeschlossenem Monat per Gegenbuchung um, ohne den alten Monat anzufassen', function () { + config(['mivita.company_user_id' => 999999]); + $company = vpRepairCreateUser(null, 'mivita', 'care gmbh'); + $company->forceFill(['id' => 999999])->save(); + $company = User::find(999999); + + $sponsor = vpRepairCreateUser(null, 'Christina', 'Schuder'); + $buyer = vpRepairCreateUser($sponsor->id, 'Anna', 'Schall'); + $product = vpRepairCreateVpProduct(200); + vpRepairCreatePaidOrder($buyer, $sponsor, $product, true, '2026-07-31 23:50:00'); + + // Live-Buchung beim Firmenaccount im (abgeschlossenen) Juli + $julyRow = UserSalesVolume::forceCreate([ + 'user_id' => $company->id, + 'shopping_order_id' => null, + 'month' => 7, + 'year' => 2026, + 'points' => 200, + 'status_points' => 2, + 'status' => 5, + 'info' => 'VP: Anna Schall | '.$product->name, + ]); + + $result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + + expect($result['rebooked'])->toHaveCount(1); + + // Juli-Zeile unangetastet + expect($julyRow->fresh()->user_id)->toBe($company->id) + ->and((int) $julyRow->fresh()->points)->toBe(200) + ->and((int) $julyRow->fresh()->month)->toBe(7); + + // August: +200 beim Berater (mit Order-Referenz), -200 beim Firmenaccount + $sponsorRow = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first(); + expect($sponsorRow)->not->toBeNull() + ->and((int) $sponsorRow->points)->toBe(200) + ->and((int) $sponsorRow->month)->toBe(8); + + $stornoRow = UserSalesVolume::where('user_id', $company->id)->where('month', 8)->where('status', 5)->first(); + expect($stornoRow)->not->toBeNull() + ->and((int) $stornoRow->points)->toBe(-200) + ->and($stornoRow->info)->toStartWith('VP-Storno:'); + + // Zweiter Lauf: idempotent, nichts weiter + $second = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026); + expect($second['rebooked'])->toHaveCount(0) + ->and($second['created'])->toHaveCount(0); +}); diff --git a/tests/Feature/TeamAboOverviewTest.php b/tests/Feature/TeamAboOverviewTest.php index c5aa90e8..82623a62 100644 --- a/tests/Feature/TeamAboOverviewTest.php +++ b/tests/Feature/TeamAboOverviewTest.php @@ -5,6 +5,7 @@ use App\Models\ShoppingOrder; use App\Models\UserAbo; use App\Models\UserAboItem; use App\Models\UserAboOrder; +use App\Models\UserAboStatusHistory; use App\Models\UserSalesVolume; use App\Services\Abo\TeamAboOverviewService; use App\Services\HTMLHelper; @@ -196,6 +197,61 @@ it('liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Li Carbon\Carbon::setTestNow(); }); +it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zum Monatsende', function (): void { + Carbon\Carbon::setTestNow('2026-08-15'); + + $root = makeUser(); + $anna = makeUser($root->id); + + // Läuft seit Mai bis heute: zählt in Mai, Juli und im Live-Monat August + makeAbo($anna->id, $anna->id, 'me', '2026-05-10'); + // Im Juli gekündigt: zählt im Juni, nicht mehr im Juli-Bestand (Stichtag 31.07.) + makeAbo($anna->id, $anna->id, 'ot', '2026-06-20') + ->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']); + // Im Juli soft-geloescht: zählt im Juni, nicht mehr im Juli + $deleted = makeAbo($anna->id, $anna->id, 'ot', '2026-05-01'); + $deleted->delete(); + UserAbo::withTrashed()->whereKey($deleted->id)->update(['deleted_at' => '2026-07-10']); + // Heute inaktiv, aber laut Status-Historie erst im August deaktiviert: zählt im Juli noch + $paused = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01'); + $paused->update(['active' => false]); + UserAboStatusHistory::forceCreate([ + 'user_abo_id' => $paused->id, + 'action' => UserAboStatusHistory::ACTION_DEACTIVATED, + 'created_at' => '2026-08-02', + ]); + // Laut Historie schon im Juni deaktiviert, erst im August geloescht: darf + // im Juni/Juli NICHT wiederauferstehen (spaeteres Ereignis heilt nichts) + $deactivatedEarly = makeAbo($anna->id, $anna->id, 'ot', '2026-05-20'); + $deactivatedEarly->update(['active' => false]); + UserAboStatusHistory::forceCreate([ + 'user_abo_id' => $deactivatedEarly->id, + 'action' => UserAboStatusHistory::ACTION_DEACTIVATED, + 'created_at' => '2026-06-10', + ]); + $deactivatedEarly->delete(); + UserAbo::withTrashed()->whereKey($deactivatedEarly->id)->update(['deleted_at' => '2026-08-05']); + + $svc = app(TeamAboOverviewService::class); + + $august = $svc->teamKpis($root, 8, 2026); + expect($august['team_partner_abos'])->toBe(1) + ->and($august['team_customer_abos'])->toBe(0); // live: gekündigt/gelöscht/deaktiviert zählen nicht + + $juli = $svc->teamKpis($root, 7, 2026); + expect($juli['team_partner_abos'])->toBe(1) + ->and($juli['team_customer_abos'])->toBe(1); // nur das erst im August deaktivierte + + $juni = $svc->teamKpis($root, 6, 2026); + expect($juni['team_customer_abos'])->toBe(3); // gekündigtes + gelöschtes + später deaktiviertes + + $mai = $svc->teamKpis($root, 5, 2026); + expect($mai['team_partner_abos'])->toBe(1) + ->and($mai['team_customer_abos'])->toBe(2); // Mai-Kundenabo (gelöscht 07/26) + das erst im Juni deaktivierte + + Carbon\Carbon::setTestNow(); +}); + it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void { $root = makeUser(); $anna = makeUser($root->id); diff --git a/tests/Feature/TeamFirstlinesPageTest.php b/tests/Feature/TeamFirstlinesPageTest.php new file mode 100644 index 00000000..367ff318 --- /dev/null +++ b/tests/Feature/TeamFirstlinesPageTest.php @@ -0,0 +1,81 @@ + 'Vor'.uniqid(), + 'last_name' => 'Nach'.uniqid(), + 'phone' => '0123456789', + ], $accountData)); + + return User::forceCreate([ + 'email' => 'tfl-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'm_sponsor' => $sponsorId, + 'account_id' => $account->id, + 'active_date' => '2026-01-15', + 'payment_account' => now()->addYear(), + ]); +} + +it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere aus', function (): void { + URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); + + $root = tflMakeUser(); + $anna = tflMakeUser($root->id, ['first_name' => 'Anna', 'last_name' => 'Alpha']); + $ben = tflMakeUser($root->id, ['first_name' => 'Ben', 'last_name' => 'Beta']); + tflMakeUser($anna->id); // Ebene 2: keine Firstline + + // Eigene (Status 1) + Kundenpunkte (Status 2) zaehlen zusammen + UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 100, 'status' => 1]); + UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 50, 'status' => 2]); + UserSalesVolume::forceCreate(['user_id' => $ben->id, 'month' => 6, 'year' => 2026, 'points' => 400, 'status' => 2]); + UserSalesVolume::forceCreate(['user_id' => $ben->id, 'month' => 5, 'year' => 2026, 'points' => 900, 'status' => 2]); // anderer Monat + + $this->actingAs($root); + Request::merge(['month' => 6, 'year' => 2026]); + Request::instance()->setLaravelSession(app('session')->driver()); + + $view = app(TeamController::class)->firstlines(); + $rows = $view->getData()['rows']; + + expect($rows)->toHaveCount(2) + ->and($rows[0]['user_id'])->toBe($ben->id) // 400 Punkte zuerst + ->and($rows[0]['total_points'])->toBe(400.0) + ->and($rows[1]['user_id'])->toBe($anna->id) + ->and($rows[1]['total_points'])->toBe(150.0) + ->and($rows[1]['name'])->toBe('Anna Alpha') + ->and($rows[1]['phone'])->toBe('0123456789') + ->and($rows[1]['email'])->toBe($anna->email); + + // View rendert ohne Fehler (Route-Fallback aktiv) + expect($view->render()) + ->toContain('Anna Alpha') + ->toContain('Ben Beta'); +}); + +it('zeigt eine leere Firstlines-Liste ohne direkte Teampartner', function (): void { + URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); + + $root = tflMakeUser(); + $this->actingAs($root); + Request::instance()->setLaravelSession(app('session')->driver()); + + $view = app(TeamController::class)->firstlines(); + + expect($view->getData()['rows'])->toBe([]) + ->and($view->render())->toContain(e(__('team.firstlines_none'))); +}); diff --git a/tests/Feature/TopResultsServiceTest.php b/tests/Feature/TopResultsServiceTest.php new file mode 100644 index 00000000..0ee2e427 --- /dev/null +++ b/tests/Feature/TopResultsServiceTest.php @@ -0,0 +1,219 @@ + $firstName, + 'last_name' => $lastName, + ]); + + return User::forceCreate([ + 'email' => 'trs-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'm_sponsor' => $sponsorId, + 'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount, + 'active_date' => $activeDate, + 'account_id' => $account->id, + ]); +} + +function trsAddVolume(int $userId, int $points, int $status, int $month = 8, int $year = 2026): void +{ + UserSalesVolume::forceCreate([ + 'user_id' => $userId, + 'month' => $month, + 'year' => $year, + 'points' => $points, + 'status' => $status, + ]); +} + +function trsMakeAbo(int $ownerId, string $isFor, string $startDate, bool $active = true, int $status = 2): UserAbo +{ + return UserAbo::create([ + 'user_id' => $ownerId, + 'member_id' => $ownerId, + 'shopping_user_id' => 0, + 'email' => 'trs-abo-'.uniqid('', true).'@example.com', + 'is_for' => $isFor, + 'payone_userid' => 0, + 'clearingtype' => 'cc', + 'amount' => 5000, + 'active' => $active, + 'status' => $status, + 'abo_interval' => 5, + 'start_date' => $startDate, + 'next_date' => '2026-09-05', + ]); +} + +function trsTop3(User $viewer): array +{ + Cache::flush(); + + return app(TopResultsService::class)->monthlyTop3($viewer, 8, 2026); +} + +it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, ohne Betrachter und Fremde', function (): void { + $root = trsMakeUser(null, 'Root', 'Viewer'); + $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); + $ben = trsMakeUser($root->id, 'Ben', 'Beta'); + $fremd = trsMakeUser(null, 'Frank', 'Fremd'); + + trsAddVolume($anna->id, 100, 1); // Eigenpunkte + trsAddVolume($anna->id, 50, 2); // Kundenpunkte + trsAddVolume($anna->id, 999, 3); // pending zaehlt nicht + trsAddVolume($ben->id, 40, 1); + trsAddVolume($ben->id, 70, 1, 7); // anderer Monat zaehlt nicht + trsAddVolume($root->id, 500, 1); // Betrachter zaehlt nicht mit + trsAddVolume($fremd->id, 800, 1); // nicht im Team + + $result = trsTop3($root); + + expect($result['top_volume'])->toHaveCount(2) + ->and($result['top_volume'][0])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0]) + ->and($result['top_volume'][0]['profile_image_url'])->toBeNull() + ->and($result['top_volume'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 40.0]); +}); + +it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void { + $root = trsMakeUser(null, 'Root', 'Viewer'); + $carla = trsMakeUser($root->id, 'Carla', 'Chef'); + $zoe = trsMakeUser($root->id, 'Zoe', 'Zett'); + $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); + $doris = trsMakeUser($root->id, 'Doris', 'Klein'); + + trsAddVolume($carla->id, 200, 1); + trsAddVolume($zoe->id, 100, 1); + trsAddVolume($anna->id, 100, 1); + trsAddVolume($doris->id, 50, 1); // Platz 4, faellt raus + + $result = trsTop3($root); + + expect($result['top_volume'])->toHaveCount(3) + ->and(array_column($result['top_volume'], 'user_id')) + ->toBe([$carla->id, $anna->id, $zoe->id]); +}); + +it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): void { + $root = trsMakeUser(null, 'Root', 'Viewer'); + $sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor'); + $other = trsMakeUser($root->id, 'Olaf', 'Other'); + + trsMakeUser($sponsor->id, 'Neu', 'Eins', '2026-08-10'); + trsMakeUser($sponsor->id, 'Neu', 'Zwei', '2026-08-20'); + trsMakeUser($sponsor->id, 'Alt', 'Juli', '2026-07-10'); // anderer Monat + trsMakeUser($sponsor->id, 'Neu', 'Inaktiv', '2026-08-12', null); // kein aktives Abrechnungskonto + trsMakeUser($other->id, 'Neu', 'Drei', '2026-08-05'); + trsMakeUser($root->id, 'Neu', 'BeimViewer', '2026-08-08'); // Sponsor = Betrachter, zaehlt nicht + + $result = trsTop3($root); + + expect($result['top_recruiters'])->toHaveCount(2) + ->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2]) + ->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1]); +}); + +it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void { + $root = trsMakeUser(null, 'Root', 'Viewer'); + $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); + $ben = trsMakeUser($root->id, 'Ben', 'Beta'); + $fremd = trsMakeUser(null, 'Frank', 'Fremd'); + + trsMakeAbo($anna->id, 'ot', '2026-08-05'); + trsMakeAbo($anna->id, 'ot', '2026-08-20'); + trsMakeAbo($anna->id, 'me', '2026-08-03'); + trsMakeAbo($anna->id, 'ot', '2026-07-05'); // anderer Monat + trsMakeAbo($anna->id, 'me', '2026-08-10', true, 4); // gekuendigt + trsMakeAbo($anna->id, 'ot', '2026-08-11', false); // inaktiv + trsMakeAbo($ben->id, 'me', '2026-08-15'); + trsMakeAbo($root->id, 'ot', '2026-08-09'); // Betrachter zaehlt nicht mit + trsMakeAbo($fremd->id, 'ot', '2026-08-09'); // nicht im Team + + $result = trsTop3($root); + + expect($result['top_new_abos'])->toHaveCount(2) + ->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3]) + ->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1]); +}); + +it('cacht das Ergebnis fuer 6 Stunden', function (): void { + $root = trsMakeUser(null, 'Root', 'Viewer'); + $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); + + trsAddVolume($anna->id, 100, 1); + $first = app(TopResultsService::class)->monthlyTop3($root, 8, 2026); + + trsAddVolume($anna->id, 900, 1); + $second = app(TopResultsService::class)->monthlyTop3($root, 8, 2026); + + expect($first['top_volume'][0]['value'])->toBe(100.0) + ->and($second)->toBe($first); +}); + +it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void { + URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); + + $row = fn (string $first, string $last, $value) => [ + 'user_id' => 1, 'first_name' => $first, 'last_name' => $last, + 'profile_image_url' => null, 'value' => $value, + ]; + + $html = view('dashboard._top_results', [ + 'monthlyStats' => [ + 'month' => 8, + 'year' => 2026, + 'top_results' => [ + 'top_volume' => [$row('Anna', 'Alpha', 150.0)], + 'top_recruiters' => [], + 'top_new_abos' => [$row('Ben', 'Beta', 3)], + ], + ], + ])->render(); + + expect($html) + ->toContain(e(__('home.top_results_title'))) + ->toContain(__('home.top_results_volume')) + ->toContain('Anna Alpha') + ->toContain('Ben Beta') + ->toContain(__('home.top_results_empty')); // leere Recruiter-Spalte + + $emptyHtml = view('dashboard._top_results', [ + 'monthlyStats' => [ + 'month' => 8, + 'year' => 2026, + 'top_results' => ['top_volume' => [], 'top_recruiters' => [], 'top_new_abos' => []], + ], + ])->render(); + + expect($emptyHtml)->not->toContain(e(__('home.top_results_title'))); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 29b42257..1f36e29c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -7,12 +7,14 @@ use App\Models\ShippingCountry; use App\Models\UserAbo; use App\User; use Illuminate\Support\Facades\DB; +use Tests\TestCase; -uses(Tests\TestCase::class)->in('Feature/Incentive'); -uses(Tests\TestCase::class)->in('Feature/Sys'); -uses(Tests\TestCase::class)->in('Unit/Incentive'); -uses(Tests\TestCase::class)->in('Unit/Dhl'); -uses(Tests\TestCase::class)->in('Feature/PaymentDashboard'); +uses(TestCase::class)->in('Feature/Incentive'); +uses(TestCase::class)->in('Feature/Sys'); +uses(TestCase::class)->in('Unit/Incentive'); +uses(TestCase::class)->in('Unit/Dhl'); +uses(TestCase::class)->in('Feature/PaymentDashboard'); +uses(TestCase::class)->in('Feature/SalesVolume'); if (! function_exists('makeShopEnv')) { function makeShopEnv(): void diff --git a/tests/Unit/Incentive/IncentiveModelTest.php b/tests/Unit/Incentive/IncentiveModelTest.php index 32d2d77b..424a1e74 100644 --- a/tests/Unit/Incentive/IncentiveModelTest.php +++ b/tests/Unit/Incentive/IncentiveModelTest.php @@ -60,6 +60,34 @@ it('returns correct calculation months', function () { ->and($months[4])->toBe(['month' => 8, 'year' => 2026]); }); +it('qualificationWindow liefert Fensterstart 00:00:00 und Fensterende 23:59:59', function () { + $incentive = new Incentive([ + 'qualification_start' => '2026-04-01', + 'qualification_end' => '2026-07-31', + 'calculation_end' => '2026-08-31', + ]); + + [$windowStart, $windowEnd] = $incentive->qualificationWindow(); + + expect($windowStart->format('Y-m-d H:i:s'))->toBe('2026-04-01 00:00:00') + ->and($windowEnd->format('Y-m-d H:i:s'))->toBe('2026-07-31 23:59:59'); +}); + +it('qualificationWindowContains zaehlt Zeitstempel am letzten Fenstertag (Randtag-Fall)', function () { + $incentive = new Incentive([ + 'qualification_start' => '2026-04-01', + 'qualification_end' => '2026-07-31', + 'calculation_end' => '2026-08-31', + ]); + + expect($incentive->qualificationWindowContains(Carbon::parse('2026-07-31 11:05:52')))->toBeTrue() + ->and($incentive->qualificationWindowContains(Carbon::parse('2026-07-31 23:59:59')))->toBeTrue() + ->and($incentive->qualificationWindowContains(Carbon::parse('2026-04-01 00:00:00')))->toBeTrue() + ->and($incentive->qualificationWindowContains(Carbon::parse('2026-05-15 12:00:00')))->toBeTrue() + ->and($incentive->qualificationWindowContains(Carbon::parse('2026-03-31 23:59:59')))->toBeFalse() + ->and($incentive->qualificationWindowContains(Carbon::parse('2026-08-01 00:00:00')))->toBeFalse(); +}); + // === Incentive: Status === it('detects status types correctly', function () {