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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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