User Statistik

This commit is contained in:
Kevin Adametz 2026-05-18 17:23:28 +02:00
parent 70240d2b6a
commit 53bdba33cd
24 changed files with 2633 additions and 9 deletions

View file

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands;
use App\Models\BackofficeStatisticsSnapshot;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class BackofficeStoreStatisticsSnapshots extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backoffice:store-statistics-snapshots
{--user= : Nur einen bestimmten User berechnen (user_id)}
{--month= : Nur einen bestimmten Monat berechnen}
{--year= : Nur ein bestimmtes Jahr berechnen}
{--force : Bereits vorhandene Snapshots überschreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Speichert Backoffice-Statistik-Snapshots fuer abgeschlossene Monate';
/**
* Execute the console command.
*/
public function handle(BackofficeDashboardService $dashboardService): int
{
$months = $this->monthsToStore();
$force = (bool) $this->option('force');
if ($months === []) {
$this->info('Keine abgeschlossenen Monate zum Speichern.');
return self::SUCCESS;
}
$userQuery = User::query()
->where('admin', '>=', 1)
->where('admin', '<', 4)
->whereNull('deleted_at');
if ($userId = $this->option('user')) {
$userQuery->where('id', $userId);
}
$users = $userQuery->get();
$this->info('Berechne Backoffice-Snapshots fuer '.$users->count().' User und '.count($months).' Monate...');
$bar = $this->output->createProgressBar($users->count());
$bar->start();
$stored = 0;
$skipped = 0;
foreach ($users as $user) {
foreach ($months as [$year, $month]) {
$exists = BackofficeStatisticsSnapshot::query()
->where('user_id', $user->id)
->where('year', $year)
->where('month', $month)
->exists();
if ($exists && ! $force) {
$skipped++;
continue;
}
$dashboardService->storeSnapshot($user, $month, $year);
$stored++;
}
$bar->advance();
gc_collect_cycles();
}
$bar->finish();
$this->newLine();
$this->info("Fertig. Gespeichert: {$stored}, uebersprungen: {$skipped}");
return self::SUCCESS;
}
/**
* @return array<array{int, int}>
*/
private function monthsToStore(): array
{
$monthOption = $this->option('month');
$yearOption = $this->option('year');
if ($monthOption && $yearOption) {
$month = max(1, min(12, (int) $monthOption));
$year = (int) $yearOption;
if (! $this->isClosedMonth($month, $year)) {
return [];
}
return [[$year, $month]];
}
$months = [];
$cursor = Carbon::create(2026, 1, 1)->startOfMonth();
$lastClosedMonth = now()->startOfMonth()->subMonth();
while ($cursor->lte($lastClosedMonth)) {
$months[] = [(int) $cursor->year, (int) $cursor->month];
$cursor->addMonth();
}
return $months;
}
private function isClosedMonth(int $month, int $year): bool
{
return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth());
}
}

View file

@ -55,6 +55,9 @@ class Kernel extends ConsoleKernel
// Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs) // Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs)
$schedule->command('abo:store-chart-snapshots')->dailyAt('04:30'); $schedule->command('abo:store-chart-snapshots')->dailyAt('04:30');
// Backoffice-Statistik-Snapshots: abgeschlossene Monate fuer VIP-Statistiken einfrieren
$schedule->command('backoffice:store-statistics-snapshots')->dailyAt('04:45');
// Incentive: Punkteberechnung täglich nach business:store-optimized // Incentive: Punkteberechnung täglich nach business:store-optimized
$schedule->command('incentive:calculate')->dailyAt('05:00'); $schedule->command('incentive:calculate')->dailyAt('05:00');

View file

@ -0,0 +1,279 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\Services\HTMLHelper;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BackofficeStatisticsController extends Controller
{
private const SESSION_MONTH_KEY = 'backoffice_statistics_month';
private const SESSION_YEAR_KEY = 'backoffice_statistics_year';
public function __construct(
private BackofficeDashboardService $dashboardService,
private BackofficeDrilldownService $drilldownService
) {
$this->middleware('active.account');
}
public function index(Request $request): View
{
if (! $request->user()?->isVIP()) {
abort(404);
}
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
$startTime = microtime(true);
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear);
$performance = [
'duration_ms' => round((microtime(true) - $startTime) * 1000, 2),
'source_label' => $statistics['_meta']['source_label'] ?? 'Live',
'calculated_at' => $statistics['_meta']['calculated_at'] ?? null,
];
return view('user.backoffice.statistics.index', [
'selectedMonth' => $selectedMonth,
'selectedYear' => $selectedYear,
'filterMonths' => HTMLHelper::getTransMonths(),
'filterYears' => HTMLHelper::getYearRange(2022),
'statistics' => $statistics,
'performance' => $performance,
]);
}
public function details(Request $request): View
{
if (! $request->user()?->isVIP()) {
abort(404);
}
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
$line = (int) $request->get('line', 1);
$metric = (string) $request->get('metric', 'consultants');
return view('user.backoffice.statistics.details', [
'selectedMonth' => $selectedMonth,
'selectedYear' => $selectedYear,
'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear),
]);
}
public function export(Request $request): StreamedResponse
{
if (! $request->user()?->isVIP()) {
abort(404);
}
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
$line = (int) $request->get('line', 1);
$metric = (string) $request->get('metric', 'consultants');
$details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear);
$filename = sprintf(
'backoffice-statistik-%s-linie-%s-%02d-%d.csv',
$metric,
$line === 0 ? 'alle' : $line,
$selectedMonth,
$selectedYear
);
return response()->streamDownload(function () use ($details): void {
$output = fopen('php://output', 'w');
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, $this->csvHeaders($details['metric']), ';');
foreach ($details['rows'] as $row) {
fputcsv($output, $this->csvRow($details['metric'], $row), ';');
}
fputcsv($output, []);
fputcsv($output, $this->csvSummaryRow($details), ';');
fclose($output);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
public function overviewExport(Request $request): StreamedResponse
{
if (! $request->user()?->isVIP()) {
abort(404);
}
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
$statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear);
$filename = sprintf('backoffice-statistik-uebersicht-%02d-%d.csv', $selectedMonth, $selectedYear);
return response()->streamDownload(function () use ($statistics): void {
$output = fopen('php://output', 'w');
fwrite($output, "\xEF\xBB\xBF");
fputcsv($output, $this->overviewCsvHeaders(), ';');
foreach ($statistics['lines'] as $line) {
fputcsv($output, $this->overviewCsvRow($line), ';');
}
fputcsv($output, []);
fputcsv($output, $this->overviewCsvRow($statistics['totals']), ';');
fclose($output);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* @return array{0: int, 1: int}
*/
private function selectedPeriod(Request $request): array
{
$selectedMonth = max(1, min(12, (int) $request->get('month', session(self::SESSION_MONTH_KEY, now()->month))));
$selectedYear = (int) $request->get('year', session(self::SESSION_YEAR_KEY, now()->year));
session([
self::SESSION_MONTH_KEY => $selectedMonth,
self::SESSION_YEAR_KEY => $selectedYear,
]);
return [$selectedMonth, $selectedYear];
}
/**
* @return string[]
*/
private function overviewCsvHeaders(): array
{
return [
'Linie',
'Berater',
'Neupartner',
'Teamabos',
'Neue Teamabos',
'Teamkundenabos',
'Neue Teamkundenabos',
'Eigenpunkte',
'Externe Punkte',
'Kundenabo-Punkte',
'Einzelbestellungs-Punkte',
'Sonstige Kundenpunkte',
'Gesamtpunkte',
'1000 Punkte Shop',
'Umsatz Netto',
];
}
/**
* @param array<string, mixed> $row
* @return array<int, mixed>
*/
private function overviewCsvRow(array $row): array
{
return [
$row['label'] ?? '',
$row['consultants'] ?? 0,
$row['new_partners'] ?? 0,
$row['team_partner_abos'] ?? 0,
$row['team_partner_abos_new'] ?? 0,
$row['team_customer_abos'] ?? 0,
$row['team_customer_abos_new'] ?? 0,
$row['own_points'] ?? 0,
$row['external_points'] ?? 0,
$row['customer_abo_points'] ?? 0,
$row['customer_single_order_points'] ?? 0,
$row['customer_other_points'] ?? 0,
$row['total_points'] ?? 0,
$row['shop_1000'] ?? 0,
$row['turnover_net'] ?? 0,
];
}
/**
* @return string[]
*/
private function csvHeaders(string $metric): array
{
if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
return ['Name', 'E-Mail', 'Karriere-Level', 'Berater', 'Abo-Punkte', 'Status', 'Status-Grund', 'Besteht seit', 'Naechste Ausfuehrung', 'Lieferungen'];
}
if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) {
return ['Name', 'E-Mail', 'Karriere-Level', 'Eigenpunkte', 'Externe Punkte', 'Kundenabo-Punkte', 'Einzelbestellungs-Punkte', 'Sonstige Kundenpunkte', 'Gesamtpunkte'];
}
return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status'];
}
/**
* @param array<string, mixed> $row
* @return array<int, mixed>
*/
private function csvRow(string $metric, array $row): array
{
if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
return [
$row['name'] ?? '',
$row['email'] ?? '',
$row['career_level'] ?? '',
$row['consultant_name'] ?? $row['name'] ?? '',
$row['points'] ?? 0,
$row['status_label'] ?? '',
$row['status_reason'] ?? '',
$row['start_date'] ?? '',
$row['next_date'] ?? '',
$row['deliveries'] ?? 0,
];
}
if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) {
return [
$row['name'] ?? '',
$row['email'] ?? '',
$row['career_level'] ?? '',
$row['own_points'] ?? 0,
$row['external_points'] ?? 0,
$row['customer_abo_points'] ?? 0,
$row['customer_single_order_points'] ?? 0,
$row['customer_other_points'] ?? 0,
$row['total_points'] ?? 0,
];
}
return [
$row['name'] ?? '',
$row['email'] ?? '',
$row['career_level'] ?? '',
$row['active_date'] ?? '',
$row['payment_account'] ?? '',
$row['account_status'] ?? '',
];
}
/**
* @param array<string, mixed> $details
* @return array<int, mixed>
*/
private function csvSummaryRow(array $details): array
{
$metric = $details['metric'];
$summary = $details['summary'];
if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
return ['Summe', $summary['count'].' Eintraege', '', '', $summary['points'], '', '', '', '', $summary['deliveries']];
}
if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) {
return ['Summe', $summary['count'].' Eintraege', '', $summary['own_points'], $summary['external_points'], $summary['customer_abo_points'], $summary['customer_single_order_points'], $summary['customer_other_points'], $summary['total_points']];
}
return ['Summe', $summary['count'].' Eintraege', '', '', '', ''];
}
}

View file

@ -87,6 +87,7 @@ class CheckoutController extends Controller
'is_for' => $is_for, 'is_for' => $is_for,
'is_abo' => $is_abo, 'is_abo' => $is_abo,
'abo_interval' => $abo_interval, 'abo_interval' => $abo_interval,
'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(),
'shopping_data' => $shopping_data, 'shopping_data' => $shopping_data,
'user_shop' => Util::getUserShop(), 'user_shop' => Util::getUserShop(),
'shopping_user' => $shopping_user, 'shopping_user' => $shopping_user,
@ -225,6 +226,11 @@ class CheckoutController extends Controller
'accepted_data_checkbox' => 'accepted', 'accepted_data_checkbox' => 'accepted',
]; ];
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';
}
if (Request::get('same_as_billing')) { if (Request::get('same_as_billing')) {
$rules = array_merge($rules, [ $rules = array_merge($rules, [
'shipping_firstname' => 'required', 'shipping_firstname' => 'required',

View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BackofficeStatisticsSnapshot extends Model
{
protected $table = 'backoffice_statistics_snapshots';
protected $fillable = [
'user_id',
'year',
'month',
'payload',
'calculated_at',
];
protected function casts(): array
{
return [
'user_id' => 'int',
'year' => 'int',
'month' => 'int',
'payload' => 'array',
'calculated_at' => 'datetime',
];
}
}

View file

@ -167,6 +167,8 @@ class ShoppingOrder extends Model
'api_notice', 'api_notice',
'api_status', 'api_status',
'mode', 'mode',
'customer_order_source',
'customer_order_source_comment',
'shipped', 'shipped',
'tracking', 'tracking',
]; ];
@ -180,6 +182,29 @@ class ShoppingOrder extends Model
'points' => 'float', 'points' => 'float',
]; ];
public const CUSTOMER_ORDER_SOURCE_OPTIONS = [
'recommendation' => 'Empfehlung',
'social_media' => 'Social Media',
'search_engine' => 'Google / Suchmaschine',
'event' => 'Event / Messe',
'consultant_link' => 'Berater-Link',
'returning_customer' => 'Wiederbesteller',
'other' => 'Sonstiges',
];
/**
* @return array<string, string>
*/
public static function customerOrderSourceOptions(): array
{
return self::CUSTOMER_ORDER_SOURCE_OPTIONS;
}
public function getCustomerOrderSourceLabel(): string
{
return self::CUSTOMER_ORDER_SOURCE_OPTIONS[$this->customer_order_source] ?? '';
}
public static $shippedTypes = [ public static $shippedTypes = [
0 => 'open', 0 => 'open',
1 => 'in_process', 1 => 'in_process',

View file

@ -28,6 +28,7 @@ class CheckoutRepository extends BaseRepository
public function makeShoppingOrder($shopping_user, $data) public function makeShoppingOrder($shopping_user, $data)
{ {
$requestData = $data;
$user_shop = Util::getUserShop(); $user_shop = Util::getUserShop();
if ($shopping_user->is_from === 'homeparty') { if ($shopping_user->is_from === 'homeparty') {
@ -103,6 +104,11 @@ class CheckoutRepository extends BaseRepository
'txaction' => 'prev', 'txaction' => 'prev',
'mode' => Util::getUserShoppingMode(), 'mode' => Util::getUserShoppingMode(),
]; ];
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;
}
} }
$shopping_order = false; $shopping_order = false;

View file

@ -0,0 +1,365 @@
<?php
namespace App\Services\Backoffice;
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class BackofficeDashboardService
{
private const MAX_DEPTH_SAFETY_LIMIT = 100;
/**
* @return array<string, string>
*/
public function metricLabels(): array
{
return [
'consultants' => 'Berater',
'new_partners' => 'Neupartner',
'team_partner_abos' => 'Teamabos',
'team_customer_abos' => 'Teamkundenabos',
'own_points' => 'Eigenpunkte',
'external_points' => 'Externe Kundenpunkte',
'customer_abo_points' => 'Kundenabo-Punkte',
'customer_single_order_points' => 'Einzelbestellungs-Punkte',
'customer_other_points' => 'Sonstige Kundenpunkte',
'total_points' => 'Gesamtpunkte',
'shop_1000' => '1000 Punkte Shop',
];
}
/**
* @return array{month: int, year: int, metric_labels: array<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
public function overview(User $user, int $month, int $year): array
{
if ($this->isClosedMonth($month, $year)) {
$snapshot = BackofficeStatisticsSnapshot::query()
->where('user_id', $user->id)
->where('month', $month)
->where('year', $year)
->first();
if ($snapshot) {
return $this->withMeta($snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i'));
}
}
return $this->withMeta($this->buildOverview($user, $month, $year), 'live');
}
public function storeSnapshot(User $user, int $month, int $year): BackofficeStatisticsSnapshot
{
$payload = $this->buildOverview($user, $month, $year);
return BackofficeStatisticsSnapshot::query()->updateOrCreate(
[
'user_id' => $user->id,
'year' => $year,
'month' => $month,
],
[
'payload' => $payload,
'calculated_at' => now(),
]
);
}
/**
* @return array{month: int, year: int, metric_labels: array<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
private function buildOverview(User $user, int $month, int $year): array
{
$lineBuckets = $this->lineBuckets($user->id);
$lines = [];
$totals = $this->emptyLine(0, []);
foreach ($lineBuckets as $line => $users) {
$row = $this->buildLineRow($line, $users, $month, $year);
$lines[$line] = $row;
$totals = $this->addToTotals($totals, $row);
}
$totals['label'] = 'Summe';
return [
'month' => $month,
'year' => $year,
'metric_labels' => $this->metricLabels(),
'lines' => $lines,
'totals' => $totals,
];
}
/**
* @param array<string, mixed> $overview
* @return array<string, mixed>
*/
private function withMeta(array $overview, string $source, ?string $calculatedAt = null): array
{
$overview['_meta'] = [
'source' => $source,
'source_label' => $source === 'snapshot' ? 'Snapshot' : 'Live',
'calculated_at' => $calculatedAt,
];
return $overview;
}
private function isClosedMonth(int $month, int $year): bool
{
return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth());
}
/**
* @return array<int, \Illuminate\Support\Collection<int, \App\User>>
*/
public function lineBuckets(int $rootUserId): array
{
$lineBuckets = [];
$currentSponsorIds = [$rootUserId];
$visitedUserIds = [$rootUserId];
for ($line = 1; $line <= self::MAX_DEPTH_SAFETY_LIMIT && $currentSponsorIds !== []; $line++) {
$users = User::query()
->with('account')
->whereIn('m_sponsor', $currentSponsorIds)
->whereNotIn('id', $visitedUserIds)
->whereColumn('id', '!=', 'm_sponsor')
->whereNull('deleted_at')
->get();
if ($users->isEmpty()) {
break;
}
$lineBuckets[$line] = $users;
$currentSponsorIds = $users->pluck('id')->all();
$visitedUserIds = array_merge($visitedUserIds, $currentSponsorIds);
}
return $lineBuckets;
}
/**
* @return int[]
*/
public function lineUserIds(int $rootUserId, int $line): array
{
if ($line === 0) {
return $this->teamUserIds($rootUserId);
}
if ($line < 1) {
return [];
}
return ($this->lineBuckets($rootUserId)[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
}
/**
* @return int[]
*/
public function teamUserIds(int $rootUserId): array
{
return collect($this->lineBuckets($rootUserId))
->flatMap(fn (Collection $users) => $users->pluck('id'))
->map(fn ($id) => (int) $id)
->values()
->all();
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
* @return array<string, mixed>
*/
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
{
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
if ($userIds === []) {
return $this->emptyLine($line, $userIds);
}
$salesSummary = $this->salesSummary($userIds, $month, $year);
return [
'line' => $line,
'label' => 'Linie '.$line,
'user_ids' => $userIds,
'consultants' => $this->activeConsultants($users),
'new_partners' => $this->newPartners($users, $month, $year),
'team_partner_abos' => $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(),
'team_partner_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year),
'team_customer_abos' => $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(),
'team_customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year),
'own_points' => $salesSummary['own_points'],
'external_points' => $salesSummary['external_points'],
'customer_abo_points' => $salesSummary['customer_abo_points'],
'customer_single_order_points' => $salesSummary['customer_single_order_points'],
'customer_other_points' => $salesSummary['customer_other_points'],
'total_points' => $salesSummary['total_points'],
'turnover_net' => $salesSummary['turnover_net'],
'shop_1000' => $this->shop1000Count($userIds, $month, $year),
];
}
/**
* @return array<string, mixed>
*/
private function emptyLine(int $line, array $userIds): array
{
return [
'line' => $line,
'label' => $line > 0 ? 'Linie '.$line : 'Summe',
'user_ids' => $userIds,
'consultants' => 0,
'new_partners' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'own_points' => 0.0,
'external_points' => 0.0,
'customer_abo_points' => 0.0,
'customer_single_order_points' => 0.0,
'customer_other_points' => 0.0,
'total_points' => 0.0,
'turnover_net' => 0.0,
'shop_1000' => 0,
];
}
/**
* @param array<string, mixed> $totals
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function addToTotals(array $totals, array $row): array
{
foreach (array_keys($this->metricLabels()) as $metric) {
$totals[$metric] += $row[$metric];
}
$totals['turnover_net'] += $row['turnover_net'];
$totals['team_partner_abos_new'] += $row['team_partner_abos_new'];
$totals['team_customer_abos_new'] += $row['team_customer_abos_new'];
$totals['user_ids'] = array_merge($totals['user_ids'], $row['user_ids']);
return $totals;
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
*/
private function activeConsultants(Collection $users): int
{
return $users
->filter(fn (User $user) => $user->m_level !== null && $user->payment_account !== null)
->count();
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
*/
private function newPartners(Collection $users, int $month, int $year): int
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return $users
->filter(function (User $user) use ($startDate, $endDate): bool {
if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) {
return false;
}
$activeDate = Carbon::parse($user->active_date);
return $activeDate->betweenIncluded($startDate, $endDate);
})
->count();
}
private function hasActivePaymentAccount(User $user): bool
{
return $user->payment_account !== null && Carbon::parse($user->payment_account)->isFuture();
}
private function activeAboQuery(): Builder
{
return UserAbo::query()
->where('active', true)
->whereNotIn('status', [4, 5, 6]);
}
/**
* @param int[] $userIds
*/
private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return $this->activeAboQuery()
->whereIn($userColumn, $userIds)
->where('is_for', $isFor)
->whereBetween('start_date', [$startDate, $endDate])
->count();
}
/**
* @param int[] $userIds
* @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, turnover_net: float}
*/
private function salesSummary(array $userIds, int $month, int $year): array
{
$summary = UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->selectRaw('COALESCE(SUM(month_KP_points), 0) as own_points')
->selectRaw('COALESCE(SUM(month_shop_points), 0) as external_points')
->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) as customer_abo_points')
->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) as customer_single_order_points')
->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) as customer_other_points')
->selectRaw('COALESCE(SUM(month_total_net), 0) + COALESCE(SUM(month_shop_total_net), 0) as turnover_net')
->first();
$ownPoints = (float) ($summary->own_points ?? 0);
$externalPoints = (float) ($summary->external_points ?? 0);
return [
'own_points' => $ownPoints,
'external_points' => $externalPoints,
'customer_abo_points' => (float) ($summary->customer_abo_points ?? 0),
'customer_single_order_points' => (float) ($summary->customer_single_order_points ?? 0),
'customer_other_points' => (float) ($summary->customer_other_points ?? 0),
'total_points' => $ownPoints + $externalPoints,
'turnover_net' => (float) ($summary->turnover_net ?? 0),
];
}
/**
* @param int[] $userIds
*/
private function shop1000Count(array $userIds, int $month, int $year): int
{
return UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_id')
->selectRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) as total_points')
->groupBy('user_id')
->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) >= 1000')
->get()
->count();
}
}

View file

@ -0,0 +1,331 @@
<?php
namespace App\Services\Backoffice;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class BackofficeDrilldownService
{
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* @return array{metric: string, metric_label: string, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
*/
public function details(User $viewer, int $line, string $metric, int $month, int $year): array
{
$metricLabels = $this->dashboardService->metricLabels();
if (! array_key_exists($metric, $metricLabels)) {
abort(404);
}
$userIds = $this->dashboardService->lineUserIds($viewer->id, $line);
$rows = match ($metric) {
'consultants' => $this->consultantRows($userIds),
'new_partners' => $this->newPartnerRows($userIds, $month, $year),
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year),
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year),
'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric),
default => [],
};
return [
'metric' => $metric,
'metric_label' => $metricLabels[$metric],
'line' => $line,
'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien',
'month' => $month,
'year' => $year,
'rows' => $rows,
'summary' => $this->summary($rows),
];
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{count: int, points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int}
*/
private function summary(array $rows): array
{
return [
'count' => count($rows),
'points' => (float) collect($rows)->sum('points'),
'own_points' => (float) collect($rows)->sum('own_points'),
'external_points' => (float) collect($rows)->sum('external_points'),
'customer_abo_points' => (float) collect($rows)->sum('customer_abo_points'),
'customer_single_order_points' => (float) collect($rows)->sum('customer_single_order_points'),
'customer_other_points' => (float) collect($rows)->sum('customer_other_points'),
'total_points' => (float) collect($rows)->sum('total_points'),
'deliveries' => (int) collect($rows)->sum('deliveries'),
];
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function consultantRows(array $userIds): array
{
return User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->orderBy('id')
->get()
->map(fn (User $user) => [
'type' => 'user',
'user_id' => $user->id,
'name' => $this->userName($user),
'email' => $user->email,
'career_level' => $this->careerLevel($user),
'is_account_active' => Carbon::parse($user->payment_account)->isFuture(),
'account_status' => Carbon::parse($user->payment_account)->isFuture() ? 'Aktiv' : 'Abgelaufen',
'active_date' => $this->formatDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
])
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function newPartnerRows(array $userIds, int $month, int $year): array
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->whereBetween('active_date', [$startDate, $endDate])
->orderBy('active_date')
->get()
->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture())
->map(fn (User $user) => [
'type' => 'user',
'user_id' => $user->id,
'name' => $this->userName($user),
'email' => $user->email,
'career_level' => $this->careerLevel($user),
'active_date' => $this->formatDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
])
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function partnerAboRows(array $userIds, int $month, int $year): array
{
return $this->activeAboQuery()
->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
->whereIn('user_id', $userIds)
->where('is_for', 'me')
->orderBy('next_date')
->get()
->map(fn (UserAbo $abo) => [
'type' => 'abo',
'abo_id' => $abo->id,
'user_id' => $abo->user_id,
'name' => $abo->user ? $this->userName($abo->user) : '#'.$abo->user_id,
'email' => $abo->user?->email,
'career_level' => $abo->user ? $this->careerLevel($abo->user) : '-',
'points' => $abo->getTotalPoints(),
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
'next_date' => $this->formatDate($abo->next_date),
'deliveries' => $abo->getCountOrders(),
'status' => $abo->status,
'status_label' => $abo->getStatusType(),
'status_badge' => $abo->getStatusFormated(),
'status_reason' => $this->aboStatusReason($abo),
])
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function customerAboRows(array $userIds, int $month, int $year): array
{
return $this->activeAboQuery()
->with(['member.account', 'member.user_level', 'user.account', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
->whereIn('member_id', $userIds)
->where('is_for', 'ot')
->orderBy('member_id')
->orderBy('next_date')
->get()
->map(fn (UserAbo $abo) => [
'type' => 'customer_abo',
'abo_id' => $abo->id,
'user_id' => $abo->user_id,
'member_id' => $abo->member_id,
'name' => $abo->user ? $this->userName($abo->user) : ($abo->email ?: '#'.$abo->user_id),
'email' => $abo->email ?: $abo->user?->email,
'consultant_name' => $abo->member ? $this->userName($abo->member) : '#'.$abo->member_id,
'career_level' => $abo->member ? $this->careerLevel($abo->member) : '-',
'points' => $abo->getTotalPoints(),
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
'next_date' => $this->formatDate($abo->next_date),
'deliveries' => $abo->getCountOrders(),
'status' => $abo->status,
'status_label' => $abo->getStatusType(),
'status_badge' => $abo->getStatusFormated(),
'status_reason' => $this->aboStatusReason($abo),
])
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function pointsRows(array $userIds, int $month, int $year, string $metric): array
{
$rows = UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_sales_volumes.user_id')
->selectRaw('COALESCE(SUM(month_KP_points), 0) as own_points')
->selectRaw('COALESCE(SUM(month_shop_points), 0) as external_points')
->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) as customer_abo_points')
->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) as customer_single_order_points')
->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) as customer_other_points')
->selectRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) as total_points')
->groupBy('user_sales_volumes.user_id');
if ($metric === 'own_points') {
$rows->havingRaw('COALESCE(SUM(month_KP_points), 0) > 0');
}
if ($metric === 'external_points') {
$rows->havingRaw('COALESCE(SUM(month_shop_points), 0) > 0');
}
if ($metric === 'customer_abo_points') {
$rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) > 0');
}
if ($metric === 'customer_single_order_points') {
$rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) > 0');
}
if ($metric === 'customer_other_points') {
$rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) > 0');
}
if ($metric === 'total_points') {
$rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) > 0');
}
if ($metric === 'shop_1000') {
$rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) >= 1000');
}
$salesRows = $rows->orderByDesc('total_points')->get();
$users = User::query()->with(['account', 'user_level'])->whereIn('id', $salesRows->pluck('user_id'))->get()->keyBy('id');
return $salesRows
->map(fn (UserSalesVolume $row) => [
'type' => 'points',
'user_id' => $row->user_id,
'name' => $users->has($row->user_id) ? $this->userName($users->get($row->user_id)) : '#'.$row->user_id,
'email' => $users->get($row->user_id)?->email,
'career_level' => $users->has($row->user_id) ? $this->careerLevel($users->get($row->user_id)) : '-',
'own_points' => (float) $row->own_points,
'external_points' => (float) $row->external_points,
'customer_abo_points' => (float) $row->customer_abo_points,
'customer_single_order_points' => (float) $row->customer_single_order_points,
'customer_other_points' => (float) $row->customer_other_points,
'total_points' => (float) $row->total_points,
])
->values()
->all();
}
private function activeAboQuery(): Builder
{
return UserAbo::query()
->where('active', true)
->whereNotIn('status', [4, 5, 6]);
}
private function userName(User $user): string
{
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));
return $name !== '' ? $name : ($user->email ?: '#'.$user->id);
}
private function careerLevel(User $user): string
{
return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-');
}
private function aboStatusReason(UserAbo $abo): ?string
{
if ((int) $abo->status === 2) {
return null;
}
$transaction = $abo->user_abo_orders
->sortByDesc('created_at')
->pluck('shopping_order')
->filter()
->map(fn ($order) => $order->getLastShoppingPaymentTransaction())
->filter()
->first();
if (! $transaction) {
return null;
}
$message = $transaction->errormessage ?: $transaction->customermessage;
if (! $message) {
return null;
}
return $transaction->errorcode ? '['.$transaction->errorcode.'] '.$message : $message;
}
private function isAboNewInMonth(UserAbo $abo, int $month, int $year): bool
{
$startDate = $abo->getRawOriginal('start_date');
if (! $startDate) {
return false;
}
$date = Carbon::parse($startDate);
return (int) $date->month === $month && (int) $date->year === $year;
}
private function formatDate(mixed $date): ?string
{
if ($date === null || $date === '') {
return null;
}
return Carbon::parse($date)->format('d.m.Y');
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('backoffice_statistics_snapshots', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('user_id');
$table->unsignedSmallInteger('year');
$table->unsignedTinyInteger('month');
$table->json('payload');
$table->timestamp('calculated_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'year', 'month'], 'backoffice_statistics_snapshot_unique');
$table->index(['year', 'month']);
$table->foreign('user_id')
->references('id')
->on('users')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('backoffice_statistics_snapshots');
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->string('customer_order_source')->nullable()->after('mode');
$table->text('customer_order_source_comment')->nullable()->after('customer_order_source');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->dropColumn(['customer_order_source', 'customer_order_source_comment']);
});
}
};

View file

@ -10,7 +10,7 @@ Das Partner-Backoffice soll von einer statischen Monats-Kachelansicht zu einer k
Kernziel ist eine einheitliche Datenbasis für: Kernziel ist eine einheitliche Datenbasis für:
- Dashboard-Kennzahlen pro Linie 1 bis 8 inklusive Summenzeile - Dashboard-Kennzahlen pro vorhandener Linie inklusive Summenzeile
- Detailansichten je Linie, Firstline und Kennzahl - Detailansichten je Linie, Firstline und Kennzahl
- Kundenabos, Teamabos, Kundenabos im Team, Umsatz und Punkte - Kundenabos, Teamabos, Kundenabos im Team, Umsatz und Punkte
- neue Spezial-Kennzahl "1000 Punkte Shop" - neue Spezial-Kennzahl "1000 Punkte Shop"
@ -37,7 +37,7 @@ Die aktuelle Statistik wird direkt im Blade-Partial berechnet. Sie nutzt unter a
Bewertung: Bewertung:
- Die vorhandene Ansicht ist ein guter Einstieg, aber fachlich zu grob. - Die vorhandene Ansicht ist ein guter Einstieg, aber fachlich zu grob.
- Es gibt noch keine Linien 1 bis 8, keine Summenzeile und keine Drill-down-Routen. - Es gibt noch keine dynamische Linienübersicht, keine Summenzeile und keine Drill-down-Routen.
- Die Kennzahl "Kundenabos" vermischt aktuell eigene Abos und Kundenabos. - Die Kennzahl "Kundenabos" vermischt aktuell eigene Abos und Kundenabos.
- "Team-Abos" zählt aktuell Beraterabos im Team, aber nicht die separat geforderten Kundenabos im Team. - "Team-Abos" zählt aktuell Beraterabos im Team, aber nicht die separat geforderten Kundenabos im Team.
@ -296,6 +296,48 @@ Geplante Actions:
Die vorhandenen Team-Views bleiben bestehen. Das neue Dashboard verweist für Detailseiten aber auf eigene, schlankere Statistik-Views. Die vorhandenen Team-Views bleiben bestehen. Das neue Dashboard verweist für Detailseiten aber auf eigene, schlankere Statistik-Views.
### Erster MVP: VIP-Menüpunkt Statistik
Der erste technische Schritt ist ein eigener Navigationspunkt `Statistik` im User-Backoffice. Dieser Punkt ist bewusst nicht unter `Mein Team` einsortiert, sondern als eigener Einstieg sichtbar, damit die neue Auswertung fachlich als eigenes Modul wahrgenommen wird.
Bereits angelegte Basis:
- Route: `GET /user/backoffice/statistics`
- Detailroute: `GET /user/backoffice/statistics/details?line=...&metric=...&month=...&year=...`
- Name: `user_backoffice_statistics`
- Controller: `App\Http\Controllers\User\BackofficeStatisticsController@index`
- View: `resources/views/user/backoffice/statistics/index.blade.php`
- Services: `App\Services\Backoffice\BackofficeDashboardService` und `App\Services\Backoffice\BackofficeDrilldownService`
- Navigation: eigener Sidenav-Punkt `Statistik` mit `VIP`-Badge
- Zugriff: nur `Auth::user()->isVIP()`, normale aktive User erhalten `404`
- MVP-Übersicht: alle tatsächlich vorhandenen Linien mit Beratern inklusive abgelaufener, aber nicht gelöschter Accounts, Neupartnern, Teamabos, Teamkundenabos, Eigenpunkten, externen Punkten, Kundenabo-Punkten, Einzelbestellungs-Punkten, sonstigen Kundenpunkten, Gesamtpunkten und `1000 Punkte Shop`
- MVP-Details: klickbare Listen für Berater, Neupartner, Teamabos, Teamkundenabos und Punktekennzahlen
- Summenzeile: ebenfalls klickbar über `line=0` und damit Detailansicht über alle vorhandenen Linien
- Berater-Detail: abgelaufene, aber nicht gelöschte Accounts werden mit roter Tabellenzeile und Status `Abgelaufen` angezeigt
- Abo-Detail: zeigt `Besteht seit`; neue Abos im gewählten Monat werden grün markiert und in der Übersicht als Klammerzahl hinter den Abo-Counts gezeigt
- Zeitraum: gewählter Monat/Jahr bleibt über Query-Parameter und Session erhalten, auch beim Rücksprung aus Detailansichten
- Detailtabellen: clientseitige Suche, klickbare Sortierung der Spalten und Summenzeile am Tabellenende
- Detailtabellen: CSV-Export über `GET /user/backoffice/statistics/export?line=...&metric=...&month=...&year=...`
- Übersicht: Kennzahlen werden als gut anklickbare Badges dargestellt; Nullwerte bleiben sichtbar, aber nicht klickbar
- Punktetrennung: externe Kundenpunkte werden zusätzlich nach Kundenabo-Punkten, Einzelbestellungs-Punkten und sonstigen Kundenpunkten getrennt
- Snapshots: abgeschlossene Monate können in `backoffice_statistics_snapshots` gespeichert werden; vorhandene Snapshots werden bevorzugt vor Live-Berechnung geladen
- Command: `backoffice:store-statistics-snapshots` speichert Snapshots für VIP-User, optional mit `--user`, `--month`, `--year` und `--force`
- 1000-Punkte-Shop: Detailansicht zeigt die getrennten Punkte nach Eigen-, Abo-, Einzelbestellungs- und sonstigen Kundenpunkten; die vorherige Qualifikations-Einteilung wurde zugunsten des aktuellen Karriere-Levels entfernt
- Karriere-Level: Detailansichten zeigen den aktuellen Karriere-Level des jeweiligen Beraters
- Datenschutz: Detailansichten zeigen einen sichtbaren Hinweis, dass personenbezogene Detaildaten rechtlich noch final geklärt werden und aktuell nur für berechtigte VIP-Auswertungen vorgesehen sind
- Übersichts-Export: Die Linienübersicht kann als CSV exportiert werden, inklusive Summenzeile, neuer Abo-Zählungen und Punktetrennung
- Tests: CSV-Inhalte, Detail-CSV mit Karriere-Level, Zeitraum-Erhalt, neue Abo-Markierung und Abo-Statusgrund aus Zahlungsfehlern sind gezielt abgesichert
- Performance-Hinweis: Die Übersicht zeigt Datenquelle (`Live` oder `Snapshot`) und Laufzeit der Berechnung, um große VIP-Teams leichter prüfen zu können
- Checkout-Herkunft: Kundenbestellungen im Shop speichern eine vordefinierte Herkunft plus optionalen Freitext und zeigen diese im Bestelldetail an
Weitere Umsetzung nach dem MVP:
1. Snapshot-Command nach Migration auf Test-/Produktivdaten einmalig für abgeschlossene Monate laufen lassen.
2. Umsatzarten weiter fachlich verfeinern, falls neben `shopping_orders.is_abo` zusätzliche Herkunftsarten zuverlässig gespeichert werden.
3. Optional Excel-Export ergänzen, falls CSV für den Fachbereich nicht reicht.
4. Spätere rechtliche Einschränkungen für Kundendaten nach finaler Klärung einarbeiten.
5. `1000 Punkte Shop` nach fachlicher Abnahme ggf. um weitere Herkunftsarten erweitern.
### Datenmodell und Definitionen ### Datenmodell und Definitionen
Einheitliche Metriken: Einheitliche Metriken:
@ -325,7 +367,7 @@ Jede Detailansicht muss sicherstellen:
- Fachliche Definitionen finalisieren - Fachliche Definitionen finalisieren
- `BackofficeDashboardService` erstellen - `BackofficeDashboardService` erstellen
- Stufe-1-Linienübersicht mit Linien 1 bis 8 und Summenzeile bauen - Stufe-1-Linienübersicht mit allen tatsächlich vorhandenen Linien und Summenzeile bauen
- bestehende Dashboard-Kachel durch Link auf neue Statistikseite ergänzen oder neue Seite im Menü aufnehmen - bestehende Dashboard-Kachel durch Link auf neue Statistikseite ergänzen oder neue Seite im Menü aufnehmen
- Kennzahlen noch ohne vollständigen Deep Dive, aber bereits sauber berechnet - Kennzahlen noch ohne vollständigen Deep Dive, aber bereits sauber berechnet
@ -346,10 +388,10 @@ Jede Detailansicht muss sicherstellen:
### Phase 4: Herkunftsabfrage im Checkout ### Phase 4: Herkunftsabfrage im Checkout
- Migration und Model-Fillable ergänzen - umgesetzt: Migration `shopping_orders.customer_order_source` und `customer_order_source_comment`
- Checkout-Formular erweitern - umgesetzt: Checkout-Formular für Shop-Kundenbestellungen (`is_from = shopping`) mit Auswahlfeld plus optionalem Freitext
- Validierung und Speicherung ergänzen - umgesetzt: Validierung und Speicherung in `CheckoutController` / `CheckoutRepository`
- Admin-/Bestelldetail oder Export um Feld erweitern - umgesetzt: Anzeige im Bestelldetail für `payment_for = 6`
### Phase 5: Storno-Qualitätssicherung ### Phase 5: Storno-Qualitätssicherung
@ -370,7 +412,7 @@ Jede Detailansicht muss sicherstellen:
Feature-Tests: Feature-Tests:
- Dashboard zeigt nur Daten der eigenen Downline. - Dashboard zeigt nur Daten der eigenen Downline.
- Linien 1 bis 8 werden korrekt gruppiert. - alle tatsächlich vorhandenen Linien werden korrekt gruppiert.
- Summenzeile entspricht Summe der Linien. - Summenzeile entspricht Summe der Linien.
- Klick auf Teamabos zeigt nur `is_for = 'me'` in der Downline. - Klick auf Teamabos zeigt nur `is_for = 'me'` in der Downline.
- Klick auf Kundenabos im Team zeigt nur `is_for = 'ot'` mit `member_id` in der Downline. - Klick auf Kundenabos im Team zeigt nur `is_for = 'ot'` mit `member_id` in der Downline.
@ -395,13 +437,21 @@ Regressionsprüfung:
## Offene Fachfragen ## Offene Fachfragen
1. Soll die neue Statistik die aktuelle Monatslogik nutzen oder standardmäßig den letzten abgeschlossenen Monat zeigen? 1. Soll die neue Statistik die aktuelle Monatslogik nutzen oder standardmäßig den letzten abgeschlossenen Monat zeigen?
//beides, Wichtig ist immer der aktuelle Monat, das hier um Qualität zahlen gibt und natürlich auch die letzten abgeschlossenen Monat. Hier können wir natürlich auch in Datenbanken entsprechend die Kennzahlen speichern und nicht immer hohe Quere an die Datenbank zu senden.
2. Sollen Stornos im Stornomonat oder im ursprünglichen Umsatzmonat gegengerechnet werden? 2. Sollen Stornos im Stornomonat oder im ursprünglichen Umsatzmonat gegengerechnet werden?
//ist noch zu prüfen Wird umgesetzt, sobald hier eine deutliche Klärung stattgefunden hat
3. Wie genau wird "1000 Punkte Shop" definiert: nur Shop-Punkte, alle Kundenpunkte oder Kundenabos plus Einzelbestellungen? 3. Wie genau wird "1000 Punkte Shop" definiert: nur Shop-Punkte, alle Kundenpunkte oder Kundenabos plus Einzelbestellungen?
Ich würde hier erst mal alle Punkte zusammenziehen also Kunden funkte Kunden Abos Einzelbestellungen etc. alles was in die einzelnen Punkte des Kunden geht. Zusätzlich würde ich's einmal trennen nach Eigenpunkten und externen Punkten. D.h. grundsätzlich würde ich hier auch eine Trennung vornehmen der einzelnen Shop Punkte, Kunden, Abos, Einzelbestellung etc.
4. Welche Kundendaten dürfen Berater in Deep-Dive-Listen sehen? 4. Welche Kundendaten dürfen Berater in Deep-Dive-Listen sehen?
In der Entwicklung zeigen wir erst mal die gesamten Inhalte an. Mit einem Hinweis wird gerade rechtlich geklärt.
5. Ist die Herkunftsabfrage Freitext, Auswahlfeld oder Kombination? 5. Ist die Herkunftsabfrage Freitext, Auswahlfeld oder Kombination?
Kombination ein Auswahlfeld von vordefinierten Sachen alternativ Freitext
6. Gilt die Herkunftsabfrage für alle Checkout-Flows oder nur für externe Kundenbestellungen? 6. Gilt die Herkunftsabfrage für alle Checkout-Flows oder nur für externe Kundenbestellungen?
Nur für Kundenbestellung in den Shops
7. Darf eine Incentive-Teilnahme bereits Name/Foto/Land freigeben oder braucht es ein separates Opt-in? 7. Darf eine Incentive-Teilnahme bereits Name/Foto/Land freigeben oder braucht es ein separates Opt-in?
Auch hier befindet sich noch beim Rechtsanwalt in Klärung. Hier würde ich erst mal einbauen Und mit Hinweisen versehen, die dann gegebenenfalls später rausgenommen werden müssen
8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads? 8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads?
Echte Galerie mit mehrfach Upload
## Empfehlung ## Empfehlung
@ -415,3 +465,15 @@ Priorität für die erste Umsetzung:
4. 1000-Punkte-Shop 4. 1000-Punkte-Shop
5. Checkout-Herkunft und Storno-Tests 5. Checkout-Herkunft und Storno-Tests
6. Incentive-Sichtbarkeit und Event-Archiv 6. Incentive-Sichtbarkeit und Event-Archiv
Snapshots/Caching: Abgeschlossene Monate speichern, damit große Teams nicht jedes Mal live berechnet werden.
php artisan migrate
php artisan backoffice:store-statistics-snapshots
php artisan list backoffice
1000-Punkte-Shop verfeinert: zählt weiter Berater ab 1000 Gesamtpunkten und zeigt in der Detailansicht den aktuellen Karriere-Level statt einer fachlich erklärungsbedürftigen Qualifikations-Einteilung.
Datenschutz-Hinweis umgesetzt: Detailansichten weisen sichtbar darauf hin, dass personenbezogene Daten rechtlich noch final geklärt werden.
Übersichts-Export umgesetzt: CSV-Export steht auch direkt in der Linienübersicht zur Verfügung.
Tests ausgebaut: CSV-Inhalte und Zeitraum-Erhalt sind näher am Controller-/Export-Flow abgesichert; Abo-Statusgrund und neue Abo-Markierung sind im Service-Test abgedeckt.
Performance prüfen: Bei echten VIP-Accounts mit großem Team messen, ob die Live-Queries schnell genug sind.

View file

@ -47,6 +47,7 @@ return [
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',
'shipping_costs' => 'Versandkosten', 'shipping_costs' => 'Versandkosten',
'start_site' => 'Startseite Shop', 'start_site' => 'Startseite Shop',
'statistics' => 'Statistik',
'structure' => 'Struktur', 'structure' => 'Struktur',
'system_settings' => 'System-E.', 'system_settings' => 'System-E.',
'translate' => 'Übersetzungen', 'translate' => 'Übersetzungen',

View file

@ -56,6 +56,7 @@ return [
'settings' => 'settings', 'settings' => 'settings',
'shipping_costs' => 'shipping', 'shipping_costs' => 'shipping',
'start_site' => 'home page', 'start_site' => 'home page',
'statistics' => 'Statistics',
'structure' => 'structure', 'structure' => 'structure',
'system_settings' => 'system E.', 'system_settings' => 'system E.',
'tags' => 'Tags', 'tags' => 'Tags',

View file

@ -56,6 +56,7 @@ return [
'settings' => 'ajustes', 'settings' => 'ajustes',
'shipping_costs' => 'envío', 'shipping_costs' => 'envío',
'start_site' => 'pagina de inicio', 'start_site' => 'pagina de inicio',
'statistics' => 'Estadísticas',
'structure' => 'estructura', 'structure' => 'estructura',
'system_settings' => 'sistema E.', 'system_settings' => 'sistema E.',
'tags' => 'Etiquetas', 'tags' => 'Etiquetas',

View file

@ -47,6 +47,7 @@ return [
'settings' => 'Paramètres', 'settings' => 'Paramètres',
'shipping_costs' => 'Frais de livraison', 'shipping_costs' => 'Frais de livraison',
'start_site' => 'Page daccueil boutique', 'start_site' => 'Page daccueil boutique',
'statistics' => 'Statistiques',
'structure' => 'Structure', 'structure' => 'Structure',
'system_settings' => 'Param. système', 'system_settings' => 'Param. système',
'translate' => 'Traductions', 'translate' => 'Traductions',

View file

@ -185,6 +185,15 @@
<div class="text-muted small">{{ __('order.points_total') }}</div> <div class="text-muted small">{{ __('order.points_total') }}</div>
{{ $shopping_order->getFormattedPoints() }} {{ $shopping_order->getFormattedPoints() }}
</div> </div>
@if ((int) $shopping_order->payment_for === 6 && $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>
@endif
</div>
@endif
</div> </div>
</div> </div>
<hr class="m-0"> <hr class="m-0">

View file

@ -55,6 +55,17 @@
<div>{{ __('navigation.news_archive') }}</div> <div>{{ __('navigation.news_archive') }}</div>
</a> </a>
</li> </li>
@if (Auth::user()->isVIP())
<li class="sidenav-item{{ Request::is('user/backoffice/statistics*') ? ' active' : '' }}">
<a href="{{ route('user_backoffice_statistics') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-ios-stats"></i>
<div>{{ __('navigation.statistics') }}</div>
<div class="pl-1 ml-auto">
<div class="badge badge-secondary">VIP</div>
</div>
</a>
</li>
@endif
<li class="sidenav-item @if (Request::is('user/team/*')) open @endif"> <li class="sidenav-item @if (Request::is('user/team/*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle"> <a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-ios-people"></i> <i class="sidenav-icon ion ion-ios-people"></i>

View file

@ -0,0 +1,279 @@
@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>
{{ __('navigation.statistics') }} / {{ $details['metric_label'] }}
<span class="badge badge-secondary ml-2">VIP</span>
</div>
<div>
<a href="{{ route('user_backoffice_statistics_export', [
'line' => $details['line'],
'metric' => $details['metric'],
'month' => $selectedMonth,
'year' => $selectedYear,
]) }}"
class="btn btn-sm btn-outline-secondary mr-2">
<span class="ion ion-md-download mr-1"></span> CSV
</a>
<a href="{{ route('user_backoffice_statistics', ['month' => $selectedMonth, 'year' => $selectedYear]) }}"
class="btn btn-sm btn-default">
{{ __('back') }}
</a>
</div>
</h4>
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-2">
<div class="text-muted small">Linie</div>
<strong>{{ $details['line_label'] }}</strong>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Zeitraum</div>
<strong>{{ $details['month'] }}/{{ $details['year'] }}</strong>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Kennzahl</div>
<strong>{{ $details['metric_label'] }}</strong>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Treffer</div>
<strong>{{ number_format($details['summary']['count'], 0, ',', '.') }}</strong>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="alert alert-warning rounded-0 border-left-0 border-right-0 border-top-0 mb-0">
<strong>Datenschutz-Hinweis:</strong>
Die Anzeige personenbezogener Detaildaten befindet sich noch in rechtlicher Klärung und ist aktuell nur für berechtigte VIP-Auswertungen vorgesehen.
</div>
@if ($details['rows'] === [])
<div class="p-4">
<p class="text-muted mb-0">{{ __('tables.no_data_available') }}</p>
</div>
@else
<div class="p-3 border-bottom">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<span class="ion ion-ios-search"></span>
</span>
</div>
<input type="search" id="backoffice-statistics-detail-search" class="form-control"
placeholder="Detailtabelle durchsuchen...">
</div>
<small class="text-muted d-block mt-2">
Suche nach Name, E-Mail, Status, Punkten oder Datum. Die Summenzeile bleibt unverändert.
</small>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0" id="backoffice-statistics-detail-table">
<thead>
<tr>
<th data-sortable="true"><span class="sortable-label">Name <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">E-Mail <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">Karriere-Level <i class="ion ion-ios-swap sort-icon"></i></span></th>
@if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true))
<th data-sortable="true"><span class="sortable-label">Berater <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">Abo-Punkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true"><span class="sortable-label">Status <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Besteht seit <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th data-sortable="true" data-sort-type="date"><span class="sortable-label">Nächste Ausführung <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">Lieferungen <i class="ion ion-ios-swap sort-icon"></i></span></th>
@elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true))
<th class="text-right" data-sortable="true" data-sort-type="number"><span class="sortable-label justify-content-end">Eigenpunkte <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">Externe Punkte <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">Kundenabo-Punkte <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">Einzelbestellungs-Punkte <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">Sonstige Kundenpunkte <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">Gesamtpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
@else
<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>
@endif
</tr>
</thead>
<tbody>
@foreach ($details['rows'] as $row)
<tr
class="{{ isset($row['is_account_active']) && ! $row['is_account_active'] ? 'table-danger' : '' }} {{ ! empty($row['is_new_this_month']) ? 'table-success' : '' }}">
<td class="font-weight-semibold">{{ $row['name'] }}</td>
<td>{{ $row['email'] ?? '-' }}</td>
<td>{{ $row['career_level'] ?? '-' }}</td>
@if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true))
<td>{{ $row['consultant_name'] ?? $row['name'] }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['points']) }}</td>
<td>
{!! $row['status_badge'] ?? '' !!}
@if (! empty($row['status_reason']))
<div class="small text-muted mt-1">{{ $row['status_reason'] }}</div>
@endif
</td>
<td>
{{ $row['start_date'] ?? '-' }}
@if (! empty($row['is_new_this_month']))
<span class="badge badge-success ml-1">Neu</span>
@endif
</td>
<td>{{ $row['next_date'] ?? '-' }}</td>
<td class="text-right">{{ number_format($row['deliveries'], 0, ',', '.') }}</td>
@elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true))
<td class="text-right">{{ \App\Services\Util::formatNumber($row['own_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['external_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['customer_abo_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['customer_single_order_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['customer_other_points']) }}</td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($row['total_points']) }}</td>
@else
<td>{{ $row['active_date'] ?? '-' }}</td>
<td>
{{ $row['payment_account'] ?? '-' }}
@if (isset($row['is_account_active']))
<span
class="badge badge-{{ $row['is_account_active'] ? 'success' : 'danger' }} ml-1">
{{ $row['account_status'] }}
</span>
@endif
</td>
@endif
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-weight-bold bg-light">
<td>Summe</td>
<td class="text-muted">{{ number_format($details['summary']['count'], 0, ',', '.') }} Einträge</td>
<td></td>
@if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true))
<td></td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['points']) }}</td>
<td></td>
<td></td>
<td></td>
<td class="text-right">{{ number_format($details['summary']['deliveries'], 0, ',', '.') }}</td>
@elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true))
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['own_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['external_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_abo_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_single_order_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_other_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['total_points']) }}</td>
@else
<td></td>
<td></td>
@endif
</tr>
</tfoot>
</table>
</div>
@endif
</div>
</div>
@if ($details['rows'] !== [])
<style>
#backoffice-statistics-detail-table {
min-width: 980px;
}
#backoffice-statistics-detail-table th,
#backoffice-statistics-detail-table td {
vertical-align: middle;
}
#backoffice-statistics-detail-table th[data-sortable="true"] {
white-space: nowrap;
}
#backoffice-statistics-detail-table .sortable-label {
align-items: center;
display: inline-flex;
gap: .25rem;
white-space: nowrap;
width: 100%;
}
#backoffice-statistics-detail-table .sort-icon {
color: #adb5bd;
flex: 0 0 auto;
font-size: .875rem;
line-height: 1;
}
#backoffice-statistics-detail-table th[data-sort-direction="asc"] .sort-icon,
#backoffice-statistics-detail-table th[data-sort-direction="desc"] .sort-icon {
color: #4e73df;
}
</style>
<script>
$(document).ready(function() {
$('#backoffice-statistics-detail-search').on('keyup search', function() {
var search = $(this).val().toLowerCase();
$('#backoffice-statistics-detail-table tbody tr').each(function() {
var rowText = $(this).text().toLowerCase();
$(this).toggle(rowText.indexOf(search) !== -1);
});
});
$('#backoffice-statistics-detail-table th[data-sortable="true"]').css('cursor', 'pointer').on('click',
function() {
var $header = $(this);
var columnIndex = $header.index();
var sortType = $header.data('sort-type') || 'text';
var direction = $header.data('sort-direction') === 'asc' ? 'desc' : 'asc';
var rows = $('#backoffice-statistics-detail-table tbody tr').get();
rows.sort(function(a, b) {
var aValue = getSortValue($(a).children('td').eq(columnIndex).text(), sortType);
var bValue = getSortValue($(b).children('td').eq(columnIndex).text(), sortType);
if (aValue < bValue) {
return direction === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return direction === 'asc' ? 1 : -1;
}
return 0;
});
$('#backoffice-statistics-detail-table th[data-sortable="true"]')
.removeData('sort-direction')
.removeAttr('data-sort-direction');
$header.data('sort-direction', direction).attr('data-sort-direction', direction);
$.each(rows, function(index, row) {
$('#backoffice-statistics-detail-table tbody').append(row);
});
});
function getSortValue(value, sortType) {
value = $.trim(value);
if (sortType === 'number') {
return parseFloat(value.replace(/\./g, '').replace(',', '.').replace(/[^\d.-]/g, '')) || 0;
}
if (sortType === 'date') {
var parts = value.match(/(\d{2})\.(\d{2})\.(\d{4})/);
if (!parts) {
return 0;
}
return new Date(parts[3], parts[2] - 1, parts[1]).getTime();
}
return value.toLowerCase();
}
});
</script>
@endif
@endsection

View file

@ -0,0 +1,237 @@
@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>
{{ __('navigation.statistics') }}
<span class="badge badge-secondary ml-2">VIP</span>
</div>
</h4>
<div class="card mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h5 class="mb-2">{{ __('navigation.statistics') }} / Backoffice MVP</h5>
<p class="text-muted mb-md-0">
Linienbasierte Übersicht für alle vorhandenen Team-Linien. Klickbare Zahlen führen direkt in die
passenden Namen- und Detaillisten.
</p>
@if (! empty($performance))
<div class="small text-muted mt-2">
Datenquelle:
<span class="badge badge-outline-default">{{ $performance['source_label'] }}</span>
<span class="ml-2">Berechnet in {{ number_format($performance['duration_ms'], 2, ',', '.') }} ms</span>
@if (! empty($performance['calculated_at']))
<span class="ml-2">Snapshot vom {{ $performance['calculated_at'] }}</span>
@endif
</div>
@endif
</div>
<div class="col-md-4 mt-3 mt-md-0">
<form method="GET" action="{{ route('user_backoffice_statistics') }}"
class="form-inline justify-content-md-end">
<a href="{{ route('user_backoffice_statistics_overview_export', ['month' => $selectedMonth, 'year' => $selectedYear]) }}"
class="btn btn-sm btn-outline-secondary mr-2">
<span class="ion ion-md-download mr-1"></span> CSV
</a>
<select name="month" class="form-control custom-select form-control-sm mr-2">
@foreach ($filterMonths as $monthNumber => $monthName)
<option value="{{ $monthNumber }}"
{{ (int) $selectedMonth === (int) $monthNumber ? 'selected' : '' }}>
{{ $monthName }}
</option>
@endforeach
</select>
<select name="year" class="form-control custom-select form-control-sm mr-2">
@foreach ($filterYears as $year)
<option value="{{ $year }}"
{{ (int) $selectedYear === (int) $year ? 'selected' : '' }}>
{{ $year }}
</option>
@endforeach
</select>
<button type="submit" class="btn btn-sm btn-primary">Anzeigen</button>
</form>
</div>
</div>
</div>
</div>
@php
$clickableMetrics = [
'consultants',
'new_partners',
'team_partner_abos',
'team_customer_abos',
'own_points',
'external_points',
'customer_abo_points',
'customer_single_order_points',
'customer_other_points',
'total_points',
'shop_1000',
];
$formatValue = function ($value, string $metric): string {
if (
in_array(
$metric,
[
'own_points',
'external_points',
'customer_abo_points',
'customer_single_order_points',
'customer_other_points',
'total_points',
],
true,
)
) {
return \App\Services\Util::formatNumber($value);
}
return number_format((float) $value, 0, ',', '.');
};
$newAboMetric = function (array $row, string $metric): ?string {
return match ($metric) {
'team_partner_abos' => 'team_partner_abos_new',
'team_customer_abos' => 'team_customer_abos_new',
default => null,
};
};
@endphp
<div class="card mb-4">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Linie</th>
<th class="text-right">Berater</th>
<th class="text-right">Neupartner</th>
<th class="text-right">Teamabos</th>
<th class="text-right">Teamkundenabos</th>
<th class="text-right">Eigenpunkte</th>
<th class="text-right">Externe Punkte</th>
<th class="text-right">Kundenabo-Punkte</th>
<th class="text-right">Einzelbestellungs-Punkte</th>
<th class="text-right">Sonstige Kundenpunkte</th>
<th class="text-right">Gesamtpunkte</th>
<th class="text-right">1000 Punkte Shop</th>
</tr>
</thead>
<tbody>
@foreach ($statistics['lines'] as $line)
<tr>
<td class="font-weight-semibold">{{ $line['label'] }}</td>
@foreach ($clickableMetrics as $metric)
<td class="text-right">
@if ($line[$metric] > 0)
<a href="{{ route('user_backoffice_statistics_details', [
'line' => $line['line'],
'metric' => $metric,
'month' => $selectedMonth,
'year' => $selectedYear,
]) }}"
class="badge badge-pill badge-outline-default backoffice-statistics-click-badge">
{{ $formatValue($line[$metric], $metric) }}
</a>
@php $newMetric = $newAboMetric($line, $metric); @endphp
@if ($newMetric && $line[$newMetric] > 0)
<span
class="badge badge-pill badge-success ml-1">+{{ number_format($line[$newMetric], 0, ',', '.') }}</span>
@endif
@else
<span
class="badge badge-pill badge-light backoffice-statistics-empty-badge">0</span>
@endif
</td>
@endforeach
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-weight-bold bg-light">
<td>{{ $statistics['totals']['label'] }}</td>
@foreach ($clickableMetrics as $metric)
<td class="text-right">
@if ($statistics['totals'][$metric] > 0)
<a href="{{ route('user_backoffice_statistics_details', [
'line' => 0,
'metric' => $metric,
'month' => $selectedMonth,
'year' => $selectedYear,
]) }}"
class="badge badge-pill badge-secondary backoffice-statistics-click-badge">
{{ $formatValue($statistics['totals'][$metric], $metric) }}
</a>
@php $newMetric = $newAboMetric($statistics['totals'], $metric); @endphp
@if ($newMetric && $statistics['totals'][$newMetric] > 0)
<span
class="badge badge-pill badge-success ml-1">+{{ number_format($statistics['totals'][$newMetric], 0, ',', '.') }}</span>
@endif
@else
<span
class="badge badge-pill badge-light backoffice-statistics-empty-badge">0</span>
@endif
</td>
@endforeach
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="mb-2">Begriffe</h5>
<p class="text-muted mb-2">
Teamabos sind Berater-/Eigenabos im Team. Teamkundenabos sind Kundenabos, die einem Berater
aus der jeweiligen Linie zugeordnet sind.
</p>
<p class="text-muted mb-0">
Externe Punkte kommen aktuell aus `month_shop_points`, Eigenpunkte aus `month_KP_points`.
</p>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-body">
<h5 class="mb-2">Nächster Ausbau</h5>
<p class="text-muted mb-0">
Die Detailansichten liefern bereits Namen und Basisdaten. Im nächsten Schritt werden Abo-Punkte
und Umsatzarten noch feiner nach Abo, Einzelbestellung und Shop getrennt.
</p>
</div>
</div>
</div>
</div>
<style>
.backoffice-statistics-click-badge,
.backoffice-statistics-empty-badge {
display: inline-block;
font-size: .85rem;
line-height: 1.35;
min-width: 2.35rem;
padding: .35rem .55rem;
text-align: center;
}
.backoffice-statistics-click-badge {
text-decoration: none;
}
.backoffice-statistics-click-badge:hover,
.backoffice-statistics-click-badge:focus {
text-decoration: none;
}
</style>
@endsection

View file

@ -550,6 +550,31 @@
<span class="info-small">{{ __('customer.language_hint') }}</span> <span class="info-small">{{ __('customer.language_hint') }}</span>
</div> </div>
</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]) !!}
@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
</div>
</div>
</div>
</div> </div>
@else @else
<div class="is_from_user"> <div class="is_from_user">

View file

@ -125,6 +125,12 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::post('/user/shop/api/orders/checkout', 'User\ShopApiController@checkout')->name('user_shop_api_orders_checkout'); Route::post('/user/shop/api/orders/checkout', 'User\ShopApiController@checkout')->name('user_shop_api_orders_checkout');
Route::get('/user/shop/api/orders/datatable', 'User\ShopApiController@ordersDatatable')->name('user_shop_api_orders_datatable'); Route::get('/user/shop/api/orders/datatable', 'User\ShopApiController@ordersDatatable')->name('user_shop_api_orders_datatable');
// user backoffice statistics
Route::get('/user/backoffice/statistics', 'User\BackofficeStatisticsController@index')->name('user_backoffice_statistics');
Route::get('/user/backoffice/statistics/details', 'User\BackofficeStatisticsController@details')->name('user_backoffice_statistics_details');
Route::get('/user/backoffice/statistics/export', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export');
Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_export');
// user team // user team
Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member'); Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member');

View file

@ -0,0 +1,342 @@
<?php
use App\Http\Controllers\User\BackofficeStatisticsController;
use App\Models\ShoppingOrder;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\User;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
uses(TestCase::class);
function makeBackofficeStatisticsUser(int $admin): User
{
return (new User)->forceFill([
'admin' => $admin,
'lang' => 'de',
'active' => 1,
'blocked' => 0,
'wizard' => 100,
]);
}
function makeBackofficeStatisticsRequest(User $user): Request
{
$request = Request::create('/user/backoffice/statistics', 'GET');
$request->setUserResolver(fn () => $user);
return $request;
}
function makeBackofficeStatisticsController(?BackofficeDashboardService $dashboardService = null, ?BackofficeDrilldownService $drilldownService = null): BackofficeStatisticsController
{
$dashboardService ??= Mockery::mock(BackofficeDashboardService::class);
$drilldownService ??= Mockery::mock(BackofficeDrilldownService::class);
return new BackofficeStatisticsController($dashboardService, $drilldownService);
}
function backofficeStatisticsStreamedContent(StreamedResponse $response): string
{
ob_start();
$response->sendContent();
return (string) ob_get_clean();
}
it('zeigt die Backoffice-Statistik fuer VIP-User', function () {
$vip = makeBackofficeStatisticsUser(1);
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
$dashboardService
->shouldReceive('overview')
->once()
->andReturn([
'month' => now()->month,
'year' => now()->year,
'metric_labels' => [],
'lines' => [],
'totals' => [],
'_meta' => [
'source_label' => 'Live',
'calculated_at' => null,
],
]);
$controller = makeBackofficeStatisticsController($dashboardService);
$response = $controller->index(makeBackofficeStatisticsRequest($vip));
expect($response->getName())->toBe('user.backoffice.statistics.index');
expect($response->getData())->toHaveKeys(['selectedMonth', 'selectedYear', 'statistics', 'performance']);
expect($response->getData()['performance']['source_label'])->toBe('Live');
});
it('behaelt den zuletzt gewaehlten Statistik-Zeitraum in der Session', function () {
$vip = makeBackofficeStatisticsUser(1);
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
$dashboardService
->shouldReceive('overview')
->twice()
->andReturn([
'month' => 4,
'year' => 2026,
'metric_labels' => [],
'lines' => [],
'totals' => [],
'_meta' => [
'source_label' => 'Snapshot',
'calculated_at' => '17.05.2026 04:45',
],
]);
$controller = makeBackofficeStatisticsController($dashboardService);
$firstRequest = makeBackofficeStatisticsRequest($vip);
$firstRequest->query->set('month', 4);
$firstRequest->query->set('year', 2026);
$controller->index($firstRequest);
$response = $controller->index(makeBackofficeStatisticsRequest($vip));
expect($response->getData()['selectedMonth'])->toBe(4);
expect($response->getData()['selectedYear'])->toBe(2026);
expect($response->getData()['performance']['source_label'])->toBe('Snapshot');
});
it('blockiert die Backoffice-Statistik fuer normale aktive User', function () {
$user = makeBackofficeStatisticsUser(0);
$controller = makeBackofficeStatisticsController();
expect(fn () => $controller->index(makeBackofficeStatisticsRequest($user)))
->toThrow(NotFoundHttpException::class);
});
it('erstellt einen CSV-Export fuer die Statistik-Uebersicht', function () {
$vip = makeBackofficeStatisticsUser(1);
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
$dashboardService
->shouldReceive('overview')
->once()
->andReturn([
'month' => 5,
'year' => 2026,
'metric_labels' => [],
'lines' => [
[
'label' => 'Linie 1',
'consultants' => 2,
'new_partners' => 1,
'team_partner_abos' => 1,
'team_partner_abos_new' => 1,
'team_customer_abos' => 1,
'team_customer_abos_new' => 1,
'own_points' => 400,
'external_points' => 700,
'customer_abo_points' => 700,
'customer_single_order_points' => 0,
'customer_other_points' => 0,
'total_points' => 1100,
'shop_1000' => 1,
'turnover_net' => 300,
],
],
'totals' => [
'label' => 'Summe',
'consultants' => 2,
'new_partners' => 1,
'team_partner_abos' => 1,
'team_partner_abos_new' => 1,
'team_customer_abos' => 1,
'team_customer_abos_new' => 1,
'own_points' => 400,
'external_points' => 700,
'customer_abo_points' => 700,
'customer_single_order_points' => 0,
'customer_other_points' => 0,
'total_points' => 1100,
'shop_1000' => 1,
'turnover_net' => 300,
],
]);
$controller = makeBackofficeStatisticsController($dashboardService);
$request = makeBackofficeStatisticsRequest($vip);
$request->query->set('month', 5);
$request->query->set('year', 2026);
$response = $controller->overviewExport($request);
$content = backofficeStatisticsStreamedContent($response);
expect($response)->toBeInstanceOf(StreamedResponse::class);
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-05-2026.csv');
expect($content)->toContain('Linie;Berater;Neupartner;Teamabos');
expect($content)->toContain('Neue Teamabos');
expect($content)->toContain('"Linie 1";2;1;1;1;1;1;400;700;700;0;0;1100;1;300');
expect($content)->toContain('Summe;2;1;1;1;1;1;400;700;700;0;0;1100;1;300');
});
it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeile', function () {
$vip = makeBackofficeStatisticsUser(1);
$drilldownService = Mockery::mock(BackofficeDrilldownService::class);
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 0, 'shop_1000', 5, 2026)
->andReturn([
'metric' => 'shop_1000',
'metric_label' => '1000 Punkte Shop',
'line' => 0,
'line_label' => 'Alle Linien',
'month' => 5,
'year' => 2026,
'rows' => [
[
'name' => 'Max Mustermann',
'email' => 'max@example.test',
'career_level' => 'Partner',
'own_points' => 400,
'external_points' => 700,
'customer_abo_points' => 700,
'customer_single_order_points' => 0,
'customer_other_points' => 0,
'total_points' => 1100,
],
],
'summary' => [
'count' => 1,
'points' => 0,
'own_points' => 400,
'external_points' => 700,
'customer_abo_points' => 700,
'customer_single_order_points' => 0,
'customer_other_points' => 0,
'total_points' => 1100,
'deliveries' => 0,
],
]);
$controller = makeBackofficeStatisticsController(null, $drilldownService);
$request = makeBackofficeStatisticsRequest($vip);
$request->query->set('line', 0);
$request->query->set('metric', 'shop_1000');
$request->query->set('month', 5);
$request->query->set('year', 2026);
$response = $controller->export($request);
$content = backofficeStatisticsStreamedContent($response);
expect($response)->toBeInstanceOf(StreamedResponse::class);
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-shop_1000-linie-alle-05-2026.csv');
expect($content)->toContain('Name;E-Mail;Karriere-Level;Eigenpunkte;"Externe Punkte"');
expect($content)->toContain('"Max Mustermann";max@example.test;Partner;400;700;700;0;0;1100');
expect($content)->toContain('Summe;"1 Eintraege";;400;700;700;0;0;1100');
});
it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function () {
$vip = makeBackofficeStatisticsUser(1);
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
$drilldownService = Mockery::mock(BackofficeDrilldownService::class);
$dashboardService
->shouldReceive('overview')
->once()
->andReturn([
'month' => 4,
'year' => 2026,
'metric_labels' => [],
'lines' => [],
'totals' => [],
]);
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 1, 'consultants', 4, 2026)
->andReturn([
'metric' => 'consultants',
'metric_label' => 'Berater',
'line' => 1,
'line_label' => 'Linie 1',
'month' => 4,
'year' => 2026,
'rows' => [],
'summary' => ['count' => 0],
]);
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 1, 'consultants', 4, 2026)
->andReturn([
'metric' => 'consultants',
'metric_label' => 'Berater',
'line' => 1,
'line_label' => 'Linie 1',
'month' => 4,
'year' => 2026,
'rows' => [],
'summary' => ['count' => 0],
]);
$controller = makeBackofficeStatisticsController($dashboardService, $drilldownService);
$indexRequest = makeBackofficeStatisticsRequest($vip);
$indexRequest->query->set('month', 4);
$indexRequest->query->set('year', 2026);
$controller->index($indexRequest);
$detailsRequest = makeBackofficeStatisticsRequest($vip);
$detailsRequest->query->set('line', 1);
$detailsRequest->query->set('metric', 'consultants');
$detailsResponse = $controller->details($detailsRequest);
$exportRequest = makeBackofficeStatisticsRequest($vip);
$exportRequest->query->set('line', 1);
$exportRequest->query->set('metric', 'consultants');
$exportResponse = $controller->export($exportRequest);
expect($detailsResponse->getData()['selectedMonth'])->toBe(4);
expect($detailsResponse->getData()['selectedYear'])->toBe(2026);
expect($exportResponse->headers->get('content-disposition'))->toContain('backoffice-statistik-consultants-linie-1-04-2026.csv');
});
it('rendert eine Suche in der Detailtabelle', function () {
$html = file_get_contents(resource_path('views/user/backoffice/statistics/details.blade.php'));
expect($html)->toContain('backoffice-statistics-detail-search');
expect($html)->toContain('backoffice-statistics-detail-table');
expect($html)->toContain('data-sortable="true"');
expect($html)->toContain('getSortValue');
expect($html)->toContain('user_backoffice_statistics_export');
expect($html)->toContain('Datenschutz-Hinweis');
expect($html)->toContain('Karriere-Level');
expect($html)->not->toContain('Qualifikation');
$indexHtml = file_get_contents(resource_path('views/user/backoffice/statistics/index.blade.php'));
expect($indexHtml)->toContain('user_backoffice_statistics_overview_export');
expect($indexHtml)->toContain('Datenquelle:');
expect($indexHtml)->toContain('Berechnet in');
});
it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () {
$checkoutView = file_get_contents(resource_path('views/web/templates/checkout.blade.php'));
$checkoutController = file_get_contents(app_path('Http/Controllers/Web/CheckoutController.php'));
$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');
expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?');
expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'");
expect($checkoutController)->toContain('customer_order_source');
expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\'');
expect($checkoutRepository)->toContain('customer_order_source_comment');
expect($orderDetail)->toContain('getCustomerOrderSourceLabel');
$shoppingOrder = (new ShoppingOrder)->forceFill([
'customer_order_source' => 'social_media',
]);
expect($shoppingOrder->getCustomerOrderSourceLabel())->toBe('Social Media');
});

View file

@ -0,0 +1,409 @@
<?php
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Schema::dropIfExists('user_sales_volumes');
Schema::dropIfExists('backoffice_statistics_snapshots');
Schema::dropIfExists('user_abo_orders');
Schema::dropIfExists('user_abo_items');
Schema::dropIfExists('user_abos');
Schema::dropIfExists('payment_transactions');
Schema::dropIfExists('shopping_payments');
Schema::dropIfExists('shopping_orders');
Schema::dropIfExists('users');
Schema::dropIfExists('user_levels');
Schema::dropIfExists('user_accounts');
Schema::create('user_accounts', function ($table) {
$table->increments('id');
$table->string('first_name')->nullable();
$table->string('last_name')->nullable();
});
Schema::create('user_levels', function ($table) {
$table->increments('id');
$table->string('name');
$table->unsignedInteger('pos')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
});
Schema::create('users', function ($table) {
$table->increments('id');
$table->string('email')->unique();
$table->string('password');
$table->unsignedInteger('account_id')->nullable();
$table->unsignedInteger('m_level')->nullable();
$table->unsignedInteger('m_sponsor')->nullable();
$table->boolean('active')->default(false);
$table->timestamp('active_date')->nullable();
$table->unsignedTinyInteger('admin')->default(0);
$table->unsignedTinyInteger('wizard')->default(0);
$table->unsignedTinyInteger('blocked')->default(0);
$table->char('lang', 2)->default('de');
$table->timestamp('payment_account')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('user_abos', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_id')->nullable();
$table->unsignedInteger('member_id')->nullable();
$table->char('is_for', 2)->nullable();
$table->boolean('active')->default(true);
$table->unsignedTinyInteger('status')->default(2);
$table->date('start_date')->nullable();
$table->date('next_date')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('user_abo_items', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_abo_id');
$table->unsignedInteger('product_id')->nullable();
$table->unsignedTinyInteger('comp')->nullable();
$table->unsignedInteger('qty')->default(1);
$table->timestamps();
});
Schema::create('user_abo_orders', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_abo_id');
$table->unsignedInteger('shopping_order_id');
$table->unsignedTinyInteger('status')->default(2);
$table->boolean('paid')->default(true);
$table->timestamps();
});
Schema::create('shopping_orders', function ($table) {
$table->increments('id');
$table->boolean('is_abo')->default(false);
$table->timestamps();
$table->softDeletes();
});
Schema::create('shopping_payments', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_order_id');
$table->string('clearingtype')->nullable();
$table->string('reference')->nullable();
$table->integer('amount')->nullable();
$table->string('currency')->nullable();
$table->timestamps();
});
Schema::create('payment_transactions', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_payment_id');
$table->string('request')->nullable();
$table->unsignedInteger('errorcode')->nullable();
$table->string('errormessage')->nullable();
$table->string('customermessage')->nullable();
$table->timestamps();
});
Schema::create('user_sales_volumes', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_id');
$table->unsignedInteger('shopping_order_id')->nullable();
$table->unsignedTinyInteger('month')->nullable();
$table->unsignedSmallInteger('year')->nullable();
$table->decimal('month_KP_points', 13, 2)->nullable();
$table->decimal('month_shop_points', 13, 2)->nullable();
$table->decimal('month_total_net', 13, 2)->nullable();
$table->decimal('month_shop_total_net', 13, 2)->nullable();
$table->timestamps();
});
Schema::create('backoffice_statistics_snapshots', function ($table) {
$table->id();
$table->unsignedInteger('user_id');
$table->unsignedSmallInteger('year');
$table->unsignedTinyInteger('month');
$table->json('payload');
$table->timestamp('calculated_at')->nullable();
$table->timestamps();
});
});
it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function () {
UserLevel::forceCreate([
'id' => 1,
'name' => 'Partner',
'pos' => 1,
'active' => true,
]);
$root = User::forceCreate([
'email' => 'root@test.test',
'password' => 'secret',
'lang' => 'de',
'admin' => 1,
]);
$lineOne = User::forceCreate([
'email' => 'line-one@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $root->id,
'm_level' => 1,
'active_date' => '2026-05-03 00:00:00',
'payment_account' => '2030-01-01 00:00:00',
]);
$lineTwo = User::forceCreate([
'email' => 'line-two@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $lineOne->id,
'm_level' => 1,
'active_date' => '2026-04-03 00:00:00',
'payment_account' => '2030-01-01 00:00:00',
]);
User::forceCreate([
'email' => 'expired-line-one@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $root->id,
'm_level' => 1,
'active_date' => '2026-01-03 00:00:00',
'payment_account' => '2020-01-01 00:00:00',
]);
$sponsor = $lineTwo;
foreach (range(3, 9) as $lineNumber) {
$sponsor = User::forceCreate([
'email' => 'line-'.$lineNumber.'@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $sponsor->id,
'm_level' => 1,
'active_date' => '2026-04-03 00:00:00',
'payment_account' => '2030-01-01 00:00:00',
]);
}
$activePartnerAbo = UserAbo::forceCreate([
'user_id' => $lineOne->id,
'member_id' => $lineOne->id,
'is_for' => 'me',
'active' => true,
'status' => 2,
'start_date' => '2026-05-04',
'next_date' => '2026-06-01',
]);
UserAbo::forceCreate([
'user_id' => $lineTwo->id,
'member_id' => $lineOne->id,
'is_for' => 'ot',
'active' => true,
'status' => 2,
'start_date' => '2026-05-05',
'next_date' => '2026-06-01',
]);
$pausedPartnerAbo = UserAbo::forceCreate([
'user_id' => $lineOne->id,
'member_id' => $lineOne->id,
'is_for' => 'me',
'active' => true,
'status' => 3,
'start_date' => '2026-04-15',
'next_date' => '2026-06-02',
]);
$aboOrderId = DB::table('shopping_orders')->insertGetId([
'is_abo' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$singleOrderId = DB::table('shopping_orders')->insertGetId([
'is_abo' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$failedAboOrderId = DB::table('shopping_orders')->insertGetId([
'is_abo' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$failedAboPaymentId = DB::table('shopping_payments')->insertGetId([
'shopping_order_id' => $failedAboOrderId,
'clearingtype' => 'cc',
'reference' => 'abo-failed',
'amount' => 9900,
'currency' => 'EUR',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('payment_transactions')->insert([
'shopping_payment_id' => $failedAboPaymentId,
'request' => 'authorization',
'errorcode' => 902,
'errormessage' => 'Bank hat abgelehnt',
'customermessage' => 'Bitte Zahlungsmittel prüfen',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('user_abo_orders')->insert([
'user_abo_id' => $pausedPartnerAbo->id,
'shopping_order_id' => $failedAboOrderId,
'status' => 3,
'paid' => false,
'created_at' => now(),
'updated_at' => now(),
]);
UserSalesVolume::forceCreate([
'user_id' => $lineOne->id,
'shopping_order_id' => $aboOrderId,
'month' => 5,
'year' => 2026,
'month_KP_points' => 400,
'month_shop_points' => 700,
'month_total_net' => 100,
'month_shop_total_net' => 200,
]);
UserSalesVolume::forceCreate([
'user_id' => $lineTwo->id,
'shopping_order_id' => $singleOrderId,
'month' => 5,
'year' => 2026,
'month_KP_points' => 200,
'month_shop_points' => 300,
'month_total_net' => 50,
'month_shop_total_net' => 60,
]);
$overview = (new BackofficeDashboardService)->overview($root, 5, 2026);
expect($overview['lines'][1]['consultants'])->toBe(2);
expect($overview['lines'][1]['new_partners'])->toBe(1);
expect($overview['lines'][1]['team_partner_abos'])->toBe(2);
expect($overview['lines'][1]['team_partner_abos_new'])->toBe(1);
expect($overview['lines'][1]['team_customer_abos'])->toBe(1);
expect($overview['lines'][1]['team_customer_abos_new'])->toBe(1);
expect($overview['lines'][1]['own_points'])->toBe(400.0);
expect($overview['lines'][1]['external_points'])->toBe(700.0);
expect($overview['lines'][1]['customer_abo_points'])->toBe(700.0);
expect($overview['lines'][1]['customer_single_order_points'])->toBe(0.0);
expect($overview['lines'][1]['customer_other_points'])->toBe(0.0);
expect($overview['lines'][1]['total_points'])->toBe(1100.0);
expect($overview['lines'][1]['shop_1000'])->toBe(1);
expect($overview['lines'][2]['consultants'])->toBe(1);
expect($overview['lines'])->toHaveKey(9);
expect($overview['lines'])->not->toHaveKey(10);
expect($overview['lines'][9]['consultants'])->toBe(1);
expect($overview['totals']['total_points'])->toBe(1600.0);
$details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'total_points', 5, 2026);
expect($details['line_label'])->toBe('Alle Linien');
expect($details['rows'])->toHaveCount(2);
expect($details['summary']['own_points'])->toBe(600.0);
expect($details['summary']['external_points'])->toBe(1000.0);
expect($details['summary']['customer_abo_points'])->toBe(700.0);
expect($details['summary']['customer_single_order_points'])->toBe(300.0);
expect($details['summary']['customer_other_points'])->toBe(0.0);
expect($details['summary']['total_points'])->toBe(1600.0);
$shop1000Details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'shop_1000', 5, 2026);
expect($shop1000Details['rows'])->toHaveCount(1);
expect($shop1000Details['rows'][0]['career_level'])->toBe('Partner');
expect($shop1000Details['rows'][0]['own_points'])->toBe(400.0);
expect($shop1000Details['rows'][0]['customer_abo_points'])->toBe(700.0);
$consultantDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'consultants', 5, 2026);
expect($consultantDetails['rows'])->toHaveCount(2);
expect($consultantDetails['summary']['count'])->toBe(2);
expect(collect($consultantDetails['rows'])->pluck('is_account_active')->all())->toBe([true, false]);
expect(collect($consultantDetails['rows'])->pluck('career_level')->all())->toBe(['Partner', 'Partner']);
$aboDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'team_partner_abos', 5, 2026);
expect($aboDetails['rows'][0]['start_date'])->toBe('04.05.2026');
expect($aboDetails['rows'][0]['is_new_this_month'])->toBeTrue();
expect($aboDetails['rows'])->toHaveCount(2);
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $activePartnerAbo->id)['status_reason'])->toBeNull();
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['status_reason'])->toBe('[902] Bank hat abgelehnt');
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['is_new_this_month'])->toBeFalse();
});
it('verwendet gespeicherte Snapshots fuer abgeschlossene Monate', function () {
$root = User::forceCreate([
'email' => 'snapshot-root@test.test',
'password' => 'secret',
'lang' => 'de',
'admin' => 1,
]);
$payload = [
'month' => 4,
'year' => 2026,
'metric_labels' => [],
'lines' => [
1 => [
'line' => 1,
'label' => 'Linie 1',
'user_ids' => [],
'consultants' => 99,
'new_partners' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'own_points' => 0,
'external_points' => 0,
'customer_abo_points' => 0,
'customer_single_order_points' => 0,
'customer_other_points' => 0,
'total_points' => 0,
'turnover_net' => 0,
'shop_1000' => 0,
],
],
'totals' => [
'label' => 'Summe',
'consultants' => 99,
],
];
BackofficeStatisticsSnapshot::create([
'user_id' => $root->id,
'year' => 2026,
'month' => 4,
'payload' => $payload,
'calculated_at' => now(),
]);
$overview = (new BackofficeDashboardService)->overview($root, 4, 2026);
expect($overview['lines'][1]['consultants'])->toBe(99);
expect($overview['_meta']['source'])->toBe('snapshot');
expect($overview['_meta']['source_label'])->toBe('Snapshot');
});