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:
parent
841d87e161
commit
867dc21b57
87 changed files with 4480 additions and 240 deletions
|
|
@ -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)
|
||||
|
|
|
|||
78
app/Http/Controllers/ProfileImageController.php
Normal file
78
app/Http/Controllers/ProfileImageController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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', '', '', '', ''];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue