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

@ -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([]);
});