Backoffice-Statistik: Punkte-Fix (kumulative Snapshots) & Meine Statistik

Punkte/Umsatz korrekt aus per-Zeile points/total_net je status berechnen
statt SUM der kumulativen month_*-Snapshots (zaehlte massiv mehrfach).
Zentralisiert in SalesVolumePoints, genutzt von Dashboard- & Drilldown-Service.

Detail-Tabelle neu gegliedert: Eigenpunkte | Shop | Abo | Sonstige |
Kundenpunkte (=Summe) | Gesamt, mit gruener Hervorhebung der Basis-/Summenspalten.

Neue VIP-Seite "Meine Statistik" (Untermenue neben Team-Statistik):
KPI-Karten + nachvollziehbare Detailtabellen, gruppiert nach eigenen
Bestellungen, Shop-Bestellungen und Abos (PersonalStatisticsService).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-25 13:31:14 +00:00
parent db5dd7e1aa
commit 54bf2e5595
14 changed files with 666 additions and 70 deletions

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\Services\Backoffice\PersonalStatisticsService;
use App\Services\HTMLHelper;
use Illuminate\Http\Request;
use Illuminate\View\View;
@ -18,7 +19,8 @@ class BackofficeStatisticsController extends Controller
public function __construct(
private BackofficeDashboardService $dashboardService,
private BackofficeDrilldownService $drilldownService
private BackofficeDrilldownService $drilldownService,
private PersonalStatisticsService $personalService
) {
$this->middleware('active.account');
}
@ -48,6 +50,23 @@ class BackofficeStatisticsController extends Controller
]);
}
public function my(Request $request): View
{
if (! $request->user()?->isVIP()) {
abort(404);
}
[$selectedMonth, $selectedYear] = $this->selectedPeriod($request);
return view('user.backoffice.statistics.my', [
'selectedMonth' => $selectedMonth,
'selectedYear' => $selectedYear,
'filterMonths' => HTMLHelper::getTransMonths(),
'filterYears' => HTMLHelper::getYearRange(2022),
'statistics' => $this->personalService->monthly($request->user(), $selectedMonth, $selectedYear),
]);
}
public function details(Request $request): View
{
if (! $request->user()?->isVIP()) {
@ -210,7 +229,7 @@ class BackofficeStatisticsController extends Controller
}
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', 'Eigenpunkte', 'Shop-Punkte', 'Abo-Punkte', 'Sonstige Punkte', 'Kundenpunkte', 'Gesamtpunkte'];
}
return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status'];
@ -253,10 +272,10 @@ class BackofficeStatisticsController extends Controller
$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_abo_points'] ?? 0,
$row['customer_other_points'] ?? 0,
$row['external_points'] ?? 0,
$row['total_points'] ?? 0,
];
}
@ -289,7 +308,7 @@ class BackofficeStatisticsController extends Controller
}
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', '', $summary['own_points'], $summary['customer_single_order_points'], $summary['customer_abo_points'], $summary['customer_other_points'], $summary['external_points'], $summary['total_points']];
}
return ['Summe', $summary['count'].' Eintraege', '', '', '', ''];

View file

@ -215,40 +215,40 @@ class BackofficeDrilldownService
->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')
->selectRaw(SalesVolumePoints::ownPoints().' as own_points')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::customerAboPoints().' as customer_abo_points')
->selectRaw(SalesVolumePoints::customerSingleOrderPoints().' as customer_single_order_points')
->selectRaw(SalesVolumePoints::customerOtherPoints().' as customer_other_points')
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
->groupBy('user_sales_volumes.user_id');
if ($metric === 'own_points') {
$rows->havingRaw('COALESCE(SUM(month_KP_points), 0) > 0');
$rows->havingRaw(SalesVolumePoints::ownPoints().' > 0');
}
if ($metric === 'external_points') {
$rows->havingRaw('COALESCE(SUM(month_shop_points), 0) > 0');
$rows->havingRaw(SalesVolumePoints::externalPoints().' > 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');
$rows->havingRaw(SalesVolumePoints::customerAboPoints().' > 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');
$rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' > 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');
$rows->havingRaw(SalesVolumePoints::customerOtherPoints().' > 0');
}
if ($metric === 'total_points') {
$rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) > 0');
$rows->havingRaw(SalesVolumePoints::totalPoints().' > 0');
}
if ($metric === 'shop_1000') {
$rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) >= 1000');
$rows->havingRaw(SalesVolumePoints::totalPoints().' >= 1000');
}
$salesRows = $rows->orderByDesc('total_points')->get();

View file

@ -0,0 +1,121 @@
<?php
namespace App\Services\Backoffice;
use App\Models\UserSalesVolume;
use App\User;
/**
* Persönliche Monatsstatistik eines Beraters: nachvollziehbare Auflistung der
* Punkte/Umsätze des Monats, gruppiert nach eigenen Bestellungen, Shop-Bestellungen
* und Abos.
*
* Im Gegensatz zur Team-Statistik ({@see BackofficeDashboardService}) werden hier die
* einzelnen `user_sales_volumes`-Zeilen (per-Zeile `points`/`total_net`) des Users selbst
* gruppiert – nicht die kumulativen month_*-Snapshots.
*/
class PersonalStatisticsService
{
/**
* @return array{
* month: int,
* year: int,
* totals: array{own_points: float, shop_points: float, abo_points: float, other_points: float, total_points: float, turnover_net: float},
* groups: array<string, array{key: string, label: string, points: float, net: float, rows: array<int, array<string, mixed>>}>
* }
*/
public function monthly(User $user, int $month, int $year): array
{
$volumes = UserSalesVolume::query()
->with(['shopping_order.shopping_user'])
->where('user_id', $user->id)
->where('month', $month)
->where('year', $year)
->orderBy('id')
->get();
$groups = [
'own' => $this->emptyGroup('own', 'Eigene Bestellungen'),
'shop' => $this->emptyGroup('shop', 'Shop-Bestellungen'),
'abo' => $this->emptyGroup('abo', 'Abos'),
'other' => $this->emptyGroup('other', 'Sonstige'),
];
foreach ($volumes as $volume) {
$key = $this->groupKey($volume);
$groups[$key]['rows'][] = $this->mapRow($volume);
$groups[$key]['points'] += (float) $volume->points;
$groups[$key]['net'] += (float) $volume->total_net;
}
$totals = [
'own_points' => $groups['own']['points'],
'shop_points' => $groups['shop']['points'],
'abo_points' => $groups['abo']['points'],
'other_points' => $groups['other']['points'],
'total_points' => $groups['own']['points'] + $groups['shop']['points'] + $groups['abo']['points'] + $groups['other']['points'],
'turnover_net' => $groups['own']['net'] + $groups['shop']['net'] + $groups['abo']['net'] + $groups['other']['net'],
];
return [
'month' => $month,
'year' => $year,
'totals' => $totals,
'groups' => $groups,
];
}
private function groupKey(UserSalesVolume $volume): string
{
$status = (int) $volume->status;
$order = $volume->shopping_order;
if ($status === 1) {
return 'own';
}
if ($status === 2 && $order) {
return $order->is_abo ? 'abo' : 'shop';
}
return 'other';
}
/**
* @return array{key: string, label: string, points: float, net: float, rows: array<int, array<string, mixed>>}
*/
private function emptyGroup(string $key, string $label): array
{
return [
'key' => $key,
'label' => $label,
'points' => 0.0,
'net' => 0.0,
'rows' => [],
];
}
/**
* @return array<string, mixed>
*/
private function mapRow(UserSalesVolume $volume): array
{
$order = $volume->shopping_order;
$customer = $order?->shopping_user;
return [
'date' => $volume->date,
'points' => (float) $volume->points,
'total_net' => (float) $volume->total_net,
'status' => (int) $volume->status,
'status_label' => $volume->getStatusType(),
'status_color' => $volume->getStatusColor(),
'order_id' => $order?->id,
'order_type' => $order ? ((int) $volume->status === 1 ? 'own' : 'shop') : null,
'customer_name' => $customer ? trim(($customer->billing_firstname ?? '').' '.($customer->billing_lastname ?? '')) : null,
'customer_email' => $customer?->billing_email,
'info' => $volume->info,
'message' => $volume->message,
];
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services\Backoffice;
/**
* Liefert SQL-Aggregat-Ausdrücke für die monatliche Punkte-/Umsatzauswertung
* der Backoffice-Statistik.
*
* Wichtig: In `user_sales_volumes` existiert pro Bestellung eine Zeile, und die
* Spalten `month_KP_points` / `month_shop_points` / `month_*_total_net` sind der
* kumulative Monats-Laufstand zum Zeitpunkt der Bestellung – KEINE Einzelbeträge.
* Eine SUM() über diese Spalten zählt mehrfach. Korrekt wird je Zeile die
* `points`/`total_net`-Spalte summiert und nach `status` gefiltert – analog zu
* {@see \App\Services\BusinessPlan\SalesPointsVolume::reCalculateSalesPointsVolume()}.
*
* Status-Zuordnung:
* - Eigenpunkte (KP): status 1, 4, 5, 6
* - Shop-/Kundenpunkte: status 2 (sowie status 6 mit status_turnover = 2)
* - status 0 (nicht zugeordnet) und 3 (pending) zählen nicht
*
* Alle Spalten werden voll qualifiziert, da `shopping_orders` ebenfalls eine
* `points`-Spalte besitzt (Join-Mehrdeutigkeit).
*/
class SalesVolumePoints
{
private const OWN_PREDICATE = 'user_sales_volumes.status IN (1, 4, 5, 6)';
private const SHOP_PREDICATE = '(user_sales_volumes.status = 2 OR (user_sales_volumes.status = 6 AND user_sales_volumes.status_turnover = 2))';
private const TURNOVER_PREDICATE = 'user_sales_volumes.status IN (1, 2, 4, 5, 6)';
public static function ownPoints(): string
{
return self::sumWhere(self::OWN_PREDICATE, 'user_sales_volumes.points');
}
public static function externalPoints(): string
{
return self::sumWhere(self::SHOP_PREDICATE, 'user_sales_volumes.points');
}
public static function customerAboPoints(): string
{
return self::sumWhere(self::SHOP_PREDICATE.' AND shopping_orders.is_abo = 1', 'user_sales_volumes.points');
}
public static function customerSingleOrderPoints(): string
{
return self::sumWhere(self::SHOP_PREDICATE.' AND shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL)', 'user_sales_volumes.points');
}
public static function customerOtherPoints(): string
{
return self::sumWhere(self::SHOP_PREDICATE.' AND shopping_orders.id IS NULL', 'user_sales_volumes.points');
}
public static function totalPoints(): string
{
return self::ownPoints().' + '.self::externalPoints();
}
public static function turnoverNet(): string
{
return self::sumWhere(self::TURNOVER_PREDICATE, 'user_sales_volumes.total_net');
}
private static function sumWhere(string $predicate, string $column): string
{
return 'COALESCE(SUM(CASE WHEN '.$predicate.' THEN '.$column.' ELSE 0 END), 0)';
}
}

View file

@ -48,6 +48,8 @@ return [
'shipping_costs' => 'Versandkosten',
'start_site' => 'Startseite Shop',
'statistics' => 'Statistik',
'team_statistics' => 'Team-Statistik',
'my_statistics' => 'Meine Statistik',
'structure' => 'Struktur',
'system_settings' => 'System-E.',
'translate' => 'Übersetzungen',

View file

@ -57,6 +57,8 @@ return [
'shipping_costs' => 'shipping',
'start_site' => 'home page',
'statistics' => 'Statistics',
'team_statistics' => 'Team Statistics',
'my_statistics' => 'My Statistics',
'structure' => 'structure',
'system_settings' => 'system E.',
'tags' => 'Tags',

View file

@ -56,14 +56,28 @@
</a>
</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>
<li class="sidenav-item @if (Request::is('user/backoffice/statistics*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<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>
<ul class="sidenav-menu">
<li class="sidenav-item{{ Request::is('user/backoffice/statistics/my') ? ' active' : '' }}">
<a href="{{ route('user_my_statistics') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-person"></i>
<div>{{ __('navigation.my_statistics') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('user/backoffice/statistics') || Request::is('user/backoffice/statistics/details') ? ' active' : '' }}">
<a href="{{ route('user_backoffice_statistics') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-people"></i>
<div>{{ __('navigation.team_statistics') }}</div>
</a>
</li>
</ul>
</li>
@endif
<li class="sidenav-item @if (Request::is('user/team/*')) open @endif">

View file

@ -89,11 +89,11 @@
<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" style="background-color:#d4edda;"><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">Shop-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">Abo-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 Punkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number" style="background-color:#d4edda;"><span class="sortable-label justify-content-end">Kundenpunkte <i class="ion ion-ios-swap sort-icon"></i></span></th>
<th class="text-right" data-sortable="true" data-sort-type="number"><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>
@ -151,11 +151,11 @@
<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"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['own_points']) }}</span></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_abo_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($row['customer_other_points']) }}</td>
<td class="text-right font-weight-bold"><span class="badge" style="background-color:#d4edda;color:#155724;font-size:90%;">{{ \App\Services\Util::formatNumber($row['external_points']) }}</span></td>
<td class="text-right font-weight-bold">{{ \App\Services\Util::formatNumber($row['total_points']) }}</td>
@else
<td>{{ $row['active_date'] ?? '-' }}</td>
@ -188,11 +188,11 @@
<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" style="background-color:#d4edda;">{{ \App\Services\Util::formatNumber($details['summary']['own_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_abo_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['customer_other_points']) }}</td>
<td class="text-right" style="background-color:#d4edda;">{{ \App\Services\Util::formatNumber($details['summary']['external_points']) }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($details['summary']['total_points']) }}</td>
@else
<td></td>

View file

@ -3,7 +3,7 @@
@section('content')
<h4 class="font-weight-bold py-2 mb-2 d-flex justify-content-between align-items-center w-100">
<div>
{{ __('navigation.statistics') }}
{{ __('navigation.team_statistics') }}
<span class="badge badge-secondary ml-2">VIP</span>
</div>
</h4>
@ -17,12 +17,13 @@
Linienbasierte Übersicht für alle vorhandenen Team-Linien. Klickbare Zahlen führen direkt in die
passenden Namen- und Detaillisten.
</p>
@if (! empty($performance))
@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">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>
@ -59,12 +60,7 @@
</div>
@php
$clickableMetrics = [
'consultants',
'total_points',
'team_partner_abos',
'team_customer_abos',
];
$clickableMetrics = ['consultants', 'total_points', 'team_partner_abos', 'team_customer_abos'];
$metricHeadings = [
'consultants' => 'Berater',
@ -100,7 +96,8 @@
'metric' => 'new_partners',
'month' => $selectedMonth,
'year' => $selectedYear,
]) }}" class="card h-100 text-decoration-none backoffice-kpi-card">
]) }}"
class="card h-100 text-decoration-none backoffice-kpi-card">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">Neupartner ({{ $selectedMonthName }} {{ $selectedYear }})</div>
@ -121,10 +118,12 @@
'metric' => 'shop_1000',
'month' => $selectedMonth,
'year' => $selectedYear,
]) }}" class="card h-100 text-decoration-none backoffice-kpi-card">
]) }}"
class="card h-100 text-decoration-none backoffice-kpi-card">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">1000-Punkte-Shops ({{ $selectedMonthName }} {{ $selectedYear }})</div>
<div class="text-muted small">1000-Punkte-Shops ({{ $selectedMonthName }} {{ $selectedYear }})
</div>
<div class="display-4 font-weight-bold text-success">
{{ number_format((float) ($statistics['totals']['shop_1000'] ?? 0), 0, ',', '.') }}
</div>

View file

@ -0,0 +1,204 @@
@extends('layouts.layout-2')
@section('content')
@php
$totals = $statistics['totals'];
$selectedMonthName = $filterMonths[(int) $selectedMonth] ?? $selectedMonth;
$fmt = fn($value) => \App\Services\Util::formatNumber($value);
$kpiCards = [
[
'label' => 'Eigenpunkte',
'value' => $totals['own_points'],
'icon' => 'ion-md-person',
'color' => 'success',
'highlight' => true,
],
[
'label' => 'Shop-Punkte',
'value' => $totals['shop_points'],
'icon' => 'ion-md-cart',
'color' => 'primary',
'highlight' => false,
],
[
'label' => 'Abo-Punkte',
'value' => $totals['abo_points'],
'icon' => 'ion-md-repeat',
'color' => 'info',
'highlight' => false,
],
[
'label' => 'Gesamtpunkte',
'value' => $totals['total_points'],
'icon' => 'ion-md-stats',
'color' => 'success',
'highlight' => true,
],
];
@endphp
<h4 class="font-weight-bold py-2 mb-2 d-flex justify-content-between align-items-center w-100">
<div>
{{ __('navigation.my_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.my_statistics') }} — {{ $selectedMonthName }} {{ $selectedYear }}
</h5>
<p class="text-muted mb-md-0">
Nachvollziehbare Auflistung deiner Punkte und Umsätze des Monats, getrennt nach eigenen
Bestellungen, Shop-Bestellungen und Abos.
</p>
</div>
<div class="col-md-4 mt-3 mt-md-0">
<form method="GET" action="{{ route('user_my_statistics') }}"
class="form-inline justify-content-md-end">
<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>
<div class="row mb-2">
@foreach ($kpiCards as $card)
<div class="col-md-6 col-lg-3 mb-4">
<div class="card h-100"
@if ($card['highlight']) style="background-color:#eafaf0;border:1px solid #c3e6cb;" @endif>
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">{{ $card['label'] }}</div>
<div class="display-4 font-weight-bold text-{{ $card['color'] }}">{{ $fmt($card['value']) }}
</div>
</div>
<span class="ion {{ $card['icon'] }} display-4 text-muted"></span>
</div>
</div>
</div>
@endforeach
</div>
<div class="card mb-4">
<div class="card-body py-3 d-flex flex-wrap align-items-center">
<span class="mr-4 mb-1">
<span class="text-muted small">Gesamtpunkte:</span>
<strong>{{ $fmt($totals['total_points']) }}</strong>
</span>
<span class="mb-1">
<span class="text-muted small">Umsatz (netto):</span>
<strong>{{ $fmt($totals['turnover_net']) }} &euro;</strong>
</span>
</div>
</div>
@foreach ($statistics['groups'] as $group)
@if ($group['rows'] !== [])
<div class="card mb-4">
<h5 class="card-header with-elements d-flex justify-content-between align-items-center flex-wrap">
<div class="card-header-title">{{ $group['label'] }}</div>
<div class="text-muted small">
{{ count($group['rows']) }} Einträge ·
<strong class="text-body">{{ $fmt($group['points']) }}</strong> Punkte ·
<strong class="text-body">{{ $fmt($group['net']) }} &euro;</strong>
</div>
</h5>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Datum</th>
<th class="text-right">Punkte</th>
<th class="text-right">Netto</th>
<th>Typ</th>
<th>Bestellung</th>
@if (in_array($group['key'], ['shop', 'abo'], true))
<th>Kunde</th>
@endif
<th>Info</th>
</tr>
</thead>
<tbody>
@foreach ($group['rows'] as $row)
<tr>
<td class="font-weight-semibold">{{ $row['date'] }}</td>
<td class="text-right font-weight-semibold">{{ $fmt($row['points']) }}</td>
<td class="text-right">{{ $fmt($row['total_net']) }} &euro;</td>
<td>
<span
class="badge badge-pill badge-{{ $row['status_color'] }}">{{ $row['status_label'] }}</span>
</td>
<td>
@if ($row['order_id'] && $row['order_type'] === 'own')
<a href="{{ route('user_order_detail', [$row['order_id']]) }}"
class="btn btn-xs btn-primary">{{ $row['order_id'] }}</a>
@elseif ($row['order_id'] && $row['order_type'] === 'shop')
<a href="{{ route('user_shop_order_detail', [$row['order_id']]) }}"
class="btn btn-xs btn-secondary">{{ $row['order_id'] }}</a>
@else
<span class="text-muted">—</span>
@endif
</td>
@if (in_array($group['key'], ['shop', 'abo'], true))
<td>
@if ($row['customer_name'])
<span class="no-line-break">{{ $row['customer_name'] }}<br>
<small
class="text-muted">{{ $row['customer_email'] }}</small></span>
@else
<span class="text-muted">—</span>
@endif
</td>
@endif
<td><span class="no-line-break">{{ $row['info'] ?: $row['message'] }}</span></td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-weight-bold bg-light">
<td>Summe</td>
<td class="text-right">{{ $fmt($group['points']) }}</td>
<td class="text-right">{{ $fmt($group['net']) }} &euro;</td>
<td colspan="{{ in_array($group['key'], ['shop', 'abo'], true) ? 4 : 3 }}"></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
@endif
@endforeach
@php
$hasRows = collect($statistics['groups'])->contains(fn($group) => $group['rows'] !== []);
@endphp
@unless ($hasRows)
<div class="card mb-4">
<div class="card-body">
<p class="text-muted mb-0">{{ __('tables.no_data_available') }}</p>
</div>
</div>
@endunless
@endsection

View file

@ -127,6 +127,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
// user backoffice statistics
Route::get('/user/backoffice/statistics', 'User\BackofficeStatisticsController@index')->name('user_backoffice_statistics');
Route::get('/user/backoffice/statistics/my', 'User\BackofficeStatisticsController@my')->name('user_my_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');

View file

@ -4,6 +4,7 @@ use App\Http\Controllers\User\BackofficeStatisticsController;
use App\Models\ShoppingOrder;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\Services\Backoffice\PersonalStatisticsService;
use App\User;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -31,12 +32,13 @@ function makeBackofficeStatisticsRequest(User $user): Request
return $request;
}
function makeBackofficeStatisticsController(?BackofficeDashboardService $dashboardService = null, ?BackofficeDrilldownService $drilldownService = null): BackofficeStatisticsController
function makeBackofficeStatisticsController(?BackofficeDashboardService $dashboardService = null, ?BackofficeDrilldownService $drilldownService = null, ?PersonalStatisticsService $personalService = null): BackofficeStatisticsController
{
$dashboardService ??= Mockery::mock(BackofficeDashboardService::class);
$drilldownService ??= Mockery::mock(BackofficeDrilldownService::class);
$personalService ??= Mockery::mock(PersonalStatisticsService::class);
return new BackofficeStatisticsController($dashboardService, $drilldownService);
return new BackofficeStatisticsController($dashboardService, $drilldownService, $personalService);
}
function backofficeStatisticsStreamedContent(StreamedResponse $response): string
@ -113,6 +115,34 @@ it('blockiert die Backoffice-Statistik fuer normale aktive User', function () {
->toThrow(NotFoundHttpException::class);
});
it('blockiert die persoenliche Statistik fuer normale aktive User', function () {
$user = makeBackofficeStatisticsUser(0);
$controller = makeBackofficeStatisticsController();
expect(fn () => $controller->my(makeBackofficeStatisticsRequest($user)))
->toThrow(NotFoundHttpException::class);
});
it('liefert die persoenliche Statistik fuer VIP-User', function () {
$vip = makeBackofficeStatisticsUser(1);
$personalService = Mockery::mock(PersonalStatisticsService::class);
$personalService
->shouldReceive('monthly')
->once()
->andReturn(['month' => 5, 'year' => 2026, 'totals' => [], 'groups' => []]);
$controller = makeBackofficeStatisticsController(null, null, $personalService);
$request = makeBackofficeStatisticsRequest($vip);
$request->query->set('month', 5);
$request->query->set('year', 2026);
$response = $controller->my($request);
expect($response->name())->toBe('user.backoffice.statistics.my');
expect($response->getData()['selectedMonth'])->toBe(5);
expect($response->getData()['statistics']['year'])->toBe(2026);
});
it('erstellt einen CSV-Export fuer die Statistik-Uebersicht', function () {
$vip = makeBackofficeStatisticsUser(1);
$dashboardService = Mockery::mock(BackofficeDashboardService::class);
@ -229,9 +259,9 @@ it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeil
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');
expect($content)->toContain('Name;E-Mail;Karriere-Level;Eigenpunkte;Shop-Punkte;Abo-Punkte;"Sonstige Punkte";Kundenpunkte;Gesamtpunkte');
expect($content)->toContain('"Max Mustermann";max@example.test;Partner;400;0;700;0;700;1100');
expect($content)->toContain('Summe;"1 Eintraege";;400;0;700;0;700;1100');
});
it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function () {

View file

@ -122,6 +122,10 @@ beforeEach(function () {
$table->unsignedInteger('shopping_order_id')->nullable();
$table->unsignedTinyInteger('month')->nullable();
$table->unsignedSmallInteger('year')->nullable();
$table->decimal('points', 13, 2)->nullable();
$table->decimal('total_net', 13, 2)->nullable();
$table->unsignedTinyInteger('status')->nullable();
$table->unsignedTinyInteger('status_turnover')->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();
@ -276,27 +280,32 @@ it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function
'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,
]);
// Mehrere Zeilen je User/Monat: Die month_*-Spalten sind kumulative Laufstände
// (NICHT Einzelbeträge). Eine SUM() darüber zählt mehrfach – die Auswertung muss
// stattdessen je Zeile `points`/`total_net` nach `status` summieren.
$makeVolume = function (array $attributes): void {
UserSalesVolume::forceCreate(array_merge([
'month' => 5,
'year' => 2026,
'status_turnover' => null,
'month_KP_points' => 0,
'month_shop_points' => 0,
'month_total_net' => 0,
'month_shop_total_net' => 0,
], $attributes));
};
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,
]);
// lineOne: Eigenpunkte 150 + 250 = 400, Shop (Abo-Order) 300 + 400 = 700
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 1, 'points' => 150, 'total_net' => 40, 'month_KP_points' => 150, 'month_total_net' => 40]);
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 1, 'points' => 250, 'total_net' => 60, 'month_KP_points' => 400, 'month_total_net' => 100]);
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 2, 'points' => 300, 'total_net' => 120, 'month_shop_points' => 300, 'month_shop_total_net' => 120]);
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => $aboOrderId, 'status' => 2, 'points' => 400, 'total_net' => 80, 'month_shop_points' => 700, 'month_shop_total_net' => 200]);
// status 3 (pending) darf nirgends mitgezählt werden
$makeVolume(['user_id' => $lineOne->id, 'shopping_order_id' => null, 'status' => 3, 'points' => 999, 'total_net' => 999]);
// lineTwo: Eigenpunkte 200, Shop (Einzelbestellung) 300
$makeVolume(['user_id' => $lineTwo->id, 'shopping_order_id' => $singleOrderId, 'status' => 1, 'points' => 200, 'total_net' => 50, 'month_KP_points' => 200, 'month_total_net' => 50]);
$makeVolume(['user_id' => $lineTwo->id, 'shopping_order_id' => $singleOrderId, 'status' => 2, 'points' => 300, 'total_net' => 60, 'month_shop_points' => 300, 'month_shop_total_net' => 60]);
$overview = (new BackofficeDashboardService)->overview($root, 5, 2026);
@ -312,12 +321,14 @@ it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function
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]['turnover_net'])->toBe(300.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);
expect($overview['totals']['turnover_net'])->toBe(410.0);
$details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'total_points', 5, 2026);

View file

@ -0,0 +1,122 @@
<?php
use App\Models\UserSalesVolume;
use App\Services\Backoffice\PersonalStatisticsService;
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('shopping_orders');
Schema::dropIfExists('shopping_users');
Schema::dropIfExists('users');
Schema::create('users', function ($table) {
$table->increments('id');
$table->string('email')->unique();
$table->string('password');
$table->timestamps();
$table->softDeletes();
});
Schema::create('shopping_users', function ($table) {
$table->increments('id');
$table->string('billing_firstname')->nullable();
$table->string('billing_lastname')->nullable();
$table->string('billing_email')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('shopping_orders', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_user_id')->nullable();
$table->boolean('is_abo')->default(false);
$table->timestamps();
$table->softDeletes();
});
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('points', 13, 2)->nullable();
$table->decimal('total_net', 13, 2)->nullable();
$table->unsignedTinyInteger('status')->nullable();
$table->unsignedTinyInteger('status_turnover')->nullable();
$table->string('date')->nullable();
$table->string('info')->nullable();
$table->string('message')->nullable();
$table->timestamps();
});
});
it('gruppiert die Monatspunkte nach Eigen, Shop, Abo und Sonstige', function () {
$user = User::forceCreate(['email' => 'me@test.test', 'password' => 'secret']);
$customer = DB::table('shopping_users')->insertGetId([
'billing_firstname' => 'Erika', 'billing_lastname' => 'Musterfrau', 'billing_email' => 'erika@test.test',
'created_at' => now(), 'updated_at' => now(),
]);
$shopOrderId = DB::table('shopping_orders')->insertGetId([
'shopping_user_id' => $customer, 'is_abo' => false, 'created_at' => now(), 'updated_at' => now(),
]);
$aboOrderId = DB::table('shopping_orders')->insertGetId([
'shopping_user_id' => $customer, 'is_abo' => true, 'created_at' => now(), 'updated_at' => now(),
]);
$ownOrderId = DB::table('shopping_orders')->insertGetId([
'is_abo' => false, 'created_at' => now(), 'updated_at' => now(),
]);
$make = function (array $attributes) use ($user): void {
UserSalesVolume::forceCreate(array_merge([
'user_id' => $user->id, 'month' => 6, 'year' => 2026, 'date' => '01.06.2026',
], $attributes));
};
// Eigene Bestellungen (status 1): 150 + 250 = 400
$make(['shopping_order_id' => $ownOrderId, 'status' => 1, 'points' => 150, 'total_net' => 40]);
$make(['shopping_order_id' => $ownOrderId, 'status' => 1, 'points' => 250, 'total_net' => 60]);
// Shop-Bestellung (status 2, keine Abo-Order): 300
$make(['shopping_order_id' => $shopOrderId, 'status' => 2, 'points' => 300, 'total_net' => 120]);
// Abo (status 2, Abo-Order): 500
$make(['shopping_order_id' => $aboOrderId, 'status' => 2, 'points' => 500, 'total_net' => 200]);
// Sonstige (status 5, ohne Order): 50
$make(['shopping_order_id' => null, 'status' => 5, 'points' => 50, 'total_net' => 10]);
$result = (new PersonalStatisticsService)->monthly($user, 6, 2026);
expect($result['totals']['own_points'])->toBe(400.0);
expect($result['totals']['shop_points'])->toBe(300.0);
expect($result['totals']['abo_points'])->toBe(500.0);
expect($result['totals']['other_points'])->toBe(50.0);
expect($result['totals']['total_points'])->toBe(1250.0);
expect($result['totals']['turnover_net'])->toBe(430.0);
expect($result['groups']['own']['rows'])->toHaveCount(2);
expect($result['groups']['shop']['rows'])->toHaveCount(1);
expect($result['groups']['abo']['rows'])->toHaveCount(1);
expect($result['groups']['other']['rows'])->toHaveCount(1);
expect($result['groups']['own']['rows'][0]['order_type'])->toBe('own');
expect($result['groups']['shop']['rows'][0]['order_type'])->toBe('shop');
expect($result['groups']['shop']['rows'][0]['customer_name'])->toBe('Erika Musterfrau');
expect($result['groups']['abo']['rows'][0]['customer_email'])->toBe('erika@test.test');
});
it('liefert leere Gruppen ohne Umsaetze', function () {
$user = User::forceCreate(['email' => 'empty@test.test', 'password' => 'secret']);
$result = (new PersonalStatisticsService)->monthly($user, 6, 2026);
expect($result['totals']['total_points'])->toBe(0.0);
expect($result['groups']['own']['rows'])->toBe([]);
expect($result['groups']['shop']['rows'])->toBe([]);
});