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