diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index d7ef6bd2..78bc8255 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -128,15 +128,19 @@ class HomeController extends Controller ? app(PaylineForecastService::class)->levelAndCommissionForecast($user, $month, $year) : null; - $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); + // Firstlines: die Neupartner-Zählung braucht alle direkten Partner (der + // Scope filtert selbst), die Kachel zeigt aber nur aktive Berater — + // sonst zählt sie anders als "Teampartner gesamt" daneben (F1). + $firstlines = User::firstlinesOf($user->id)->get(); + $firstlineIds = $firstlines->pluck('id')->map(fn ($id) => (int) $id)->all(); + $activeFirstlineCount = $firstlines + ->filter(fn (User $member) => $dashboardService->isActiveConsultant($member)) + ->count(); + // 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. @@ -159,7 +163,7 @@ 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), + 'firstline_count' => $activeFirstlineCount, 'team_partner_count' => count($activeTeamUserIds), 'payline_member_count' => $activePaylineMemberCount, 'payline_abo_count' => $paylineAboCount, diff --git a/app/Http/Controllers/ProfileImageController.php b/app/Http/Controllers/ProfileImageController.php index 661af996..ff5523e6 100644 --- a/app/Http/Controllers/ProfileImageController.php +++ b/app/Http/Controllers/ProfileImageController.php @@ -54,7 +54,9 @@ class ProfileImageController extends Controller return redirect(route('user_edit')); } catch (\Exception $e) { - \Session()->flash('alert-danger', 'Error: '.$e); + // Details ins Log, nicht ins UI — die Exception enthält Serverpfade. + \Log::warning('ProfileImageController: Upload fehlgeschlagen für User '.$user->id.': '.$e->getMessage()); + \Session()->flash('alert-danger', __('msg.file_upload_failed')); return redirect(route('user_edit')); } diff --git a/app/Http/Controllers/User/TeamController.php b/app/Http/Controllers/User/TeamController.php index 8e8d7331..675a9dc1 100644 --- a/app/Http/Controllers/User/TeamController.php +++ b/app/Http/Controllers/User/TeamController.php @@ -750,6 +750,9 @@ class TeamController extends Controller return view('user.team.firstlines', [ 'rows' => $rows, + // Die Dashboard-Kachel zeigt nur aktive Berater; die Liste zeigt alle + // (inaktive sind fürs Nachfassen gerade interessant) und weist beide Zahlen aus. + 'activeCount' => collect($rows)->where('is_active_account', true)->count(), 'selectedMonth' => $month, 'selectedYear' => $year, 'filterMonths' => HTMLHelper::getTransMonths(), diff --git a/app/Services/Abo/TeamAboOverviewService.php b/app/Services/Abo/TeamAboOverviewService.php index da2cfd1f..08787ca9 100644 --- a/app/Services/Abo/TeamAboOverviewService.php +++ b/app/Services/Abo/TeamAboOverviewService.php @@ -132,6 +132,18 @@ class TeamAboOverviewService * @param int[] $userIds */ public function aboStockCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int + { + return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year)->count(); + } + + /** + * Die Bestandsabfrage hinter {@see aboStockCount()} — offen, damit die + * "+N neu"-Zählung ({@see newAboCount()}) exakt dieselbe Stichtags-Logik + * verwendet und nicht in derselben Kachel eine andere Zählweise zeigt. + * + * @param int[] $userIds + */ + private function aboStockQuery(array $userIds, string $userColumn, string $isFor, int $month, int $year): Builder { $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); @@ -140,7 +152,7 @@ class TeamAboOverviewService ->whereIn($userColumn, $userIds); if ($endOfMonth->gte(now()->endOfMonth())) { - return $query->activeAbo()->count(); + return $query->activeAbo(); } return $query @@ -177,8 +189,7 @@ class TeamAboOverviewService UserAboStatusHistory::ACTION_DEACTIVATED, ])->where('created_at', '>', $endOfMonth); }); - }) - ->count(); + }); } /** @@ -197,15 +208,14 @@ class TeamAboOverviewService $teamUserIds = $this->dashboardService->activeTeamUserIds($user->id); $monthNames = HTMLHelper::getTransMonths(); - $ownByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'user_id', 'me', $year); - $customerByMonth = $teamUserIds === [] ? [] : $this->newAbosByMonth($teamUserIds, 'member_id', 'ot', $year); - $months = []; $totals = ['own_new' => 0, 'customer_new' => 0, 'total_new' => 0]; for ($month = 1; $month <= 12; $month++) { - $own = $ownByMonth[$month] ?? 0; - $customer = $customerByMonth[$month] ?? 0; + // Gleiche Stichtags-Zählung wie die "+N neu"-Badges, damit die + // Wachstumszahlen vergangener Monate nicht nachträglich sinken. + $own = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'user_id', 'me', $month, $year); + $customer = $teamUserIds === [] ? 0 : $this->newAboCount($teamUserIds, 'member_id', 'ot', $month, $year); $months[] = [ 'month' => $month, @@ -227,35 +237,6 @@ class TeamAboOverviewService ]; } - /** - * Zählt neue (aktive) Abos je Monat eines Jahres anhand start_date. - * DB-agnostisch (kein MONTH()/strftime): rohe Datumswerte werden in PHP gruppiert. - * - * @param int[] $userIds - * @return array - */ - private function newAbosByMonth(array $userIds, string $userColumn, string $isFor, int $year): array - { - $dates = UserAbo::query() - ->activeAbo() - ->where('is_for', $isFor) - ->whereIn($userColumn, $userIds) - ->whereYear('start_date', $year) - ->toBase() - ->pluck('start_date'); - - $byMonth = []; - foreach ($dates as $date) { - if (! $date) { - continue; - } - $month = (int) Carbon::parse($date)->month; - $byMonth[$month] = ($byMonth[$month] ?? 0) + 1; - } - - return $byMonth; - } - /** * Abo-Zusammenfassung eines einzelnen Team-Mitglieds für das Detail-Modal. * Liefert null, wenn das Mitglied nicht in der Downline des Owners liegt. @@ -499,18 +480,19 @@ class TeamAboOverviewService } /** + * Im Monat hinzugekommene Abos — mit derselben Stichtags-Logik wie der + * Bestand: für vergangene Monate zählt, was am Monatsende bestand, nicht + * was heute noch aktiv ist. Sonst schrumpft das "+N neu"-Badge rückwirkend, + * während der Bestand daneben stehen bleibt. + * * @param int[] $userIds */ private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int { $startDate = Carbon::create($year, $month, 1)->startOfMonth(); - $endDate = Carbon::create($year, $month, 1)->endOfMonth(); - return UserAbo::query() - ->activeAbo() - ->whereIn($userColumn, $userIds) - ->where('is_for', $isFor) - ->whereBetween('start_date', [$startDate, $endDate]) + return $this->aboStockQuery($userIds, $userColumn, $isFor, $month, $year) + ->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()]) ->count(); } diff --git a/app/Services/Backoffice/BackofficeDashboardService.php b/app/Services/Backoffice/BackofficeDashboardService.php index f51f84b8..65dc4d8b 100644 --- a/app/Services/Backoffice/BackofficeDashboardService.php +++ b/app/Services/Backoffice/BackofficeDashboardService.php @@ -424,27 +424,11 @@ class BackofficeDashboardService */ private function newPartners(Collection $users, int $month, int $year): int { - $startDate = Carbon::create($year, $month, 1)->startOfMonth(); - $endDate = Carbon::create($year, $month, 1)->endOfMonth(); - return $users - ->filter(function (User $user) use ($startDate, $endDate): bool { - if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) { - return false; - } - - $activeDate = Carbon::parse($user->active_date); - - return $activeDate->betweenIncluded($startDate, $endDate); - }) + ->filter(fn (User $user) => $user->isNewPartnerIn($month, $year)) ->count(); } - private function hasActivePaymentAccount(User $user): bool - { - return $user->payment_account !== null && Carbon::parse($user->payment_account)->isFuture(); - } - private function activeAboQuery(): Builder { return UserAbo::query() @@ -855,9 +839,6 @@ class BackofficeDashboardService return []; } - $startDate = Carbon::create($year, $month, 1)->startOfMonth(); - $endDate = $startDate->copy()->endOfMonth(); - $newPartnersBySponsor = []; foreach ($usersByLine as $entry) { @@ -867,11 +848,7 @@ class BackofficeDashboardService $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)) { + if (! $user->isNewPartnerIn($month, $year)) { continue; } diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php index 176a6904..fc82323e 100644 --- a/app/Services/Backoffice/BackofficeDrilldownService.php +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -131,19 +131,12 @@ class BackofficeDrilldownService */ private function newPartnerRows(array $userIds, int $month, int $year): array { - $startDate = Carbon::create($year, $month, 1)->startOfMonth(); - $endDate = Carbon::create($year, $month, 1)->endOfMonth(); - $partners = User::query() ->with(['account', 'user_level']) ->whereIn('id', $userIds) - ->whereNotNull('m_level') - ->whereNotNull('payment_account') - ->whereBetween('active_date', [$startDate, $endDate]) + ->newPartnerIn($month, $year) ->orderBy('active_date') - ->get() - ->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture()) - ->values(); + ->get(); $sponsors = User::query() ->with('account') @@ -426,6 +419,6 @@ class BackofficeDrilldownService return null; } - return Carbon::parse($date)->format('d.m.Y'); + return Carbon::parse($date)->format(\Util::formatDateDB()); } } diff --git a/app/Services/Team/TopResultsService.php b/app/Services/Team/TopResultsService.php index 0ceb10ee..530a67bf 100644 --- a/app/Services/Team/TopResultsService.php +++ b/app/Services/Team/TopResultsService.php @@ -14,9 +14,10 @@ use Illuminate\Support\Facades\Cache; * "Die besten Ergebnisse des Monats" (N6): drei teaminterne Top-3-Ranglisten * für das Home-Dashboard — persönliches Volumen, Neupartner, neue Abos. * - * Basis ist die Downline des Betrachters ({@see BackofficeDashboardService::activeTeamUserIds()}); - * der Betrachter selbst zählt nicht mit. Ergebnis wird 6 h gecacht - * (Muster payline_context_*, {@see PaylineForecastService}). + * Basis ist die Downline des Betrachters ({@see BackofficeDashboardService::activeTeamUserIds()}) + * inklusive des Betrachters selbst. Ergebnis wird 6 h gecacht + * (Muster payline_context_*, {@see PaylineForecastService}); die Profilfotos + * kommen bewusst erst beim Lesen dazu ({@see withProfileImages()}). */ class TopResultsService { @@ -36,9 +37,9 @@ class TopResultsService /** * @return array{ - * top_volume: array, - * top_recruiters: array, - * top_new_abos: array + * top_volume: array, + * top_recruiters: array, + * top_new_abos: array * } */ public function monthlyTop3(User $user, int $month, int $year): array @@ -46,30 +47,70 @@ class TopResultsService $cacheKey = "top_results_{$user->id}_{$month}_{$year}"; $cached = Cache::get($cacheKey); if (is_array($cached)) { - return $cached; + return $this->withProfileImages($cached); } $teamUserIds = $this->scopedUserIds($user); + $viewerId = (int) $user->id; $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)), + 'top_volume' => $this->rankRows($this->volumeByUserId($teamUserIds, $month, $year), $viewerId), + 'top_recruiters' => $this->rankRows($this->newPartnerCountBySponsorId($teamUserIds, $month, $year), $viewerId), + 'top_new_abos' => $this->rankRows($this->newAboCountByUserId($teamUserIds, $month, $year), $viewerId), ]; Cache::put($cacheKey, $result, now()->addHours(self::CACHE_HOURS)); - return $result; + return $this->withProfileImages($result); } /** * Team-Ermittlung gekapselt für den Scope-Schalter {@see self::SCOPE}. * + * Der Betrachter zählt mit (Kundenwunsch aus der Juli-Runde: „ich" gehört in + * die Ranglisten) — konsistent zu den Kundenabo-Ranglisten der VIP-Statistik. + * * @return int[] */ private function scopedUserIds(User $viewer): array { - return $this->dashboardService->activeTeamUserIds($viewer->id); + return array_values(array_unique(array_merge( + [(int) $viewer->id], + $this->dashboardService->activeTeamUserIds($viewer->id) + ))); + } + + /** + * Profilfoto-URLs erst beim Lesen ergänzen, nicht mitcachen: der Dateiname + * ändert sich bei jedem Upload, eine gecachte URL zeigte sonst bis zu 6 h + * lang ein totes Bild. + * + * @param array>> $result + * @return array>> + */ + private function withProfileImages(array $result): array + { + $userIds = collect($result)->flatten(1)->pluck('user_id')->unique()->values()->all(); + + if ($userIds === []) { + return $result; + } + + // Das Konto haengt ueber users.account_id (belongsTo), nicht ueber eine + // user_id-Spalte an user_accounts — deshalb ueber die User laden. + $imageUrls = User::query() + ->with('account') + ->whereIn('id', $userIds) + ->get() + ->mapWithKeys(fn (User $member) => [(int) $member->id => $member->account?->getProfileImageUrl()]); + + foreach ($result as $key => $rows) { + foreach ($rows as $index => $row) { + $result[$key][$index]['profile_image_url'] = $imageUrls->get((int) $row['user_id']); + } + } + + return $result; } /** @@ -159,9 +200,9 @@ class TopResultsService * hydriert, die die Top 3 noch erreichen können. * * @param array $valuesByUserId - * @return array + * @return array */ - private function rankRows(array $valuesByUserId): array + private function rankRows(array $valuesByUserId, int $viewerId): array { $valuesByUserId = array_filter($valuesByUserId, fn ($value) => $value > 0); if ($valuesByUserId === []) { @@ -191,7 +232,7 @@ class TopResultsService 'user_id' => (int) $userId, 'first_name' => (string) $user->account->first_name, 'last_name' => (string) $user->account->last_name, - 'profile_image_url' => $user->account->getProfileImageUrl(), + 'is_self' => (int) $userId === $viewerId, 'value' => $value, ]; } diff --git a/app/User.php b/app/User.php index 69151801..125488b6 100644 --- a/app/User.php +++ b/app/User.php @@ -232,11 +232,23 @@ class User extends Authenticatable ->whereNull('deleted_at'); } + /** + * Stichtag der Neupartner-Prüfung: im laufenden (oder einem zukünftigen) + * Monat gilt „heute", für abgeschlossene Monate das Monatsende. Sonst würde + * die Neupartnerzahl eines vergangenen Monats rückwirkend sinken, sobald + * Jahresgebühren auslaufen — die Zahl muss aber stabil bleiben. + */ + public static function newPartnerReferenceDate(int $month, int $year): Carbon + { + $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); + + return $endOfMonth->isFuture() ? now() : $endOfMonth; + } + /** * 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). + * und Top-3-Karte): Mitgliedsebene gesetzt, Abrechnungskonto zum Stichtag + * gültig ({@see newPartnerReferenceDate()}), im Monat aktiv geworden. * * @param Builder $query * @return Builder @@ -248,10 +260,30 @@ class User extends Authenticatable return $query->whereNotNull('m_level') ->whereNotNull('payment_account') - ->where('payment_account', '>', now()) + ->where('payment_account', '>', self::newPartnerReferenceDate($month, $year)) ->whereBetween('active_date', [$startDate, $endDate]); } + /** + * PHP-seitiges Gegenstück zu {@see scopeNewPartnerIn()} für bereits geladene + * User-Collections (Linien-Auswertung, Recruiter-Ranking) — damit die + * Neupartner-Regel nur an einer Stelle definiert ist. + */ + public function isNewPartnerIn(int $month, int $year): bool + { + if ($this->m_level === null || $this->active_date === null || $this->payment_account === null) { + return false; + } + + if (Carbon::parse($this->payment_account)->lte(self::newPartnerReferenceDate($month, $year))) { + return false; + } + + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + + return Carbon::parse($this->active_date)->betweenIncluded($startDate, $startDate->copy()->endOfMonth()); + } + public function user_pre_sponsor() { return $this->belongsTo('App\User', 'pre_sponsor'); diff --git a/resources/lang/de/home.php b/resources/lang/de/home.php index 7ed0091f..c8d0375b 100644 --- a/resources/lang/de/home.php +++ b/resources/lang/de/home.php @@ -75,9 +75,9 @@ return [ 'team_partner_total' => 'Teampartner gesamt', 'payline_partner_count' => 'Teampartner in der Payline', 'payline_abo_count' => 'Abos in der Payline', - 'level_current' => 'Aktuelles Level', + 'level_current' => 'Qualifikation in diesem Monat', 'level_career' => 'Karriere-Level', - 'level_next_missing' => 'Zum nächsten Level :level fehlen noch :points Payline-Punkte.', + 'level_next_missing' => 'Für die Qualifikation als :level in diesem Monat fehlen dir 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.', @@ -94,6 +94,7 @@ return [ 'top_results_volume' => 'Top 3 persönliches Volumen', 'top_results_recruiters' => 'Top 3 Neupartner', 'top_results_new_abos' => 'Top 3 neue Abos', + 'top_results_self' => 'ich', 'top_results_empty' => 'Noch keine Ergebnisse in diesem Monat.', 'top_results_unit_points' => 'Punkte', 'top_results_unit_partners' => 'Neupartner', diff --git a/resources/lang/de/msg.php b/resources/lang/de/msg.php index 068f17b8..c1490fc8 100644 --- a/resources/lang/de/msg.php +++ b/resources/lang/de/msg.php @@ -15,6 +15,7 @@ return [ 'no_change_made' => 'keine Änderung vorgenommen', 'booked_package_has_been_changed' => 'gebuchtes Paket wurde geändert.', 'cancel_membership_is_requested' => 'Mitgliedschaft beenden ist beantragt', + 'file_upload_failed' => 'Die Datei konnte nicht hochgeladen werden. Bitte versuche es erneut.', 'file_uploaded' => 'Datei hochgeladen', 'file_empty' => 'Datei leer"', 'file_deleted' => 'Datei gelöscht', diff --git a/resources/lang/de/team.php b/resources/lang/de/team.php index bd71431b..96fd8b82 100644 --- a/resources/lang/de/team.php +++ b/resources/lang/de/team.php @@ -164,6 +164,7 @@ return [ '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_count_split' => ':active aktiv von :total gesamt', '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)', diff --git a/resources/lang/en/home.php b/resources/lang/en/home.php index ef21d29f..48f5e796 100644 --- a/resources/lang/en/home.php +++ b/resources/lang/en/home.php @@ -75,9 +75,9 @@ return [ '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_current' => 'Qualification this month', 'level_career' => 'Career level', - 'level_next_missing' => ':points payline points missing to reach the next level :level.', + 'level_next_missing' => 'You still need :points payline points to qualify as :level this month.', '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.', @@ -94,6 +94,7 @@ return [ 'top_results_volume' => 'Top 3 personal volume', 'top_results_recruiters' => 'Top 3 new partners', 'top_results_new_abos' => 'Top 3 new subscriptions', + 'top_results_self' => 'me', 'top_results_empty' => 'No results yet this month.', 'top_results_unit_points' => 'points', 'top_results_unit_partners' => 'new partners', diff --git a/resources/lang/en/msg.php b/resources/lang/en/msg.php index f8162187..84d8847d 100644 --- a/resources/lang/en/msg.php +++ b/resources/lang/en/msg.php @@ -17,6 +17,7 @@ return [ 'file_deleted' => 'file deleted', 'file_empty' => '"file empty"', 'file_not_found' => 'file not found', + 'file_upload_failed' => 'The file could not be uploaded. Please try again.', 'file_uploaded' => 'file uploaded', 'homeparty_delete' => 'time out party deleted', 'homeparty_guest_delete' => 'time out party guest deleted', diff --git a/resources/lang/en/team.php b/resources/lang/en/team.php index b1e5a87b..5cf70864 100644 --- a/resources/lang/en/team.php +++ b/resources/lang/en/team.php @@ -164,6 +164,7 @@ return [ 'annual_fee_group_due_2_months' => 'Due in ≤ 2 months', 'annual_fee_group_due_3_months' => 'Due in ≤ 3 months', 'firstlines' => 'Firstlines', + 'firstlines_count_split' => ':active active of :total total', '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)', diff --git a/resources/lang/es/home.php b/resources/lang/es/home.php index 844835ba..45f4aaa1 100644 --- a/resources/lang/es/home.php +++ b/resources/lang/es/home.php @@ -75,9 +75,9 @@ return [ '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_current' => 'Calificación de este mes', 'level_career' => 'Nivel de carrera', - 'level_next_missing' => 'Para el siguiente nivel :level faltan :points puntos de payline.', + 'level_next_missing' => 'Para calificar como :level este mes te 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.', @@ -94,6 +94,7 @@ return [ 'top_results_volume' => 'Top 3 volumen personal', 'top_results_recruiters' => 'Top 3 nuevos socios', 'top_results_new_abos' => 'Top 3 nuevas suscripciones', + 'top_results_self' => 'yo', 'top_results_empty' => 'Aún no hay resultados este mes.', 'top_results_unit_points' => 'puntos', 'top_results_unit_partners' => 'nuevos socios', diff --git a/resources/lang/es/msg.php b/resources/lang/es/msg.php index f528b037..6d9707af 100644 --- a/resources/lang/es/msg.php +++ b/resources/lang/es/msg.php @@ -18,6 +18,7 @@ return [ 'file_deleted' => 'archivo eliminado', 'file_empty' => '"archivo vacío"', 'file_not_found' => 'archivo no encontrado', + 'file_upload_failed' => 'No se pudo subir el archivo. Inténtalo de nuevo.', 'file_uploaded' => 'archivo subido', 'homeparty_delete' => 'fiesta de tiempo de espera eliminada', 'homeparty_guest_delete' => 'invitado a la fiesta de tiempo de espera eliminado', diff --git a/resources/lang/es/team.php b/resources/lang/es/team.php index 31a0d970..e018bc33 100644 --- a/resources/lang/es/team.php +++ b/resources/lang/es/team.php @@ -164,6 +164,7 @@ return [ 'annual_fee_group_due_2_months' => 'Vence en ≤ 2 meses', 'annual_fee_group_due_3_months' => 'Vence en ≤ 3 meses', 'firstlines' => 'Firstlines', + 'firstlines_count_split' => ':active activos de :total en total', '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)', diff --git a/resources/lang/fr/home.php b/resources/lang/fr/home.php index afb7bbf9..e551d031 100644 --- a/resources/lang/fr/home.php +++ b/resources/lang/fr/home.php @@ -75,9 +75,9 @@ return [ '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_current' => 'Qualification de ce mois', 'level_career' => 'Niveau de carrière', - 'level_next_missing' => 'Pour le niveau suivant :level, il manque encore :points points de payline.', + 'level_next_missing' => 'Pour te qualifier comme :level ce mois-ci, il te 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.', @@ -94,6 +94,7 @@ return [ 'top_results_volume' => 'Top 3 volume personnel', 'top_results_recruiters' => 'Top 3 nouveaux partenaires', 'top_results_new_abos' => 'Top 3 nouveaux abonnements', + 'top_results_self' => 'moi', 'top_results_empty' => 'Pas encore de résultats ce mois-ci.', 'top_results_unit_points' => 'points', 'top_results_unit_partners' => 'nouveaux partenaires', diff --git a/resources/lang/fr/msg.php b/resources/lang/fr/msg.php index cb6dba4c..5b85e4a3 100644 --- a/resources/lang/fr/msg.php +++ b/resources/lang/fr/msg.php @@ -15,6 +15,7 @@ return [ 'no_change_made' => 'aucune modification effectuée', 'booked_package_has_been_changed' => 'le package réservé a été modifié.', 'cancel_membership_is_requested' => 'La résiliation de l’adhésion a été demandée', + 'file_upload_failed' => 'Le fichier n’a pas pu être téléversé. Merci de réessayer.', 'file_uploaded' => 'Fichier téléversé', 'file_empty' => 'Fichier vide"', 'file_deleted' => 'Fichier supprimé', diff --git a/resources/lang/fr/team.php b/resources/lang/fr/team.php index fc471734..82b98501 100644 --- a/resources/lang/fr/team.php +++ b/resources/lang/fr/team.php @@ -143,6 +143,7 @@ return [ '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_count_split' => ':active actifs sur :total au total', '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)', diff --git a/resources/views/dashboard/_statistics.blade.php b/resources/views/dashboard/_statistics.blade.php index 37a79486..d9f68cae 100644 --- a/resources/views/dashboard/_statistics.blade.php +++ b/resources/views/dashboard/_statistics.blade.php @@ -214,7 +214,8 @@ @else
{{ __('home.level_top_reached') }}
@endif - @if ($level['career_name'] !== '') + {{-- Karriere-Level nur als Zusatz, wenn es von der Monats-Quali abweicht --}} + @if ($level['career_name'] !== '' && $level['career_name'] !== $level['name'])
{{ __('home.level_career') }}: {{ $level['career_name'] }}
@endif diff --git a/resources/views/dashboard/_top_results.blade.php b/resources/views/dashboard/_top_results.blade.php index 7cc9a69c..fee0bf16 100644 --- a/resources/views/dashboard/_top_results.blade.php +++ b/resources/views/dashboard/_top_results.blade.php @@ -41,7 +41,11 @@
- {{ $row['first_name'] }} {{ $row['last_name'] }}
+ {{ $row['first_name'] }} {{ $row['last_name'] }} + @if (!empty($row['is_self'])) + {{ __('home.top_results_self') }} + @endif +
{{ number_format((float) $row['value'], $column['decimals'], ',', '.') }} {{ $column['unit'] }}
diff --git a/resources/views/user/team/firstlines.blade.php b/resources/views/user/team/firstlines.blade.php index a5d123af..52e52936 100644 --- a/resources/views/user/team/firstlines.blade.php +++ b/resources/views/user/team/firstlines.blade.php @@ -9,7 +9,8 @@
- {{ __('team.firstlines') }} ({{ count($rows) }}) + {{ __('team.firstlines') }} + ({{ __('team.firstlines_count_split', ['active' => $activeCount, 'total' => count($rows)]) }})
{{ __('team.firstlines_copy') }}
diff --git a/tests/Feature/BackofficeStatisticsDrilldownTest.php b/tests/Feature/BackofficeStatisticsDrilldownTest.php index d03e30ee..3ef17cf3 100644 --- a/tests/Feature/BackofficeStatisticsDrilldownTest.php +++ b/tests/Feature/BackofficeStatisticsDrilldownTest.php @@ -134,7 +134,8 @@ it('zaehlt Neupartner nur fuer den gewaehlten Monat mit aktivem Account', functi $root = boMakeUser(); $newInJune = boMakeUser($root->id, '2026-06-15'); $newInMay = boMakeUser($root->id, '2026-05-15'); - $expiredAccount = boMakeUser($root->id, '2026-06-20', now()->subDay()); + // Account schon vor dem Monatsende abgelaufen -> zaehlt fuer Juni nicht + $expiredAccount = boMakeUser($root->id, '2026-06-20', '2026-06-25'); $service = app(BackofficeDashboardService::class); $teamIds = $service->teamUserIds($root->id); @@ -143,6 +144,20 @@ it('zaehlt Neupartner nur fuer den gewaehlten Monat mit aktivem Account', functi ->and($service->newPartnersCount($teamIds, 5, 2026))->toBe(1); }); +it('haelt die Neupartnerzahl vergangener Monate stabil, wenn Accounts spaeter ablaufen', function (): void { + $root = boMakeUser(); + // Im Juni aktiv geworden, Jahresgebuehr erst danach ausgelaufen: die Juni-Zahl + // darf dadurch nicht nachtraeglich sinken. + boMakeUser($root->id, '2026-06-15', now()->subDay()); + + $service = app(BackofficeDashboardService::class); + $teamIds = $service->teamUserIds($root->id); + + expect($service->newPartnersCount($teamIds, 6, 2026))->toBe(1) + // Im laufenden Monat gilt weiterhin "Account heute gueltig". + ->and($service->newPartnersCount($teamIds, (int) now()->month, (int) now()->year))->toBe(0); +}); + it('rendert die kombinierte Monatsstatistik-Card mit Abo- und Neupartner-Kennzahlen', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); diff --git a/tests/Feature/BackofficeStatisticsListsTest.php b/tests/Feature/BackofficeStatisticsListsTest.php index 241daad5..6bcc78a9 100644 --- a/tests/Feature/BackofficeStatisticsListsTest.php +++ b/tests/Feature/BackofficeStatisticsListsTest.php @@ -445,9 +445,9 @@ it('rankt Recruiter des Monats nach gesponserten Neupartnern inklusive ich', fun bslMakeUser($anna->id, null, '2026-05-12'); // Mai, zaehlt nicht bslMakeUser($root->id); // Januar-Bestand, zaehlt nicht - // abgelaufener Account: zaehlt nicht als Neupartner + // Account war schon vor dem Monatsende abgelaufen: zaehlt nicht als Neupartner $expired = bslMakeUser($root->id, null, '2026-06-20'); - $expired->forceFill(['payment_account' => now()->subDay()])->save(); + $expired->forceFill(['payment_account' => '2026-06-25'])->save(); $ranking = app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking']; diff --git a/tests/Feature/HomeMonthlyStatsTest.php b/tests/Feature/HomeMonthlyStatsTest.php new file mode 100644 index 00000000..a62f5bb7 --- /dev/null +++ b/tests/Feature/HomeMonthlyStatsTest.php @@ -0,0 +1,77 @@ + 'hms-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'm_sponsor' => $sponsorId, + 'active_date' => '2026-01-10', + 'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount, + ]); +} + +function hmsMonthlyStats(User $user): array +{ + Request::merge(['stats_month' => 8, 'stats_year' => 2026]); + + $method = (new ReflectionClass(HomeController::class))->getMethod('buildMonthlyStats'); + $method->setAccessible(true); + + return $method->invoke(new HomeController, $user); +} + +it('zaehlt in der Firstlines-Kachel nur aktive Berater, konsistent zu "Teampartner gesamt"', function (): void { + $root = hmsMakeUser(); + hmsMakeUser($root->id); + hmsMakeUser($root->id); + // Abgelaufenes, aber gesetztes Abrechnungskonto: zaehlt wie in der + // "Berater"-Spalte weiterhin als Berater (BackofficeDashboardService::isActiveConsultant). + hmsMakeUser($root->id, now()->subMonth()->toDateString()); + // Gar kein Abrechnungskonto: kein Berater + hmsMakeUser($root->id, null); + + $stats = hmsMonthlyStats($root); + + // Beide Kacheln stehen im selben Cluster und muessen dieselbe Basis nutzen. + expect($stats['firstline_count'])->toBe(3) + ->and($stats['team_partner_count'])->toBe(3); +}); + +it('zaehlt Neupartner weiterhin ueber alle Firstlines, nicht nur ueber die Kachel-Menge', function (): void { + $root = hmsMakeUser(); + + // Im laufenden Monat aktiv geworden, Konto gueltig -> Neupartner + hmsMakeUser($root->id)->forceFill(['active_date' => '2026-08-05'])->save(); + // Im laufenden Monat aktiv geworden, Konto abgelaufen -> kein Neupartner, + // aber weiterhin Berater in der Firstlines-Kachel + hmsMakeUser($root->id, now()->subDay()->toDateString()) + ->forceFill(['active_date' => '2026-08-06'])->save(); + // Ohne Abrechnungskonto: weder Neupartner noch Berater + hmsMakeUser($root->id, null)->forceFill(['active_date' => '2026-08-07'])->save(); + + $stats = hmsMonthlyStats($root); + + expect($stats['direct_new_partners'])->toBe(1) + ->and($stats['firstline_count'])->toBe(2); +}); diff --git a/tests/Feature/TeamAboOverviewTest.php b/tests/Feature/TeamAboOverviewTest.php index 82623a62..428ce361 100644 --- a/tests/Feature/TeamAboOverviewTest.php +++ b/tests/Feature/TeamAboOverviewTest.php @@ -252,6 +252,35 @@ it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zu Carbon\Carbon::setTestNow(); }); +it('zaehlt "+N neu" mit derselben Stichtags-Logik wie den Bestand', function (): void { + Carbon\Carbon::setTestNow('2026-08-15'); + + $root = makeUser(); + $anna = makeUser($root->id); + + // Im Juni gestartet, erst im Juli gekuendigt: gehoert in den Juni-Bestand + // UND in die Juni-Zugaenge - frueher fiel es aus "+N neu" heraus, weil es + // heute nicht mehr aktiv ist. + makeAbo($anna->id, $anna->id, 'ot', '2026-06-20') + ->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']); + + // Im Juni gestartet und noch im Juni gekuendigt: zaehlt in keiner Juni-Zahl. + makeAbo($anna->id, $anna->id, 'ot', '2026-06-02') + ->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-06-20']); + + $svc = app(TeamAboOverviewService::class); + $juni = $svc->teamKpis($root, 6, 2026); + + expect($juni['team_customer_abos'])->toBe(1) + ->and($juni['team_customer_abos_new'])->toBe(1); + + // Wachstumstabelle nutzt dieselbe Zaehlung + $growth = collect($svc->monthlyGrowth($root, 2026)['months'])->firstWhere('month', 6); + expect($growth['customer_new'])->toBe(1); + + 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 index 367ff318..0e783de0 100644 --- a/tests/Feature/TeamFirstlinesPageTest.php +++ b/tests/Feature/TeamFirstlinesPageTest.php @@ -39,6 +39,11 @@ it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere a $ben = tflMakeUser($root->id, ['first_name' => 'Ben', 'last_name' => 'Beta']); tflMakeUser($anna->id); // Ebene 2: keine Firstline + // Abgelaufenes Abrechnungskonto: bleibt in der Liste (Nachfassen), zaehlt + // aber nicht als aktiver Berater - die Dashboard-Kachel zeigt nur diese. + $inaktiv = tflMakeUser($root->id, ['first_name' => 'Ina', 'last_name' => 'Inaktiv']); + $inaktiv->forceFill(['payment_account' => now()->subMonth()])->save(); + // 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]); @@ -52,7 +57,8 @@ it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere a $view = app(TeamController::class)->firstlines(); $rows = $view->getData()['rows']; - expect($rows)->toHaveCount(2) + expect($rows)->toHaveCount(3) + ->and($view->getData()['activeCount'])->toBe(2) // Kachel-Zahl: nur aktive Berater ->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) diff --git a/tests/Feature/TopResultsServiceTest.php b/tests/Feature/TopResultsServiceTest.php index 0ee2e427..2d2bbdbd 100644 --- a/tests/Feature/TopResultsServiceTest.php +++ b/tests/Feature/TopResultsServiceTest.php @@ -83,7 +83,7 @@ function trsTop3(User $viewer): array 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 { +it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, mit Betrachter, ohne Fremde', function (): void { $root = trsMakeUser(null, 'Root', 'Viewer'); $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); $ben = trsMakeUser($root->id, 'Ben', 'Beta'); @@ -94,15 +94,16 @@ it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, ohne Be 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($root->id, 500, 1); // Betrachter zaehlt mit ("ich") 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]); + expect($result['top_volume'])->toHaveCount(3) + ->and($result['top_volume'][0])->toMatchArray(['user_id' => $root->id, 'value' => 500.0, 'is_self' => true]) + ->and($result['top_volume'][1])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0, 'is_self' => false]) + ->and($result['top_volume'][1]['profile_image_url'])->toBeNull() + ->and($result['top_volume'][2])->toMatchArray(['user_id' => $ben->id, 'value' => 40.0]); }); it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void { @@ -134,13 +135,15 @@ it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): vo 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 + trsMakeUser($root->id, 'Neu', 'BeimViewer', '2026-08-08'); // Sponsor = Betrachter, zaehlt mit $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]); + // Gleichstand 1:1 zwischen Olaf und Root -> nach Vorname sortiert + expect($result['top_recruiters'])->toHaveCount(3) + ->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2, 'is_self' => false]) + ->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1]) + ->and($result['top_recruiters'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]); }); it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void { @@ -156,14 +159,16 @@ it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void 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($root->id, 'ot', '2026-08-09'); // Betrachter zaehlt 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]); + // Gleichstand 1:1 zwischen Ben und Root -> nach Vorname sortiert + expect($result['top_new_abos'])->toHaveCount(3) + ->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3, 'is_self' => false]) + ->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1]) + ->and($result['top_new_abos'][2])->toMatchArray(['user_id' => $root->id, 'value' => 1, 'is_self' => true]); }); it('cacht das Ergebnis fuer 6 Stunden', function (): void { @@ -180,6 +185,24 @@ it('cacht das Ergebnis fuer 6 Stunden', function (): void { ->and($second)->toBe($first); }); +it('zeigt ein neues Profilfoto sofort, trotz gecachter Rangliste', function (): void { + Storage::fake('public'); + + $root = trsMakeUser(null, 'Root', 'Viewer'); + $anna = trsMakeUser($root->id, 'Anna', 'Alpha'); + trsAddVolume($anna->id, 100, 1); + + $service = app(TopResultsService::class); + expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url'])->toBeNull(); + + // Foto wird hochgeladen, nachdem die Rangliste bereits im Cache liegt + Storage::disk('public')->put('images/profile/anna.jpg', 'x'); + $anna->account->forceFill(['profile_image' => 'anna.jpg'])->save(); + + expect($service->monthlyTop3($root, 8, 2026)['top_volume'][0]['profile_image_url']) + ->toContain('images/profile/anna.jpg'); +}); + it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); diff --git a/tests/Unit/Services/BackofficeDashboardServiceTest.php b/tests/Unit/Services/BackofficeDashboardServiceTest.php index acb134b7..b241e343 100644 --- a/tests/Unit/Services/BackofficeDashboardServiceTest.php +++ b/tests/Unit/Services/BackofficeDashboardServiceTest.php @@ -134,6 +134,9 @@ beforeEach(function () { $table->decimal('total_net', 13, 2)->nullable(); $table->unsignedTinyInteger('status')->nullable(); $table->unsignedTinyInteger('status_turnover')->nullable(); + // Nur-KP-Kennzeichen (Neupartner-Einschreibungspunkte) — gebraucht von + // SalesVolumePoints::nonPaylinePoints(). + $table->unsignedTinyInteger('status_points')->nullable(); $table->decimal('month_KP_points', 13, 2)->nullable(); $table->decimal('month_shop_points', 13, 2)->nullable(); $table->decimal('month_total_net', 13, 2)->nullable();