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:
parent
db5dd7e1aa
commit
54bf2e5595
14 changed files with 666 additions and 70 deletions
|
|
@ -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', '', '', '', ''];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
121
app/Services/Backoffice/PersonalStatisticsService.php
Normal file
121
app/Services/Backoffice/PersonalStatisticsService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Services/Backoffice/SalesVolumePoints.php
Normal file
71
app/Services/Backoffice/SalesVolumePoints.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
204
resources/views/user/backoffice/statistics/my.blade.php
Normal file
204
resources/views/user/backoffice/statistics/my.blade.php
Normal 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']) }} €</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']) }} €</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']) }} €</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']) }} €</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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
122
tests/Unit/Services/PersonalStatisticsServiceTest.php
Normal file
122
tests/Unit/Services/PersonalStatisticsServiceTest.php
Normal 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([]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue