mivita/tests/Unit/Services/BackofficeDashboardServiceTest.php
Kevin 96545364f1 Team-Statistik = Team-Struktur (Provisionsbaum, Umschalter) und Laufstand-Fix in user_sales_volumes
K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026.

C1 – Statistik rechnet wie die Provision
- Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene,
  Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien).
  Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session.
- Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt
  eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt.
- Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf
  (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration).
- Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp.
- Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift).

C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen)
- Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month).
- Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und
  Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt.
- Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf;
  abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08).

Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein:
Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:31:32 +00:00

470 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
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('backoffice_statistics_snapshots');
Schema::dropIfExists('user_businesses');
Schema::dropIfExists('user_business_structures');
Schema::dropIfExists('user_abo_orders');
Schema::dropIfExists('user_abo_items');
Schema::dropIfExists('products');
Schema::dropIfExists('user_abos');
Schema::dropIfExists('payment_transactions');
Schema::dropIfExists('shopping_payments');
Schema::dropIfExists('shopping_order_items');
Schema::dropIfExists('shopping_orders');
Schema::dropIfExists('users');
Schema::dropIfExists('user_levels');
Schema::dropIfExists('user_accounts');
Schema::create('user_accounts', function ($table) {
$table->increments('id');
$table->string('first_name')->nullable();
$table->string('last_name')->nullable();
});
Schema::create('user_levels', function ($table) {
$table->increments('id');
$table->string('name');
$table->unsignedInteger('pos')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
});
Schema::create('users', function ($table) {
$table->increments('id');
$table->string('email')->unique();
$table->string('password');
$table->unsignedInteger('account_id')->nullable();
$table->unsignedInteger('m_level')->nullable();
$table->unsignedInteger('m_sponsor')->nullable();
$table->boolean('active')->default(false);
$table->timestamp('active_date')->nullable();
$table->unsignedTinyInteger('admin')->default(0);
$table->unsignedTinyInteger('wizard')->default(0);
$table->unsignedTinyInteger('blocked')->default(0);
$table->char('lang', 2)->default('de');
$table->timestamp('payment_account')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('user_abos', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_id')->nullable();
$table->unsignedInteger('member_id')->nullable();
$table->char('is_for', 2)->nullable();
$table->boolean('active')->default(true);
$table->unsignedTinyInteger('status')->default(2);
$table->date('start_date')->nullable();
$table->date('next_date')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('user_abo_items', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_abo_id');
$table->unsignedInteger('product_id')->nullable();
$table->unsignedTinyInteger('comp')->nullable();
$table->unsignedInteger('qty')->default(1);
$table->timestamps();
});
Schema::create('products', function ($table) {
$table->increments('id');
$table->decimal('points', 8, 2)->default(0);
$table->timestamps();
$table->softDeletes();
});
Schema::create('user_abo_orders', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_abo_id');
$table->unsignedInteger('shopping_order_id');
$table->unsignedTinyInteger('status')->default(2);
$table->boolean('paid')->default(true);
$table->timestamps();
});
Schema::create('shopping_orders', function ($table) {
$table->increments('id');
$table->boolean('is_abo')->default(false);
$table->timestamps();
$table->softDeletes();
});
// Positionsebene: nur fuer die Aufteilung der Abo-Punkte in wiederkehrende
// und einmalig dazubestellte (is_abo_addon) noetig.
Schema::create('shopping_order_items', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_order_id');
$table->unsignedInteger('product_id')->nullable();
$table->unsignedInteger('qty')->default(1);
$table->decimal('points', 10, 2)->nullable();
$table->boolean('is_abo_addon')->default(false);
$table->timestamps();
$table->softDeletes();
});
Schema::create('shopping_payments', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_order_id');
$table->string('clearingtype')->nullable();
$table->string('reference')->nullable();
$table->integer('amount')->nullable();
$table->string('currency')->nullable();
$table->timestamps();
});
Schema::create('payment_transactions', function ($table) {
$table->increments('id');
$table->unsignedInteger('shopping_payment_id');
$table->string('request')->nullable();
$table->unsignedInteger('errorcode')->nullable();
$table->string('errormessage')->nullable();
$table->string('customermessage')->nullable();
$table->timestamps();
});
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();
// Ursprungsart einer Stornobuchung — gebraucht von den Eigen-/Shop-Praedikaten.
$table->unsignedTinyInteger('storno_origin_status')->nullable();
// Nur-KP-Kennzeichen (Neupartner-Einschreibungspunkte) — gebraucht von
// SalesVolumePoints::nonPaylinePoints().
$table->unsignedTinyInteger('status_points')->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();
$table->decimal('month_shop_total_net', 13, 2)->nullable();
$table->timestamps();
});
// Gespeicherter Provisionslauf: fuer abgeschlossene Monate liest die
// Statistik die Struktur von dort (hier leer → Live-Baum).
Schema::create('user_business_structures', function ($table) {
$table->increments('id');
$table->unsignedTinyInteger('month');
$table->unsignedSmallInteger('year');
$table->boolean('completed')->default(false);
$table->timestamps();
});
Schema::create('user_businesses', function ($table) {
$table->increments('id');
$table->unsignedInteger('user_id');
$table->unsignedTinyInteger('month');
$table->unsignedSmallInteger('year');
$table->unsignedInteger('b_structure_id')->nullable();
$table->text('sponsor')->nullable();
$table->timestamps();
});
Schema::create('backoffice_statistics_snapshots', function ($table) {
$table->id();
$table->unsignedInteger('user_id');
$table->unsignedSmallInteger('year');
$table->unsignedTinyInteger('month');
$table->string('tree', 16)->default('commission');
$table->json('payload');
$table->timestamp('calculated_at')->nullable();
$table->timestamps();
});
});
it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function () {
UserLevel::forceCreate([
'id' => 1,
'name' => 'Partner',
'pos' => 1,
'active' => true,
]);
$root = User::forceCreate([
'email' => 'root@test.test',
'password' => 'secret',
'lang' => 'de',
'admin' => 1,
]);
$lineOne = User::forceCreate([
'email' => 'line-one@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $root->id,
'm_level' => 1,
'active_date' => '2026-05-03 00:00:00',
'payment_account' => '2030-01-01 00:00:00',
]);
$lineTwo = User::forceCreate([
'email' => 'line-two@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $lineOne->id,
'm_level' => 1,
'active_date' => '2026-04-03 00:00:00',
'payment_account' => '2030-01-01 00:00:00',
]);
User::forceCreate([
'email' => 'expired-line-one@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $root->id,
'm_level' => 1,
'active_date' => '2026-01-03 00:00:00',
'payment_account' => '2020-01-01 00:00:00',
]);
$sponsor = $lineTwo;
foreach (range(3, 9) as $lineNumber) {
$sponsor = User::forceCreate([
'email' => 'line-'.$lineNumber.'@test.test',
'password' => 'secret',
'lang' => 'de',
'm_sponsor' => $sponsor->id,
'm_level' => 1,
'active_date' => '2026-04-03 00:00:00',
'payment_account' => '2030-01-01 00:00:00',
]);
}
$activePartnerAbo = UserAbo::forceCreate([
'user_id' => $lineOne->id,
'member_id' => $lineOne->id,
'is_for' => 'me',
'active' => true,
'status' => 2,
'start_date' => '2026-05-04',
'next_date' => '2026-06-01',
]);
UserAbo::forceCreate([
'user_id' => $lineTwo->id,
'member_id' => $lineOne->id,
'is_for' => 'ot',
'active' => true,
'status' => 2,
'start_date' => '2026-05-05',
'next_date' => '2026-06-01',
]);
$pausedPartnerAbo = UserAbo::forceCreate([
'user_id' => $lineOne->id,
'member_id' => $lineOne->id,
'is_for' => 'me',
'active' => true,
'status' => 3,
'start_date' => '2026-04-15',
'next_date' => '2026-06-02',
]);
$aboOrderId = DB::table('shopping_orders')->insertGetId([
'is_abo' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$singleOrderId = DB::table('shopping_orders')->insertGetId([
'is_abo' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$failedAboOrderId = DB::table('shopping_orders')->insertGetId([
'is_abo' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$failedAboPaymentId = DB::table('shopping_payments')->insertGetId([
'shopping_order_id' => $failedAboOrderId,
'clearingtype' => 'cc',
'reference' => 'abo-failed',
'amount' => 9900,
'currency' => 'EUR',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('payment_transactions')->insert([
'shopping_payment_id' => $failedAboPaymentId,
'request' => 'authorization',
'errorcode' => 902,
'errormessage' => 'Bank hat abgelehnt',
'customermessage' => 'Bitte Zahlungsmittel prüfen',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('user_abo_orders')->insert([
'user_abo_id' => $pausedPartnerAbo->id,
'shopping_order_id' => $failedAboOrderId,
'status' => 3,
'paid' => false,
'created_at' => now(),
'updated_at' => now(),
]);
// 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));
};
// 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);
expect($overview['lines'][1]['consultants'])->toBe(2);
expect($overview['lines'][1]['new_partners'])->toBe(1);
expect($overview['lines'][1]['team_partner_abos'])->toBe(2);
expect($overview['lines'][1]['team_partner_abos_new'])->toBe(1);
expect($overview['lines'][1]['team_customer_abos'])->toBe(1);
expect($overview['lines'][1]['team_customer_abos_new'])->toBe(1);
expect($overview['lines'][1]['own_points'])->toBe(400.0);
expect($overview['lines'][1]['external_points'])->toBe(700.0);
expect($overview['lines'][1]['customer_abo_points'])->toBe(700.0);
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);
expect($details['line_label'])->toBe('Alle Linien');
expect($details['rows'])->toHaveCount(2);
expect($details['summary']['own_points'])->toBe(600.0);
expect($details['summary']['external_points'])->toBe(1000.0);
expect($details['summary']['customer_abo_points'])->toBe(700.0);
expect($details['summary']['customer_single_order_points'])->toBe(300.0);
expect($details['summary']['customer_other_points'])->toBe(0.0);
expect($details['summary']['total_points'])->toBe(1600.0);
$shop1000Details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'shop_1000', 5, 2026);
expect($shop1000Details['rows'])->toHaveCount(1);
expect($shop1000Details['rows'][0]['career_level'])->toBe('Partner');
expect($shop1000Details['rows'][0]['own_points'])->toBe(400.0);
expect($shop1000Details['rows'][0]['customer_abo_points'])->toBe(700.0);
$consultantDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'consultants', 5, 2026);
expect($consultantDetails['rows'])->toHaveCount(2);
expect($consultantDetails['summary']['count'])->toBe(2);
expect(collect($consultantDetails['rows'])->pluck('is_account_active')->all())->toBe([true, false]);
expect(collect($consultantDetails['rows'])->pluck('career_level')->all())->toBe(['Partner', 'Partner']);
$aboDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'team_partner_abos', 5, 2026);
expect($aboDetails['rows'][0]['start_date'])->toBe('04.05.2026');
expect($aboDetails['rows'][0]['is_new_this_month'])->toBeTrue();
expect($aboDetails['rows'])->toHaveCount(2);
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $activePartnerAbo->id)['status_reason'])->toBeNull();
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['status_reason'])->toBe('[902] Bank hat abgelehnt');
expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['is_new_this_month'])->toBeFalse();
});
it('verwendet gespeicherte Snapshots fuer abgeschlossene Monate', function () {
$root = User::forceCreate([
'email' => 'snapshot-root@test.test',
'password' => 'secret',
'lang' => 'de',
'admin' => 1,
]);
$payload = [
'month' => 4,
'year' => 2026,
'metric_labels' => [],
'lines' => [
1 => [
'line' => 1,
'label' => 'Linie 1',
'user_ids' => [],
'consultants' => 99,
'new_partners' => 0,
'team_partner_abos' => 0,
'team_partner_abos_new' => 0,
'team_customer_abos' => 0,
'team_customer_abos_new' => 0,
'own_points' => 0,
'external_points' => 0,
'customer_abo_points' => 0,
'customer_single_order_points' => 0,
'customer_other_points' => 0,
'total_points' => 0,
'turnover_net' => 0,
'shop_1000' => 0,
],
],
'totals' => [
'label' => 'Summe',
'consultants' => 99,
],
];
BackofficeStatisticsSnapshot::create([
'user_id' => $root->id,
'year' => 2026,
'month' => 4,
'payload' => $payload,
'calculated_at' => now(),
]);
$overview = (new BackofficeDashboardService)->overview($root, 4, 2026);
expect($overview['lines'][1]['consultants'])->toBe(99);
expect($overview['_meta']['source'])->toBe('snapshot');
expect($overview['_meta']['source_label'])->toBe('Snapshot');
});