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
|
|
@ -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();
|
||||
|
|
|
|||
89
app/Console/Commands/RepairSponsorBuyingPoints.php
Normal file
89
app/Console/Commands/RepairSponsorBuyingPoints.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SponsorBuyingPointsRepairService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Nacht-Repair: fehlende Vertriebspartner-Punkte (VP, status=5) des Sponsors
|
||||
* fuer bezahlte Bestellungen des laufenden Monats nachbuchen.
|
||||
*
|
||||
* Hintergrund und Faelle: dev/2026-08-12-intensiv-tickets/README.md
|
||||
*/
|
||||
class RepairSponsorBuyingPoints extends Command
|
||||
{
|
||||
protected $signature = 'sales:repair-sponsor-vp
|
||||
{--month= : Monat (Standard: aktueller Monat)}
|
||||
{--year= : Jahr (Standard: aktuelles Jahr)}
|
||||
{--dry-run : Nur anzeigen, nichts buchen}
|
||||
{--force : Buchung in einen anderen als den laufenden Monat erlauben}';
|
||||
|
||||
protected $description = 'Fehlende Vertriebspartner-Punkte (VP, status=5) des Sponsors für bezahlte Bestellungen des Monats nachbuchen';
|
||||
|
||||
public function handle(SponsorBuyingPointsRepairService $service): int
|
||||
{
|
||||
$month = (int) ($this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<int, array{month: int, year: int}>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<int, int>
|
||||
*/
|
||||
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<int, int> $lineByUserId
|
||||
* @return array{personal: float, by_line: array<int, float>}
|
||||
*/
|
||||
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<int, int> $lineByUserId
|
||||
* @return array<int, float>
|
||||
*/
|
||||
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<int, float> $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]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<int, array<int, Collection<int, User>>>
|
||||
*/
|
||||
private array $lineBucketsMemo = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
|
@ -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<int, Collection<int, User>> $lineBuckets
|
||||
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, new_partner_count: int, volume_points: float, partners: array<int, array{user_id: int, name: string, career_level: string, total_points: float, new_partner_count: int, children: array<int, array{user_id: int, name: string, career_level: string, total_points: float}>}>}>
|
||||
*/
|
||||
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<int, float>
|
||||
*/
|
||||
private function totalPointsByUser(array $userIds, int $month, int $year): Collection
|
||||
{
|
||||
return SalesVolumePoints::totalPointsByUser($userIds, $month, $year);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Bestand aktiver Kundenabos je Berater (member_id).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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<int, float>
|
||||
* @return Collection<int, array{external_points: float, total_points: float}>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<int, float> 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.
|
||||
|
|
|
|||
|
|
@ -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<string, mixed>|null, next_level: array<string, mixed>|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<string, mixed>|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<string, mixed>|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<string, mixed>|null $qualLevel
|
||||
* @param array<string, mixed>|null $nextLevel
|
||||
* @return array{payline_points: float, paylines: int, pp: float, shop: float, growth: float, is_live: bool, failed: bool, qual_level: array<string, mixed>|null, next_level: array<string, mixed>|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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .= '<option value="">'.__('please select').'</option>\n';
|
||||
foreach ($values as $key => $value) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
abstract class SlimStatus {
|
||||
abstract class SlimStatus
|
||||
{
|
||||
const FAILURE = 'failure';
|
||||
|
||||
const SUCCESS = 'success';
|
||||
}
|
||||
|
||||
class Slim {
|
||||
class Slim
|
||||
{
|
||||
/**
|
||||
* Speichert ein Slim-Cropper-Upload (Base64-Payload aus getImages()) im
|
||||
* public-Disk und liefert Dateiname + Metadaten — gemeinsame Logik für
|
||||
* Shop- und Profilfoto-Upload. Null bei nicht erlaubtem Dateityp.
|
||||
*
|
||||
* @param array $image Ein Eintrag aus {@see getImages()}
|
||||
* @return array{filename: string, originalname: string, ext: string, mime: string, size: mixed}|null
|
||||
*/
|
||||
public static function storeCroppedImage(array $image, string $directory): ?array
|
||||
{
|
||||
$allowed = ['image/jpeg' => '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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
337
app/Services/SponsorBuyingPointsRepairService.php
Normal file
337
app/Services/SponsorBuyingPointsRepairService.php
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Nacht-Repair fuer Vertriebspartner-Punkte (VP) des Sponsors.
|
||||
*
|
||||
* Live werden die VP-Punkte nur im Zahlungs-Callback gebucht
|
||||
* ({@see Payment::addSponsorBuyingPoints} via Payment::paymentStatusPaidAction),
|
||||
* seit 2026-08 inklusive shopping_order_id. Laeuft dieser Pfad nicht durch
|
||||
* (verspaetete PAYONE-Meldungen, Stripe-Sonderfaelle), fehlt die status=5-Zeile
|
||||
* ("Registrierung") beim Sponsor dauerhaft, obwohl das Incentive-Tracking per
|
||||
* incentive:calculate nachgezogen wird.
|
||||
*
|
||||
* Dieser Repair zieht fehlende VP-Zeilen idempotent nach. Gebucht wird immer in
|
||||
* den Ziel-Monat (Entscheidung 2026-08: keine rueckwirkenden Buchungen in
|
||||
* abgeschlossene Provisionsmonate); geprueft werden auch Bestellungen des
|
||||
* VORmonats, damit Monatswechsel-Faelle (Bestellung 31., Zahlung/Callback im
|
||||
* Folgemonat) nicht durchs Raster fallen.
|
||||
*
|
||||
* Duplikat-Erkennung: primaer ueber shopping_order_id (eindeutig, robust gegen
|
||||
* Umbenennungen); fuer Alt-Zeilen ohne Order-Referenz zaehlend ueber den
|
||||
* info-Text im Pruef-Fenster — eine Alt-Zeile "verbraucht" genau eine
|
||||
* Bestellung, zwei gleiche Bestellungen werden also nicht mehr faelschlich als
|
||||
* eine erkannt.
|
||||
*/
|
||||
class SponsorBuyingPointsRepairService
|
||||
{
|
||||
/**
|
||||
* @return array{checked: int, skipped_existing: int, skipped_other_sponsor: array<int, array{order_id: int, buyer: string, current_sponsor_id: int, booked_sponsor_id: int}>, rebooked: array<int, array{order_id: int, sponsor_id: int, sponsor: string, buyer: string, product: string, points: int}>, created: array<int, array{order_id: int, sponsor_id: int, sponsor: string, buyer: string, product: string, points: int}>}
|
||||
*/
|
||||
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<int, bool> $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<int, int> $orderIds
|
||||
* @param Collection<int, string> $candidateInfos
|
||||
* @return array{0: array<int, array{id: int, user_id: int, month: int, year: int}>, 1: array<string, array<int, array{id: int, user_id: int, month: int, year: int}>>}
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
126
app/Services/Team/AnnualFeeDueService.php
Normal file
126
app/Services/Team/AnnualFeeDueService.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Team;
|
||||
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Jahresgebühr-Nachfassliste für den Team-Bereich (alle Berater, eigenes Team).
|
||||
*
|
||||
* Sammelt alle Teampartner der Downline, deren Account-Gültigkeit
|
||||
* (`users.payment_account` = „bezahlt bis") überfällig ist oder innerhalb der
|
||||
* nächsten drei Monate abläuft — gruppiert nach Dringlichkeit (überfällig zuerst,
|
||||
* dann fällig in ≤ 1 / ≤ 2 / ≤ 3 Monaten). User ohne `payment_account` werden
|
||||
* übersprungen. Wiederverwendet die Linien-Logik aus
|
||||
* {@see BackofficeDashboardService} (keine Duplizierung der Baum-Traversierung).
|
||||
*/
|
||||
class AnnualFeeDueService
|
||||
{
|
||||
public const GROUP_OVERDUE = 'overdue';
|
||||
|
||||
public const GROUP_DUE_1_MONTH = 'due_1_month';
|
||||
|
||||
public const GROUP_DUE_2_MONTHS = 'due_2_months';
|
||||
|
||||
public const GROUP_DUE_3_MONTHS = 'due_3_months';
|
||||
|
||||
/**
|
||||
* @var list<string> 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<string, list<array{
|
||||
* user_id: int,
|
||||
* first_name: string,
|
||||
* last_name: string,
|
||||
* email: string,
|
||||
* phone: string,
|
||||
* mobil: string,
|
||||
* line: int,
|
||||
* level_name: string,
|
||||
* due_date: Carbon
|
||||
* }>>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
204
app/Services/Team/TopResultsService.php
Normal file
204
app/Services/Team/TopResultsService.php
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Team;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Abo\PaylineForecastService;
|
||||
use App\Services\Backoffice\BackofficeDashboardService;
|
||||
use App\Services\Backoffice\SalesVolumePoints;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
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}).
|
||||
*/
|
||||
class TopResultsService
|
||||
{
|
||||
/**
|
||||
* Sichtbarkeits-Scope der Ranglisten. 'team' = Downline des Betrachters
|
||||
* (Kundenentscheid 2026-08, teamintern). 'company' (firmenweit, EIN Ranking
|
||||
* für alle) ist als spätere Option vorgesehen — dafür nur
|
||||
* {@see scopedUserIds()} erweitern, die Ranglisten-Logik bleibt gleich.
|
||||
*/
|
||||
public const SCOPE = 'team';
|
||||
|
||||
private const CACHE_HOURS = 6;
|
||||
|
||||
private const TOP_LIMIT = 3;
|
||||
|
||||
public function __construct(private BackofficeDashboardService $dashboardService) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* top_volume: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>,
|
||||
* top_recruiters: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>,
|
||||
* top_new_abos: array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>
|
||||
* }
|
||||
*/
|
||||
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<int, float> 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<int, int> 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<int, int> 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<int, float|int> $valuesByUserId
|
||||
* @return array<int, array{user_id: int, first_name: string, last_name: string, profile_image_url: string|null, value: float|int}>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
84
app/User.php
84
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<static>|User wherePreDeletedAt($value)
|
||||
*
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserBusiness> $userBusiness
|
||||
* @property-read Collection<int, UserBusiness> $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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Berater-Profilfoto (N6): Dateiname relativ zu images/profile/ im
|
||||
* public-Disk, Muster wie user_shops.filename.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_accounts', function (Blueprint $table) {
|
||||
$table->string('profile_image')->nullable()->after('instagram');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_accounts', function (Blueprint $table) {
|
||||
$table->dropColumn('profile_image');
|
||||
});
|
||||
}
|
||||
};
|
||||
BIN
dev/2026-07-22/Statistik-Aenderungen-19-08-2026.pdf
Normal file
BIN
dev/2026-07-22/Statistik-Aenderungen-19-08-2026.pdf
Normal file
Binary file not shown.
190
dev/2026-07-22/entwicklungsplan-2026-08-19.md
Normal file
190
dev/2026-07-22/entwicklungsplan-2026-08-19.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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: <Name> | <Produkt>` 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 |
|
||||
|
|
|
|||
72
dev/2026-08-12-intensiv-tickets/juli-anerkennungen.md
Normal file
72
dev/2026-08-12-intensiv-tickets/juli-anerkennungen.md
Normal file
|
|
@ -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 💙
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -125,4 +125,5 @@ return [
|
|||
'reorder_info_2' => 'Your delivery country is: :country<br>If you want a different country, update your billing or delivery address under <a class="text-primary" href=":link">My details</a>',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -126,4 +126,5 @@ return [
|
|||
'reorder_info' => '¿Quieres volver a pedir este artículo?<br>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<br>Si quieres que tu pedido se entregue en otro país, cambia tu dirección de facturación o de entrega en <a class="text-primary" href=":link">Mis datos</a>',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -209,12 +209,16 @@
|
|||
<div class="text-muted small">{{ __('order.points_total') }}</div>
|
||||
{{ $shopping_order->getFormattedPoints() }}
|
||||
</div>
|
||||
@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))
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">Herkunft</div>
|
||||
{{ $shopping_order->getCustomerOrderSourceLabel() }}
|
||||
@if ($shopping_order->customer_order_source_comment)
|
||||
<div class="small text-muted mt-1">{{ $shopping_order->customer_order_source_comment }}</div>
|
||||
@if ($shopping_order->customer_order_source)
|
||||
{{ $shopping_order->getCustomerOrderSourceLabel() }}
|
||||
@if ($shopping_order->customer_order_source_comment)
|
||||
<div class="small text-muted mt-1">{{ $shopping_order->customer_order_source_comment }}</div>
|
||||
@endif
|
||||
@else
|
||||
{{ $shopping_order->customer_order_source_comment }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
15
resources/views/components/_avatar.blade.php
Normal file
15
resources/views/components/_avatar.blade.php
Normal file
|
|
@ -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))
|
||||
<img src="{{ $imageUrl }}" alt="{{ trim(($firstName ?? '') . ' ' . ($lastName ?? '')) }}" class="rounded-circle"
|
||||
style="width: {{ $avatarSize }}px; height: {{ $avatarSize }}px; object-fit: cover; flex: 0 0 auto;">
|
||||
@else
|
||||
<div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center font-weight-bold"
|
||||
style="width: {{ $avatarSize }}px; height: {{ $avatarSize }}px; font-size: {{ (int) round($avatarSize * 0.4) }}px; flex: 0 0 auto;">
|
||||
{{ $avatarInitials }}</div>
|
||||
@endif
|
||||
|
|
@ -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 @@
|
|||
</div>
|
||||
@endforeach
|
||||
|
||||
@if (! empty($s['level']))
|
||||
@php($level = $s['level'])
|
||||
<h6 class="text-muted text-uppercase font-weight-bold small mb-3 mt-2 pt-3 border-top">
|
||||
{{ __('home.level_current') }}
|
||||
@if ($level['is_live'])
|
||||
<span class="badge badge-pill badge-info ml-1" title="{{ __('home.live_calculation_hint') }}">Live</span>
|
||||
@endif
|
||||
<span class="ion ion-md-information-circle-outline ml-1" title="{{ __('home.level_hint') }}"></span>
|
||||
</h6>
|
||||
<div class="d-flex align-items-center flex-wrap mb-2">
|
||||
<span class="badge badge-primary mr-3 mb-2" style="font-size: 1rem; padding: .5rem .9rem;">
|
||||
{{ $level['name'] }}
|
||||
</span>
|
||||
<div class="mb-2">
|
||||
@if ($level['next_name'] !== null && $level['next_name'] !== '')
|
||||
<div>
|
||||
{!! __('home.level_next_missing', [
|
||||
'level' => '<strong>'.e($level['next_name']).'</strong>',
|
||||
'points' => '<strong>'.$fmt($level['missing_pp']).'</strong>',
|
||||
]) !!}
|
||||
</div>
|
||||
@if ($level['missing_personal'] > 0)
|
||||
<div class="text-muted small">
|
||||
{{ __('home.level_next_missing_personal', ['points' => $fmt($level['missing_personal'])]) }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="text-success">{{ __('home.level_top_reached') }}</div>
|
||||
@endif
|
||||
@if ($level['career_name'] !== '')
|
||||
<div class="text-muted small">{{ __('home.level_career') }}: {{ $level['career_name'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($s['payline_forecast']['days']))
|
||||
<h6 class="text-muted text-uppercase font-weight-bold small mb-3 mt-2 pt-3 border-top">
|
||||
{{ __('home.payline_forecast_title') }}
|
||||
|
|
@ -190,6 +250,43 @@
|
|||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (! empty($s['level_forecast']))
|
||||
@php($lf = $s['level_forecast'])
|
||||
<div class="alert alert-light border mb-0">
|
||||
<div class="small text-muted mb-1">
|
||||
{{ __('home.forecast_level_title') }}
|
||||
<span class="badge badge-pill badge-warning ml-1"
|
||||
title="{{ __('home.forecast_level_disclaimer') }}">{{ __('home.forecast_unverbindlich') }}</span>
|
||||
</div>
|
||||
@if ($lf['next_name'] !== null && $lf['next_name'] !== '')
|
||||
@if ($lf['reached'])
|
||||
<div>
|
||||
{!! __('home.forecast_level_reached', [
|
||||
'level' => '<strong>'.e($lf['next_name']).'</strong>',
|
||||
'volume' => '<strong>'.$fmt($lf['personal_required']).'</strong>',
|
||||
]) !!}
|
||||
</div>
|
||||
@else
|
||||
<div>
|
||||
{!! __('home.forecast_level_missing', [
|
||||
'level' => '<strong>'.e($lf['next_name']).'</strong>',
|
||||
'points' => '<strong>'.$fmt($lf['missing_pp']).'</strong>',
|
||||
]) !!}
|
||||
@if ($lf['missing_personal'] > 0)
|
||||
{{ __('home.forecast_level_missing_personal', ['points' => $fmt($lf['missing_personal'])]) }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
<div>
|
||||
{!! __('home.forecast_commission', [
|
||||
'amount' => '<strong>'.number_format((float) $lf['estimated_commission'], 2, ',', '.').' €</strong>',
|
||||
'level' => e($lf['estimated_level_name']),
|
||||
]) !!}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
59
resources/views/dashboard/_top_results.blade.php
Normal file
59
resources/views/dashboard/_top_results.blade.php
Normal file
|
|
@ -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)
|
||||
<div class="d-flex col-xl-12 align-items-stretch">
|
||||
<div class="card w-100 mb-4">
|
||||
<h5 class="card-header">
|
||||
<i class="ion ion-md-trophy mr-2"></i>{{ __('home.top_results_title') }} -
|
||||
{{ HTMLHelper::getMonth($monthlyStats['month']) }} {{ $monthlyStats['year'] }}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
@foreach ($topColumns as $column)
|
||||
<div class="col-md-4 mb-3 {{ !$loop->last ? 'border-md-right' : '' }}">
|
||||
<h6 class="text-muted text-uppercase font-weight-bold small mb-3">{{ $column['title'] }}
|
||||
</h6>
|
||||
@forelse ($topResults[$column['key']] ?? [] as $row)
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="position-relative" style="flex: 0 0 auto;">
|
||||
@include('components._avatar', [
|
||||
'imageUrl' => $row['profile_image_url'],
|
||||
'firstName' => $row['first_name'],
|
||||
'lastName' => $row['last_name'],
|
||||
'size' => 48,
|
||||
])
|
||||
@if ($loop->first)
|
||||
<i class="ion ion-md-trophy text-warning position-absolute"
|
||||
style="right: -4px; bottom: -4px; font-size: 1rem;"
|
||||
title="{{ $column['title'] }}"></i>
|
||||
@endif
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<div class="font-weight-bold">
|
||||
{{ $row['first_name'] }} {{ $row['last_name'] }}</div>
|
||||
<div class="text-muted small">
|
||||
{{ number_format((float) $row['value'], $column['decimals'], ',', '.') }}
|
||||
{{ $column['unit'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-muted small">{{ __('home.top_results_empty') }}</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -46,6 +46,8 @@
|
|||
@if (Auth::user()->isActiveAccount())
|
||||
@include('dashboard._statistics')
|
||||
|
||||
@include('dashboard._top_results')
|
||||
|
||||
@include('dashboard._points')
|
||||
|
||||
@include('dashboard._activities')
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@
|
|||
<div>{{ __('navigation.level_reports') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('user/team/annual-fee') ? ' active' : '' }}">
|
||||
<a href="{{ route('user_team_annual_fee') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-md-alarm"></i>
|
||||
<div>{{ __('navigation.annual_fee') }}</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidenav-item{{ Request::is('user/team/marketingplan') ? ' active' : '' }}">
|
||||
<a href="{{ route('user_team_marketingplan') }}" class="sidenav-link"><i
|
||||
class="sidenav-icon ion ion-ios-trending-up"></i>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@
|
|||
@elseif ($details['metric'] === 'new_partners')
|
||||
<th data-sortable="true"><span class="sortable-label">Sponsor <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
||||
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Kundenpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
||||
<th class="text-right" data-sortable="true" data-sort-type="number" title="Eigen- und Kundenpunkte des Monats zusammen — inkl. Starterpaket"><span class="sortable-label justify-content-end">Punkte gesamt <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
||||
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Aktiv seit <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
||||
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Account gültig bis <i class="ion ion-ios-swap sort-icon"></i></span></th>
|
||||
@else
|
||||
|
|
@ -169,6 +170,7 @@
|
|||
@elseif ($details['metric'] === 'new_partners')
|
||||
<td>{{ $row['sponsor'] ?? '-' }}</td>
|
||||
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }}</td>
|
||||
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }}</td>
|
||||
<td>{{ $row['active_date'] ?? '-' }}</td>
|
||||
<td>{{ $row['payment_account'] ?? '-' }}</td>
|
||||
@else
|
||||
|
|
@ -211,6 +213,7 @@
|
|||
@elseif ($details['metric'] === 'new_partners')
|
||||
<td></td>
|
||||
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_points'] ?? 0) }}</td>
|
||||
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['total_points'] ?? 0) }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -432,6 +432,109 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$recruiterRanking = $statistics['recruiter_ranking'] ?? null;
|
||||
@endphp
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">Recruiter des Monats</h5>
|
||||
<div class="text-muted small"
|
||||
title="Neupartner wie in der Neupartner-Kennzahl (aktiver Account, im Monat aktiv geworden). Neues Volumen = Monats-Gesamtpunkte der Neupartner (Eigen- und Kundenpunkte, inkl. Starterpaket) plus Kettenvolumen: Punkte der Neupartner, die diese Neupartner im selben Monat selbst geworben haben.">
|
||||
{{ $selectedMonthName }} {{ $selectedYear }} · nach Anzahl gesponserter Neupartner
|
||||
<span class="ion ion-md-information-circle-outline"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="display-4 font-weight-bold text-primary mb-0">
|
||||
{{ number_format((float) ($statistics['totals']['new_partners'] ?? 0), 0, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($recruiterRanking === null)
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-0">
|
||||
Für diesen Monat liegt noch ein älterer Snapshot ohne diese Liste vor.
|
||||
</p>
|
||||
</div>
|
||||
@elseif (count($recruiterRanking) === 0)
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-0">In diesem Monat wurden keine Neupartner gesponsert.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sponsor</th>
|
||||
<th class="text-right">Neupartner</th>
|
||||
<th class="text-right" title="Monats-Gesamtpunkte der Neupartner inkl. Kettenvolumen">neues Volumen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($recruiterRanking as $row)
|
||||
<tr data-toggle="collapse" data-target="#recruiter-partners-{{ $loop->index }}"
|
||||
role="button" style="cursor: pointer;"
|
||||
title="Klick: geworbene Neupartner anzeigen">
|
||||
<td>
|
||||
<div class="font-weight-semibold">
|
||||
{{ $row['name'] }}
|
||||
@if (!empty($row['is_self']))
|
||||
<span class="badge badge-pill badge-info ml-1">ich</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-muted small">{{ !empty($row['is_self']) ? 'Eigene Neupartner' : 'Linie '.$row['line'] }} · {{ $row['career_level'] }}</div>
|
||||
</td>
|
||||
<td class="text-right align-middle">
|
||||
<span class="badge badge-pill badge-success backoffice-statistics-click-badge">
|
||||
+{{ number_format((float) $row['new_partner_count'], 0, ',', '.') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right align-middle font-weight-bold">
|
||||
{{ \App\Services\Util::formatNumber($row['volume_points']) }} P.
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="collapse bg-light" id="recruiter-partners-{{ $loop->index }}">
|
||||
<td colspan="3" class="py-2">
|
||||
<ul class="list-unstyled small mb-0">
|
||||
@foreach ($row['partners'] ?? [] as $partner)
|
||||
<li class="{{ $loop->last ? 'mb-0' : 'mb-1' }}">
|
||||
<span class="font-weight-semibold">{{ $partner['name'] }}</span>
|
||||
<span class="text-muted">· {{ $partner['career_level'] }}</span>
|
||||
· {{ \App\Services\Util::formatNumber($partner['total_points']) }} P.
|
||||
@if (!empty($partner['children']))
|
||||
<ul class="list-unstyled text-muted ml-3 mb-0">
|
||||
@foreach ($partner['children'] as $child)
|
||||
<li>
|
||||
↳ {{ $child['name'] }}
|
||||
· {{ $child['career_level'] }}
|
||||
· {{ \App\Services\Util::formatNumber($child['total_points']) }} P.
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body pt-2 pb-3">
|
||||
<p class="text-muted small mb-0">
|
||||
<strong>neues Volumen</strong> = 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.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$topCustomerAbos = $statistics['top_customer_abos'] ?? null;
|
||||
@endphp
|
||||
|
|
|
|||
65
resources/views/user/components/user_profile_image.blade.php
Normal file
65
resources/views/user/components/user_profile_image.blade.php
Normal file
|
|
@ -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. --}}
|
||||
<div class="card mb-4">
|
||||
<h5 class="card-header">{{ __('account.profile_image') }}<br>
|
||||
<small>{{ __('account.profile_image_copy') }}</small>
|
||||
</h5>
|
||||
<div class="card-body p-3">
|
||||
@if ($user->account->getProfileImageUrl())
|
||||
<div class="text-center">
|
||||
@include('components._avatar', [
|
||||
'imageUrl' => $user->account->getProfileImageUrl(),
|
||||
'firstName' => $user->account->first_name,
|
||||
'lastName' => $user->account->last_name,
|
||||
'size' => 120,
|
||||
])
|
||||
<br><br>
|
||||
<a href="{{ route('user_profile_delete_image') }}" class="btn btn-danger"
|
||||
onclick="return confirm('{{ __('actions.really_delete_picture') }}');"><i class="fa fa-trash"></i>
|
||||
{{ __('account.profile_image_delete') }}</a>
|
||||
</div>
|
||||
@else
|
||||
<form method="POST" action="{{ route('user_profile_upload_image') }}" accept-charset="UTF-8" class="avatar"
|
||||
enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="slim_holder text-center">
|
||||
<div class="slim" style="margin:20px auto; max-width: 320px;"
|
||||
data-label='<span class="text-green">{{ __('actions.upload_photo') }}</span><br>({{ __('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">
|
||||
<input type="file" name="images[]" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group text-left mx-auto" style="max-width: 480px;">
|
||||
<label class="custom-control custom-checkbox m-0">
|
||||
<input type="checkbox" class="custom-control-input" name="profile_image_consent"
|
||||
id="profile_image_consent" value="1">
|
||||
<span class="custom-control-label">{{ __('account.profile_image_consent') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" type="submit" id="profile-image-submit"
|
||||
disabled>{{ __('actions.save_image') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<script type="application/javascript">
|
||||
$(document).ready(function() {
|
||||
// Ohne Einwilligung kein Upload (zusätzlich serverseitig geprüft).
|
||||
$('#profile_image_consent').on('change', function() {
|
||||
$('#profile-image-submit').prop('disabled', !this.checked);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -18,6 +18,10 @@
|
|||
<h4 class="font-weight-bold py-2 mb-2">
|
||||
{{ __('navigation.my_data') }}
|
||||
</h4>
|
||||
|
||||
{{-- 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']) !!}
|
||||
<input type="hidden" name="user_id" id="user_id" value="@if($user->id>0){{$user->id}}@else new @endif">
|
||||
@include('user.user_form')
|
||||
|
|
|
|||
68
resources/views/user/team/_annual_fee_groups.blade.php
Normal file
68
resources/views/user/team/_annual_fee_groups.blade.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{{ __('team.annual_fee_title') }}
|
||||
<span class="badge badge-info ml-2">{{ $totalCount }}</span>
|
||||
</h5>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<div class="alert alert-info m-4 mb-3">
|
||||
<i class="ion ion-md-information-circle-outline mr-1"></i>
|
||||
{{ __('team.annual_fee_hint') }}
|
||||
</div>
|
||||
|
||||
@if ($totalCount === 0)
|
||||
<div class="px-4 pb-4 text-muted">
|
||||
{{ __('team.annual_fee_no_data') }}
|
||||
</div>
|
||||
@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)
|
||||
<h6 class="px-4 pt-2 mb-2">
|
||||
<span class="badge {{ $groupMeta[$groupKey]['badge'] }}">{{ $groupMeta[$groupKey]['label'] }}</span>
|
||||
<span class="text-muted small ml-1">{{ count($rows) }}</span>
|
||||
</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th>{{ __('tables.firstname') }}</th>
|
||||
<th>{{ __('tables.lastname') }}</th>
|
||||
<th>{{ __('tables.email') }}</th>
|
||||
<th>{{ __('tables.phone') }}</th>
|
||||
<th>{{ __('tables.mobil') }}</th>
|
||||
<th>{{ __('tables.line') }}</th>
|
||||
<th>{{ __('tables.level') }}</th>
|
||||
<th>{{ __('tables.account_to') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($rows as $row)
|
||||
<tr>
|
||||
<td>{{ $row['first_name'] }}</td>
|
||||
<td>{{ $row['last_name'] }}</td>
|
||||
<td><a href="mailto:{{ $row['email'] }}">{{ $row['email'] }}</a></td>
|
||||
<td>{{ $row['phone'] !== '' ? $row['phone'] : '-' }}</td>
|
||||
<td>{{ $row['mobil'] !== '' ? $row['mobil'] : '-' }}</td>
|
||||
<td>{{ $row['line'] }}</td>
|
||||
<td>{{ $row['level_name'] !== '' ? $row['level_name'] : '-' }}</td>
|
||||
<td class="no-line-break">{{ formatDate($row['due_date']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
5
resources/views/user/team/annual-fee.blade.php
Normal file
5
resources/views/user/team/annual-fee.blade.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
@include('user.team._annual_fee_groups', ['groups' => $groups, 'totalCount' => $totalCount])
|
||||
@endsection
|
||||
78
resources/views/user/team/firstlines.blade.php
Normal file
78
resources/views/user/team/firstlines.blade.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
@extends('layouts.layout-2')
|
||||
|
||||
@section('content')
|
||||
<h4 class="font-weight-bold py-2 mb-2 d-flex justify-content-between align-items-center w-100">
|
||||
<div>{{ __('team.firstlines') }}</div>
|
||||
</h4>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<span class="font-weight-bold">{{ __('team.firstlines') }} ({{ count($rows) }})</span>
|
||||
<div class="text-muted small">{{ __('team.firstlines_copy') }}</div>
|
||||
</div>
|
||||
<div class="col-md-4 mt-2 mt-md-0">
|
||||
{!! 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()']) !!}
|
||||
<noscript><button type="submit" class="btn btn-sm btn-secondary ml-2">{{ __('abo.filter') }}</button></noscript>
|
||||
{!! Form::close() !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (count($rows) === 0)
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-0">{{ __('team.firstlines_none') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr class="text-muted small">
|
||||
<th>{{ __('tables.adviser') }}</th>
|
||||
<th>{{ __('team.career_level') }}</th>
|
||||
<th class="text-right">{{ __('team.firstlines_points') }}</th>
|
||||
<th>{{ __('tables.phone') }}</th>
|
||||
<th>{{ __('tables.email') }}</th>
|
||||
<th>{{ __('team.active_since') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($rows as $row)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-weight-semibold">{{ $row['name'] }}</div>
|
||||
@unless ($row['is_active_account'])
|
||||
<span class="badge badge-outline-danger">{{ __('team.account_inactive') }}</span>
|
||||
@endunless
|
||||
</td>
|
||||
<td class="align-middle small text-muted">{{ $row['career_level'] }}</td>
|
||||
<td class="align-middle text-right font-weight-bold">
|
||||
{{ \App\Services\Util::formatNumber($row['total_points']) }}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@if ($row['phone'] !== '')
|
||||
<a href="tel:{{ $row['phone'] }}">{{ $row['phone'] }}</a>
|
||||
@else
|
||||
<span class="text-muted">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<a href="mailto:{{ $row['email'] }}">{{ $row['email'] }}</a>
|
||||
</td>
|
||||
<td class="align-middle small text-muted">
|
||||
{{ $row['active_since'] ? formatDate($row['active_since']) : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -553,24 +553,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12 col-sm-12">
|
||||
<div class="form-group {{($errors->has('customer_order_source') ? 'error' : '')}}">
|
||||
<label class="mt-0 fs-14 fw-400" for="customer_order_source">Wie bist du auf uns aufmerksam geworden? *</label>
|
||||
<select id="customer_order_source" name="customer_order_source" class="form-control custom-select">
|
||||
<option value="">Bitte auswählen</option>
|
||||
@foreach($customer_order_source_options as $sourceKey => $sourceLabel)
|
||||
<option value="{{ $sourceKey }}" {{ old('customer_order_source') === $sourceKey ? 'selected' : '' }}>{{ $sourceLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if ($errors->has('customer_order_source'))
|
||||
<label for="customer_order_source" class="error text-danger small" style="display: block;">{{ $errors->first('customer_order_source') }}</label>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12 col-sm-12">
|
||||
<div class="form-group {{($errors->has('customer_order_source_comment') ? 'error' : '')}}">
|
||||
<label class="mt-0 fs-14 fw-400" for="customer_order_source_comment">Ergänzung zur Herkunft (optional)</label>
|
||||
{!! 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]) !!}
|
||||
<label class="mt-0 fs-14 fw-400" for="customer_order_source_comment">Wie bist du auf uns aufmerksam geworden? *</label>
|
||||
{!! 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'))
|
||||
<label for="customer_order_source_comment" class="error text-danger small" style="display: block;">{{ $errors->first('customer_order_source_comment') }}</label>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
151
tests/Feature/AnnualFeeDueServiceTest.php
Normal file
151
tests/Feature/AnnualFeeDueServiceTest.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserLevel;
|
||||
use App\Services\Team\AnnualFeeDueService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function afdMakeLevel(string $name): UserLevel
|
||||
{
|
||||
return UserLevel::forceCreate([
|
||||
'name' => $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');
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
72
tests/Feature/CheckoutOrderSourceValidationTest.php
Normal file
72
tests/Feature/CheckoutOrderSourceValidationTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Web\CheckoutController;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* Ruft die private Checkout-Validierung mit dem gegebenen Request-Input auf.
|
||||
* (route() ist wegen Domain-Routing im Test nicht verfügbar, daher direkt.)
|
||||
*/
|
||||
function cosvValidateCheckout(array $input): Validator
|
||||
{
|
||||
request()->replace($input);
|
||||
|
||||
$method = new ReflectionMethod(CheckoutController::class, 'validateCheckoutData');
|
||||
|
||||
return $method->invoke(app(CheckoutController::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
79
tests/Feature/ProfileImageTest.php
Normal file
79
tests/Feature/ProfileImageTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileImageController;
|
||||
use App\Models\UserAccount;
|
||||
use App\Services\Slim;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function piMakeUser(): User
|
||||
{
|
||||
$account = UserAccount::forceCreate([
|
||||
'first_name' => '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');
|
||||
});
|
||||
433
tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php
Normal file
433
tests/Feature/SalesVolume/SponsorBuyingPointsRepairTest.php
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Product;
|
||||
use App\Models\Shipping;
|
||||
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\Payment;
|
||||
use App\Services\SponsorBuyingPointsRepairService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-08-12 12:00:00'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
function vpRepairCreateUser(?int $sponsorId = null, string $firstName = 'Test', string $lastName = 'User'): User
|
||||
{
|
||||
$user = User::forceCreate([
|
||||
'email' => '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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
81
tests/Feature/TeamFirstlinesPageTest.php
Normal file
81
tests/Feature/TeamFirstlinesPageTest.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\User\TeamController;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function tflMakeUser(?int $sponsorId = null, array $accountData = []): User
|
||||
{
|
||||
$account = UserAccount::forceCreate(array_merge([
|
||||
'first_name' => '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')));
|
||||
});
|
||||
219
tests/Feature/TopResultsServiceTest.php
Normal file
219
tests/Feature/TopResultsServiceTest.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Team\TopResultsService;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow(Carbon::parse('2026-08-15'));
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
function trsMakeUser(
|
||||
?int $sponsorId = null,
|
||||
string $firstName = 'Team',
|
||||
string $lastName = 'Partner',
|
||||
?string $activeDate = null,
|
||||
?string $paymentAccount = 'future',
|
||||
): User {
|
||||
$account = UserAccount::forceCreate([
|
||||
'first_name' => $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')));
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue