Ergebnis des Senior-Reviews über dev/2026-07-22 (Konzept 22.07., Feedback 24.07.,
Plan 19.08.). Rechnerisch war nichts zu korrigieren: Payline-Punkte und Provision
des Prognose-Blocks (N4) stimmen exakt mit der Provisions-Engine überein
(54.718,5 P. bzw. 3.772,88 EUR gegengerechnet an User 454). Behoben sind
Darstellungs- und Konsistenzfehler, die der Beraterin als falsche Zahlen
aufgefallen wären:
- "+N neu" schrumpfte rückwirkend, der Bestand daneben nicht: newAboCount() nutzte
weiter activeAbo(), während aboStockCount() auf das Monatsende zurückrechnet.
Beide teilen sich jetzt aboStockQuery(); die Jahres-Wachstumstabelle ebenso.
- Level-Kachel widersprach sich ("Zum nächsten Level Diamant fehlen X" über
"Karriere-Level: Diamant"): Text auf die Monats-Qualifikation umformuliert
(de/en/es/fr), Überschrift "Qualifikation in diesem Monat", Karriere-Level nur
noch bei Abweichung.
- Firstlines-Kachel zählte alle direkten Partner, "Teampartner gesamt" daneben nur
aktive (F1): Kachel nutzt jetzt isActiveConsultant(), die Detailseite listet
weiter alle und weist beide Zahlen aus.
- Neupartnerzahlen vergangener Monate sanken auf dem Home-Dashboard, sobald
Jahresgebühren ausliefen: neue User::newPartnerReferenceDate() (laufender Monat =
heute, abgeschlossener Monat = Monatsende). Dabei die drei Inline-Kopien der
Neupartner-Regel auf scopeNewPartnerIn()/isNewPartnerIn() zusammengeführt.
- Top-3-Karte ließ den Betrachter aus, obwohl "ich gehört in die Liste" in der
Juli-Runde für alle anderen Ranglisten umgesetzt wurde: zählt jetzt mit, inkl.
is_self und "ich"-Badge.
- Neues Profilfoto zeigte bis zu 6 h ein totes Bild: die URL steckt nicht mehr im
Cache, sondern entsteht beim Lesen (withProfileImages()).
- Test-Regression seit der Juli-Runde: Unit/Services/BackofficeDashboardServiceTest
fiel über die fehlende Spalte status_points (kam mit nonPaylinePoints() dazu).
- Kleinigkeiten: ProfileImageController gab die komplette Exception als
Flash-Message aus (jetzt Log + Sprachkey), BackofficeDrilldownService formatierte
Datumsangaben hart auf d.m.Y statt über Util::formatDateDB().
Tests: neuer HomeMonthlyStatsTest (buildMonthlyStats war ungetestet) plus Tests für
Stichtags-Zählung und Foto-Cache. Fünf Tests kodierten das alte Verhalten
(Betrachter zählt nicht mit, abgelaufener Account zählt nie) und wurden auf die
neue Semantik umgestellt, jeweils mit Gegenprobe. Suite: 861 grün (vorher 855),
89 rot — ausschließlich die in docs/2026-06-11-test-suite-vorbestehende-fehler.md
dokumentierten Altlasten.
Offen und bewusst nicht enthalten:
- Deploy-Schritt: backoffice:store-statistics-snapshots --force (vorher dumpen).
Kein vorhandener Snapshot enthält recruiter_ranking, Mai/Juni zusätzlich weder
non_payline_points noch die Führungskräfte-"gesamt"-Spalten; der Cron überspringt
vorhandene Snapshots ohne --force. Achtung: die Neuberechnung ist nicht
verlustfrei.
- TreeHtmlRendererTest/TreeCalcBotIntegrationTest: die Abo-Badges machten den
Renderer datenbankabhängig; die Dateien waren schon vorher rot und gehören in die
separate Test-Aufräumaufgabe.
- deleteImage bleibt ein GET-Route (gleiches Muster wie das Shop-Bild).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
429 lines
14 KiB
PHP
429 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\DashboardNews;
|
|
use App\Models\Incentive;
|
|
use App\Models\IncentiveParticipant;
|
|
use App\Models\ShoppingPayment;
|
|
use App\Models\UserAbo;
|
|
use App\Services\Abo\PaylineForecastService;
|
|
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;
|
|
use Illuminate\View\View;
|
|
use Request;
|
|
use Util;
|
|
|
|
class HomeController extends Controller
|
|
{
|
|
/**
|
|
* Create a new controller instance.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function __construct() {}
|
|
|
|
public function index()
|
|
{
|
|
|
|
if (! Auth::check()) {
|
|
return redirect('login');
|
|
}
|
|
|
|
return redirect('home');
|
|
}
|
|
|
|
public function newsArchive(): View
|
|
{
|
|
return view('dashboard.news_archive', [
|
|
'currentNews' => DashboardNews::getActiveNews(),
|
|
'archiveNews' => DashboardNews::getArchiveNews(),
|
|
]);
|
|
}
|
|
|
|
// login / Dashboard
|
|
public function show()
|
|
{
|
|
|
|
if (! Auth::check()) {
|
|
return redirect('login');
|
|
}
|
|
|
|
$user = Auth::user();
|
|
|
|
$activeIncentive = null;
|
|
$incentiveParticipant = null;
|
|
|
|
if ($user->isActiveAccount()) {
|
|
$activeIncentive = Incentive::active()->first();
|
|
if ($activeIncentive) {
|
|
$incentiveParticipant = IncentiveParticipant::where('incentive_id', $activeIncentive->id)
|
|
->where('user_id', $user->id)
|
|
->first();
|
|
}
|
|
}
|
|
|
|
// Eigenes Abo des Beraters für die Einmalprodukte-Dashboard-Card (nur wenn
|
|
// das Zeitfenster offen ist und das Feature für den User sichtbar ist).
|
|
$oneTimeAbo = null;
|
|
$aboOneTimeId = UserAbo::where('user_id', $user->id)
|
|
->where('is_for', 'me')
|
|
->where('active', true)
|
|
->where('status', 2)
|
|
->value('id');
|
|
|
|
if ($aboOneTimeId) {
|
|
$abo = UserAbo::find($aboOneTimeId);
|
|
if ($abo && AboHelper::isOneTimeWindowOpen($abo) && AboHelper::isOneTimeFeatureVisible($abo)) {
|
|
$oneTimeAbo = $abo;
|
|
}
|
|
}
|
|
|
|
$monthlyStats = null;
|
|
if ($user->isActiveAccount()) {
|
|
$monthlyStats = $this->buildMonthlyStats($user);
|
|
}
|
|
|
|
$data = [
|
|
'user' => $user,
|
|
'now' => Carbon::now(),
|
|
'dashboardNews' => DashboardNews::getActiveNews(),
|
|
'activeIncentive' => $activeIncentive,
|
|
'incentiveParticipant' => $incentiveParticipant,
|
|
'oneTimeAbo' => $oneTimeAbo,
|
|
'monthlyStats' => $monthlyStats,
|
|
];
|
|
|
|
return view('home', $data);
|
|
}
|
|
|
|
/**
|
|
* Kennzahlen der Monatsstatistik-Card. Bündelt persönliche Punkte/Abos, Team-Abo-KPIs
|
|
* (konsistent über {@see TeamAboOverviewService}) und die Team-Payline-Punkte.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildMonthlyStats(User $user): array
|
|
{
|
|
$month = max(1, min(12, (int) Request::get('stats_month', now()->month)));
|
|
$year = max(2020, min((int) now()->year + 1, (int) Request::get('stats_year', now()->year)));
|
|
|
|
$salesVolume = $user->getUserSalesVolume($month, $year, 'first');
|
|
[$paylinePoints, $paylineIsLive] = $this->teamPaylinePoints($user, $month, $year);
|
|
$customerPoints = $salesVolume ? (float) $salesVolume->getPointsKPSum() : 0.0;
|
|
|
|
// Payline-Prognose (B10) nur für den laufenden Monat — Basis ist der heutige
|
|
// Stand aus Kundenumsatz + Payline-Punkten.
|
|
$isCurrentMonth = $month === (int) now()->month && $year === (int) now()->year;
|
|
$paylineForecast = $isCurrentMonth
|
|
? app(PaylineForecastService::class)->forecastDays($user, $customerPoints + (float) $paylinePoints)
|
|
: null;
|
|
$levelForecast = $isCurrentMonth
|
|
? app(PaylineForecastService::class)->levelAndCommissionForecast($user, $month, $year)
|
|
: null;
|
|
|
|
$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.
|
|
$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(
|
|
[
|
|
'month' => $month,
|
|
'year' => $year,
|
|
'customer_points' => $customerPoints,
|
|
'team_payline_points' => $paylinePoints,
|
|
'payline_is_live' => $paylineIsLive,
|
|
'payline_forecast' => $paylineForecast,
|
|
'commission' => app(LiveCommissionService::class)->commission($user, $month, $year),
|
|
'direct_new_partners' => $dashboardService->newPartnersCount($firstlineIds, $month, $year),
|
|
'firstline_count' => $activeFirstlineCount,
|
|
'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)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Team-Payline-Punkte: aus dem gemeinsamen Live-Lauf ({@see LiveCommissionService}),
|
|
* der auch die Provision und die Payline-Tiefe liefert — ein Baum-Lauf für alles.
|
|
*
|
|
* @return array{0: float, 1: bool} [Punkte, istLive]
|
|
*/
|
|
private function teamPaylinePoints(User $user, int $month, int $year): array
|
|
{
|
|
$snapshot = app(LiveCommissionService::class)->snapshot($user, $month, $year);
|
|
|
|
return [$snapshot['payline_points'], $snapshot['is_live']];
|
|
}
|
|
|
|
public function loadingModal()
|
|
{
|
|
|
|
$data = Request::get('data');
|
|
$target = Request::get('target');
|
|
$response = '';
|
|
if ($data === 'data_protection') {
|
|
$data = [
|
|
'modal' => true,
|
|
'user_shop' => true,
|
|
'isMivitaShop' => false,
|
|
];
|
|
$response = view('legal.data_protect_de', $data)->render();
|
|
}
|
|
if ($data === 'imprint') {
|
|
$data = [
|
|
'modal' => true,
|
|
'user_shop' => Util::getUserShop(),
|
|
];
|
|
$response = view('legal.imprint_de', $data)->render();
|
|
}
|
|
if ($data === 'shop_term_of_use') {
|
|
$data = [
|
|
'modal' => true,
|
|
'user_shop' => Util::getUserShop(),
|
|
];
|
|
$response = view('legal.shop_term_of_use_de', $data)->render();
|
|
}
|
|
if ($data === 'agb') {
|
|
$data = [
|
|
'modal' => true,
|
|
'user_shop' => Util::getUserShop(),
|
|
];
|
|
$response = view('legal.agb_de', $data)->render();
|
|
}
|
|
if (Request::ajax()) {
|
|
return response()->json(['response' => $response, 'target' => $target]);
|
|
}
|
|
abort(404);
|
|
}
|
|
|
|
/* public function checkLogin($identify, $token)
|
|
{
|
|
if($identify){
|
|
//user find by $identify
|
|
$user = User::where('identify', '=', $identify)->first();
|
|
if(!$user){
|
|
return abort(404);
|
|
}
|
|
//user - check für from $sf_guard_user - old system
|
|
$sf_guard_user = SfGuardUser::where('identify', '=', $identify)->first();
|
|
if(!$sf_guard_user){
|
|
return abort(404);
|
|
}
|
|
if($user->id != $sf_guard_user->user_id){
|
|
return abort(404);
|
|
|
|
}
|
|
if($sf_guard_user->token != $token){
|
|
return abort(404);
|
|
}
|
|
$time = Carbon::parse($sf_guard_user->token_at);
|
|
$now = Carbon::now();
|
|
$duration = $time->diffInSeconds($now);
|
|
|
|
if($duration > 3){
|
|
return abort(404);
|
|
}
|
|
$sf_guard_user->token = null;
|
|
$sf_guard_user->token_at = null;
|
|
$sf_guard_user->save();
|
|
if(!Auth::check()){
|
|
$user->last_login = now();
|
|
$user->save();
|
|
Auth::login($user);
|
|
}
|
|
if(Auth::check()){
|
|
return redirect('/templates');
|
|
}
|
|
}
|
|
return abort(404);
|
|
}
|
|
*/
|
|
public function zahlungsarten()
|
|
{
|
|
return view('web.templates.zahlungsarten', [
|
|
'user_shop' => Util::getUserShop(),
|
|
'isMivitaShop' => Util::isMivitaShop(),
|
|
'yard_instance' => 'webshop',
|
|
]);
|
|
}
|
|
|
|
public function versandkosten()
|
|
{
|
|
return view('web.templates.versandkosten', [
|
|
'user_shop' => Util::getUserShop(),
|
|
'isMivitaShop' => Util::isMivitaShop(),
|
|
'yard_instance' => 'webshop',
|
|
]);
|
|
}
|
|
|
|
public function legalDataProtected()
|
|
{
|
|
$data = [
|
|
'modal' => false,
|
|
'user_shop' => Util::getUserShop(),
|
|
'isMivitaShop' => Util::isMivitaShop(),
|
|
'yard_instance' => 'webshop',
|
|
];
|
|
|
|
return view('legal.data_protected', $data);
|
|
}
|
|
|
|
public function legalAGB()
|
|
{
|
|
$data = [
|
|
'modal' => false,
|
|
'user_shop' => Util::getUserShop(),
|
|
'yard_instance' => 'webshop',
|
|
'yard_instance' => 'webshop',
|
|
|
|
];
|
|
|
|
return view('legal.agb', $data);
|
|
}
|
|
|
|
public function legalImprint()
|
|
{
|
|
|
|
$data = [
|
|
'modal' => false,
|
|
'user_shop' => Util::getUserShop(),
|
|
'yard_instance' => 'webshop',
|
|
];
|
|
|
|
return view('legal.imprint', $data);
|
|
}
|
|
|
|
public function verify($confirmation_code)
|
|
{
|
|
if (! $confirmation_code) {
|
|
return redirect('/status/error');
|
|
}
|
|
|
|
$user = User::whereConfirmationCode($confirmation_code)->first();
|
|
if (! $user) {
|
|
return redirect('/status/not/found');
|
|
}
|
|
$user_auto_login = false;
|
|
if ($user->confirmed === 0) {
|
|
$user->confirmed = 1;
|
|
$user->confirmation_date = now();
|
|
$user_auto_login = true;
|
|
// nur bei der ersten Verifizierung den user auto login
|
|
}
|
|
// wird nun in WizardController::releaseAccount() auf null gesetzt
|
|
// $user->confirmation_code = null;
|
|
// $user->confirmation_code_to = null;
|
|
// $user->confirmation_code_remider = 0;
|
|
$user->save();
|
|
|
|
// Login!
|
|
if ($user_auto_login) {
|
|
Auth::login($user);
|
|
}
|
|
$url = Util::getMyMivitaUrl();
|
|
|
|
return redirect($url);
|
|
}
|
|
|
|
public function statusRegister()
|
|
{
|
|
return view('status.status_register');
|
|
}
|
|
|
|
public function statusVerify()
|
|
{
|
|
return view('status.status_verify');
|
|
}
|
|
|
|
public function statusError()
|
|
{
|
|
return view('status.status_error');
|
|
}
|
|
|
|
public function notFound()
|
|
{
|
|
return view('status.not_found');
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function checkMail()
|
|
{
|
|
|
|
$data = Request::all();
|
|
if ($data['user_id'] === 'new') {
|
|
if (User::where('email', $data['email'])->count()) {
|
|
return json_encode(false);
|
|
}
|
|
} else {
|
|
if (User::where('email', $data['email'])->where('id', '!=', $data['user_id'])->count()) {
|
|
return json_encode(false);
|
|
}
|
|
}
|
|
|
|
return json_encode(true);
|
|
}
|
|
|
|
public function blocked()
|
|
{
|
|
return view('status.user_blocked');
|
|
}
|
|
|
|
public function backToShop($reference = '')
|
|
{
|
|
|
|
if ($reference) {
|
|
$ShoppingPayment = ShoppingPayment::where('reference', $reference)->first();
|
|
if ($ShoppingPayment && $ShoppingPayment->status === 'success') {
|
|
$user = Auth::user();
|
|
// is form wizard create payment
|
|
if ($user && ($user->wizard == 13 || $user->wizard == 20)) {
|
|
$user->wizard = 15; // realese Payments
|
|
$user->save();
|
|
|
|
return redirect(route('wizard_create', [15]));
|
|
}
|
|
} else {
|
|
\Session()->flash('alert-error', __('msg.error_occurred_with_order'));
|
|
|
|
return redirect(url('/'));
|
|
}
|
|
}
|
|
}
|
|
}
|