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 9565814221
commit 7a620d66ae
14 changed files with 666 additions and 70 deletions

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)';
}
}