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>
This commit is contained in:
Kevin 2026-09-14 15:31:32 +00:00
parent 8f4e105c0f
commit d57687b4e3
33 changed files with 2321 additions and 139 deletions

View file

@ -213,7 +213,7 @@ it('erstellt einen CSV-Export fuer die Statistik-Uebersicht', function () {
$content = backofficeStatisticsStreamedContent($response);
expect($response)->toBeInstanceOf(StreamedResponse::class);
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-05-2026.csv');
expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-commission-05-2026.csv');
expect($content)->toContain('Linie;Berater;Neupartner;Teamabos');
expect($content)->toContain('Neue Teamabos');
expect($content)->toContain('"Linie 1";2;1;1;1;1;1;400;700;700;0;0;1100;1;300');
@ -226,7 +226,7 @@ it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeil
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 0, 'shop_1000', 5, 2026, false)
->with($vip, 0, 'shop_1000', 5, 2026, false, 'commission')
->andReturn([
'metric' => 'shop_1000',
'metric_label' => '1000 Punkte Shop',
@ -297,7 +297,7 @@ it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function ()
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 1, 'consultants', 4, 2026, false)
->with($vip, 1, 'consultants', 4, 2026, false, 'commission')
->andReturn([
'metric' => 'consultants',
'metric_label' => 'Berater',
@ -313,7 +313,7 @@ it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function ()
$drilldownService
->shouldReceive('details')
->once()
->with($vip, 1, 'consultants', 4, 2026, false)
->with($vip, 1, 'consultants', 4, 2026, false, 'commission')
->andReturn([
'metric' => 'consultants',
'metric_label' => 'Berater',
@ -353,7 +353,12 @@ it('rendert eine Suche in der Detailtabelle', function () {
expect($html)->toContain('backoffice-statistics-detail-search');
expect($html)->toContain('backoffice-statistics-detail-table');
expect($html)->toContain('data-sortable="true"');
expect($html)->toContain('getSortValue');
// Die Sortier-/Suchlogik liegt seit 09/2026 im gemeinsamen Partial, damit die
// Kundenabo-Liste dieselbe Implementierung nutzt statt einer Kopie.
expect($html)->toContain("@include('components._sortable_table'");
expect(file_get_contents(resource_path('views/components/_sortable_table.blade.php')))
->toContain('getSortValue')
->toContain('data-sort-direction');
expect($html)->toContain('user_backoffice_statistics_export');
expect($html)->toContain('Datenschutz-Hinweis');
expect($html)->toContain('Karriere-Level');

View file

@ -31,7 +31,9 @@ function boMakeUser(?int $sponsorId = null, ?string $activeDate = null, ?string
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'active_date' => $activeDate,
// Ohne active_date steht ein Berater nicht im Provisionsbaum (K11) — wie
// im TreeCalcBot. Der Standard liegt vor allen Testmonaten.
'active_date' => $activeDate ?? '2026-01-15',
'payment_account' => $paymentAccount ?? now()->addYear(),
]);
}

View file

@ -422,8 +422,11 @@ it('summiert Punkte ohne Payline-Wirkung (status_points 2) als eigene Kennzahl',
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
// K11: „Umsatz (Punkte)" sind die Provisionspunkte — die Nur-KP-Buchung steckt
// nicht mehr darin, sondern wird gesondert ausgewiesen.
expect($overview['non_payline_points'])->toBe(200.0)
->and($overview['totals']['total_points'])->toBe(350.0);
->and($overview['totals']['total_points'])->toBe(150.0)
->and($overview['totals']['own_points'])->toBe(350.0);
});
it('ignoriert Abo-Punkte geloeschter Produkte im Soll der Top-10-Umsatzliste', function (): void {

View file

@ -0,0 +1,186 @@
<?php
use App\Models\UserBusinessStructure;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
use App\Services\Database\ConcurrencyError;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Ticket C2 (Entwicklungsplan September 2026): Laufstand-Drift in
* `user_sales_volumes` — Erkennung, Reparatur, Sicherheitsnetz-Kommando und
* die sperrende Lesart der Neuberechnung.
*/
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-09-15 10:00:00');
});
afterEach(fn () => Carbon::setTestNow());
function rtUser(): User
{
return User::forceCreate([
'email' => 'rt-'.uniqid('', true).'@example.test',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'active_date' => '2026-01-01',
'payment_account' => '2027-01-01',
]);
}
/**
* Buchung mit explizit gesetztem Laufstand — so, wie sie eine Transaktion
* hinterlassen hat, die die Nachbarzeile nicht sah.
*/
function rtRow(User $user, float $points, float $runningShop, float $runningTp = 0, int $status = 2, int $statusPoints = 1, int $month = 9): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $user->id,
'month' => $month,
'year' => 2026,
'date' => sprintf('2026-%02d-10', $month),
'points' => $points,
'total_net' => $points,
'status' => $status,
'status_points' => $statusPoints,
'month_KP_points' => $runningTp,
'month_TP_points' => $runningTp,
'month_shop_points' => $runningShop,
]);
}
it('erkennt einen ueberholten Laufstand und rechnet ihn neu', function () {
$partner = rtUser();
$healthy = rtUser();
// Fall aus dem Messlauf (User 848, 10.09.): 129 → 506, dann 51 → 428 statt 557.
rtRow($partner, 377, 377);
rtRow($partner, 129, 506);
rtRow($partner, 51, 428);
rtRow($healthy, 100, 100);
rtRow($healthy, 20, 120);
$service = app(SalesVolumeRunningTotalService::class);
$drift = $service->detectDrift(9, 2026);
expect($drift)->toHaveKey($partner->id)
->and($drift)->not->toHaveKey($healthy->id)
->and($drift[$partner->id])->toBe(['rows' => 557.0, 'running' => 428.0]);
$repaired = $service->repair(9, 2026);
expect(array_keys($repaired))->toBe([$partner->id]);
$rows = UserSalesVolume::where('user_id', $partner->id)->orderBy('id')->pluck('month_shop_points')->map(fn ($v) => (float) $v)->all();
expect($rows)->toBe([377.0, 506.0, 557.0])
->and($service->detectDrift(9, 2026))->toBe([]);
});
it('meldet keine Drift fuer reine Karrierepunkte und andere Monate', function () {
$partner = rtUser();
// KP-only (Neupartner-Einschreibung) zaehlt nicht zu TP + Shop: Laufstand bleibt 0 + 80.
rtRow($partner, 80, 80);
rtRow($partner, 50, 80, 0, 5, 2);
// Vormonat mit eigenem Laufstand — darf die September-Pruefung nicht beeinflussen.
rtRow($partner, 999, 999, 0, 2, 1, 8);
$service = app(SalesVolumeRunningTotalService::class);
expect($service->detectDrift(9, 2026))->toBe([])
->and($service->detectDrift(8, 2026))->toBe([]);
});
it('prueft im Nachtlauf den laufenden Monat und den Vormonat nur solange er offen ist', function () {
$service = app(SalesVolumeRunningTotalService::class);
expect($service->monthsToGuard())->toBe([
['month' => 8, 'year' => 2026],
['month' => 9, 'year' => 2026],
]);
UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]);
expect($service->monthsToGuard())->toBe([['month' => 9, 'year' => 2026]]);
Carbon::setTestNow('2026-10-01 02:45:00');
expect($service->monthsToGuard())->toBe([
['month' => 9, 'year' => 2026],
['month' => 10, 'year' => 2026],
]);
});
it('repariert per Kommando, im Dry-Run nur Anzeige', function () {
$partner = rtUser();
rtRow($partner, 129, 129);
rtRow($partner, 51, 51);
$this->artisan('sales:repair-running-totals', ['--dry-run' => true])
->expectsOutputToContain('Laufstaende 09/2026: 1 User mit Drift (DRY-RUN, nichts geschrieben)')
->expectsOutputToContain('Laufstaende 08/2026: keine Abweichung.')
->assertSuccessful();
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(51.0);
$this->artisan('sales:repair-running-totals')
->expectsOutputToContain('Laufstaende 09/2026: 1 User mit Drift, neu berechnet')
->assertSuccessful();
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(180.0);
$this->artisan('sales:repair-running-totals')
->expectsOutputToContain('Laufstaende 09/2026: keine Abweichung.')
->assertSuccessful();
});
it('rechnet einen abgerechneten Monat nur mit --force neu', function () {
$partner = rtUser();
rtRow($partner, 129, 129, 0, 2, 1, 8);
rtRow($partner, 51, 51, 0, 2, 1, 8);
UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]);
$this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026])
->expectsOutputToContain('Monat 08/2026 ist abgerechnet')
->assertFailed();
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(51.0);
$this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026, '--dry-run' => true])
->expectsOutputToContain('1 User mit Drift (DRY-RUN')
->assertSuccessful();
$this->artisan('sales:repair-running-totals', ['month' => 8, 'year' => 2026, '--force' => true])
->expectsOutputToContain('neu berechnet')
->assertSuccessful();
expect((float) UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->value('month_shop_points'))->toBe(180.0);
});
it('liest die Zeilen fuer die Neuberechnung sperrend', function () {
$query = SalesPointsVolume::runningTotalRows(1, 9, 2026);
expect($query->getQuery()->lock)->toBeTrue();
});
it('erkennt Deadlocks und Lock-Timeouts als Concurrency-Fehler', function () {
$deadlock = new QueryException('mysql', 'update x', [], new PDOException('SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock'));
$timeout = new QueryException('mysql', 'update x', [], new PDOException('SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction'));
$other = new QueryException('mysql', 'insert x', [], new PDOException('SQLSTATE[23000]: Integrity constraint violation'));
expect(ConcurrencyError::isCausedBy($deadlock))->toBeTrue()
->and(ConcurrencyError::isCausedBy($timeout))->toBeTrue()
->and(ConcurrencyError::isCausedBy($other))->toBeFalse()
->and(ConcurrencyError::isCausedBy(new RuntimeException('Rechnung kaputt')))->toBeFalse();
});

View file

@ -0,0 +1,248 @@
<?php
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserBusiness;
use App\Models\UserBusinessStructure;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* K11 (Kundenentscheid 2026-09-14): Die Team-Statistik rechnet standardmaessig mit
* dem Provisionsbaum und den Provisionspunkten — denselben Mitgliedern und
* Punkten wie die Team-Struktur. Die gesamte Downline bleibt per Umschalter
* erreichbar.
*/
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-09-15 10:00:00');
});
afterEach(fn () => Carbon::setTestNow());
/**
* @param array<string, mixed> $overrides
*/
function sctUser(?int $sponsor = null, array $overrides = []): User
{
return User::forceCreate(array_merge([
'email' => 'sct-'.uniqid('', true).'@example.test',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsor,
'active_date' => '2026-01-01',
'payment_account' => '2027-01-01',
], $overrides));
}
function sctVolume(User $user, float $points, int $status = 1, int $statusPoints = 1, int $month = 9): UserSalesVolume
{
return UserSalesVolume::forceCreate([
'user_id' => $user->id,
'month' => $month,
'year' => 2026,
'date' => sprintf('2026-%02d-05', $month),
'points' => $points,
'total_net' => $points,
'status' => $status,
'status_points' => $statusPoints,
'status_turnover' => 1,
]);
}
it('nimmt in den Provisionsbaum nur Berater mit Mitgliedsebene und Abrechnungskonto auf und kappt deren Unterbaum', function () {
$root = sctUser();
$partner = sctUser($root->id); // Linie 1, im Provisionsbaum
$noLevel = sctUser($root->id, ['m_level' => null]); // Linie 1, ohne Ebene → nicht im Provisionsbaum
$underNoLevel = sctUser($noLevel->id); // Linie 2, regulaer — aber unter gekapptem Knoten
$noAccount = sctUser($partner->id, ['payment_account' => null]); // Linie 2, ohne Abrechnungskonto
$expired = sctUser($partner->id, ['payment_account' => '2026-08-01']); // Linie 2, abgelaufen — zaehlt im Provisionsbaum trotzdem
$laterActive = sctUser($partner->id, ['active_date' => '2026-10-01']); // Linie 2, erst nach Monatsende aktiv
sctVolume($partner, 100);
sctVolume($noLevel, 100);
sctVolume($underNoLevel, 100);
sctVolume($noAccount, 100);
sctVolume($expired, 100);
sctVolume($laterActive, 100);
$service = app(BackofficeDashboardService::class);
$commission = $service->overview($root, 9, 2026);
$downline = $service->overview($root, 9, 2026, BackofficeDashboardService::TREE_DOWNLINE);
expect($commission['_meta']['tree'])->toBe('commission')
->and($commission['tree'])->toBe('commission')
->and($commission['lines'][1]['user_ids'])->toBe([$partner->id])
->and($commission['lines'][2]['user_ids'])->toBe([$expired->id])
->and($commission['lines'])->not->toHaveKey(3)
->and((float) $commission['totals']['total_points'])->toBe(200.0)
->and($commission['totals']['consultants'])->toBe(2);
expect($downline['_meta']['tree'])->toBe('downline')
->and(collect($downline['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$partner->id, $noLevel->id])->sort()->values()->all())
->and(count($downline['lines'][2]['user_ids']))->toBe(4)
->and((float) $downline['totals']['total_points'])->toBe(600.0);
});
it('rechnet Umsatz (Punkte) als Provisionspunkte: ohne Nur-KP-Buchungen und mit Knotenregel fuer negative Salden', function () {
$root = sctUser();
$a = sctUser($root->id);
$b = sctUser($root->id);
sctVolume($a, 300); // Eigenbestellung → TP
sctVolume($a, 50, 1, 2); // Neupartner-Einschreibung: nur KP → nicht in den Provisionspunkten
sctVolume($a, 120, 2); // Kundenbestellung → Shop
sctVolume($b, 80);
sctVolume($b, -100, 6); // Storno ueber den Monat hinaus → Saldo -20 → Knotenregel 0
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
$line = $overview['lines'][1];
expect((float) $line['total_points'])->toBe(420.0)
->and((float) $line['own_points'])->toBe(330.0) // Aufteilung bleibt Zeilensumme: 300 + 50 + 80 - 100
->and((float) $line['external_points'])->toBe(120.0)
->and((float) $line['storno_floor_points'])->toBe(20.0)
->and((float) $overview['non_payline_points'])->toBe(50.0)
->and((float) $overview['storno_floor_points'])->toBe(20.0)
->and((float) $overview['totals']['total_points'])->toBe(420.0);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026);
$rowA = collect($details['rows'])->firstWhere('user_id', $a->id);
$rowB = collect($details['rows'])->firstWhere('user_id', $b->id);
expect($details['summary']['total_points'])->toBe(420.0)
->and($rowA['total_points'])->toBe(420.0)
->and($rowB['total_points'])->toBe(0.0)
->and($rowB['storno_floor_points'])->toBe(20.0)
->and($rowB['own_points'])->toBe(-20.0);
});
it('liefert die Provisionspunkte je Linie so, wie die Team-Struktur sie aus den Laufstaenden bildet', function () {
$root = sctUser();
$line1 = sctUser($root->id);
$line2 = sctUser($line1->id);
// Laufstaende wie SalesPointsVolume::reCalculateSalesPointsVolume() sie fuehren
// wuerde; getPointsTPSum() der letzten Zeile ist die Basis der Struktur.
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-05', 'points' => 200, 'total_net' => 200, 'status' => 1, 'status_points' => 1, 'month_KP_points' => 200, 'month_TP_points' => 200, 'month_shop_points' => 0]);
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-06', 'points' => 30, 'total_net' => 30, 'status' => 1, 'status_points' => 2, 'month_KP_points' => 230, 'month_TP_points' => 200, 'month_shop_points' => 0]);
UserSalesVolume::forceCreate(['user_id' => $line1->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-07', 'points' => 75.5, 'total_net' => 75.5, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 230, 'month_TP_points' => 200, 'month_shop_points' => 75.5]);
UserSalesVolume::forceCreate(['user_id' => $line2->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-08', 'points' => 40, 'total_net' => 40, 'status' => 5, 'status_points' => 1, 'month_KP_points' => 40, 'month_TP_points' => 40, 'month_shop_points' => 0]);
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
$lastRowOfLine1 = UserSalesVolume::where('user_id', $line1->id)->orderByDesc('id')->first();
$lastRowOfLine2 = UserSalesVolume::where('user_id', $line2->id)->orderByDesc('id')->first();
expect((float) $overview['lines'][1]['total_points'])->toBe((float) $lastRowOfLine1->getPointsTPSum())
->and((float) $overview['lines'][2]['total_points'])->toBe((float) $lastRowOfLine2->getPointsTPSum())
->and((float) $overview['totals']['total_points'])->toBe(315.5);
});
it('haelt je Baum-Definition einen eigenen Snapshot fuer abgeschlossene Monate', function () {
$root = sctUser();
$partner = sctUser($root->id);
$noLevel = sctUser($root->id, ['m_level' => null]);
sctVolume($partner, 100, 1, 1, 8);
sctVolume($noLevel, 100, 1, 1, 8);
$service = app(BackofficeDashboardService::class);
$service->storeSnapshot($root, 8, 2026, BackofficeDashboardService::TREE_COMMISSION);
$service->storeSnapshot($root, 8, 2026, BackofficeDashboardService::TREE_DOWNLINE);
expect(BackofficeStatisticsSnapshot::query()->where('user_id', $root->id)->count())->toBe(2)
->and(BackofficeStatisticsSnapshot::query()->where('user_id', $root->id)->pluck('tree')->sort()->values()->all())->toBe(['commission', 'downline']);
$noLevel->update(['m_level' => 1]);
$commission = $service->overview($root, 8, 2026);
$downline = $service->overview($root, 8, 2026, BackofficeDashboardService::TREE_DOWNLINE);
expect($commission['_meta']['source'])->toBe('snapshot')
->and((float) $commission['totals']['total_points'])->toBe(100.0)
->and($downline['_meta']['source'])->toBe('snapshot')
->and((float) $downline['totals']['total_points'])->toBe(200.0);
// Die Detailliste des eingefrorenen Monats folgt derselben Baum-Definition
// (die Berater-Liste selbst verlangt weiterhin eine Mitgliedsebene, deshalb
// hier ueber die Punkte-Liste).
$downlineDetails = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026, false, BackofficeDashboardService::TREE_DOWNLINE);
$commissionDetails = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 8, 2026);
expect(collect($downlineDetails['rows'])->pluck('user_id')->sort()->values()->all())->toBe(collect([$partner->id, $noLevel->id])->sort()->values()->all())
->and(collect($commissionDetails['rows'])->pluck('user_id')->all())->toBe([$partner->id]);
});
it('faellt bei unbekannter Baum-Definition auf den Provisionsbaum zurueck', function () {
expect(BackofficeDashboardService::normalizeTree('foo'))->toBe('commission')
->and(BackofficeDashboardService::normalizeTree(null))->toBe('commission')
->and(BackofficeDashboardService::normalizeTree('downline'))->toBe('downline');
});
it('meldet im Messlauf Laufstand-Drift und repariert ihn auf Wunsch', function () {
$root = sctUser(null, ['admin' => 1]);
$partner = sctUser($root->id);
// Zwei Buchungen, deren Laufstaende sich ueberholt haben (zeitgleiche
// Abo-Ausfuehrungen): die letzte Zeile kennt die 129 Punkte der vorherigen nicht.
UserSalesVolume::forceCreate(['user_id' => $partner->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-10', 'points' => 129, 'total_net' => 129, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 0, 'month_TP_points' => 0, 'month_shop_points' => 129]);
UserSalesVolume::forceCreate(['user_id' => $partner->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-10', 'points' => 51, 'total_net' => 51, 'status' => 2, 'status_points' => 1, 'month_KP_points' => 0, 'month_TP_points' => 0, 'month_shop_points' => 51]);
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id]])
->expectsOutputToContain('1 User mit Laufstand-Drift')
->assertSuccessful();
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id], '--repair-running-totals' => true])
->assertSuccessful();
$lastRow = UserSalesVolume::where('user_id', $partner->id)->orderByDesc('id')->first();
expect((float) $lastRow->month_shop_points)->toBe(180.0);
$this->artisan('statistics:compare-trees', ['month' => 9, 'year' => 2026, '--user' => [$root->id]])
->expectsOutputToContain('keine Abweichung')
->assertSuccessful();
});
it('nimmt fuer abgeschlossene Monate die Struktur des gespeicherten Provisionslaufs statt des heutigen Baums', function () {
$root = sctUser();
$stayed = sctUser($root->id); // damals wie heute unter root
$movedAway = sctUser(null); // damals unter root, heute deaktiviert (kein Sponsor)
$joinedLater = sctUser($root->id); // heute unter root, im Lauf vom 01.09. nicht im Baum
$grandChild = sctUser($movedAway->id); // damals unter movedAway (Linie 2)
sctVolume($stayed, 100, 1, 1, 8);
sctVolume($movedAway, 200, 1, 1, 8);
sctVolume($joinedLater, 400, 1, 1, 8);
sctVolume($grandChild, 50, 1, 1, 8);
$structure = UserBusinessStructure::create(['month' => 8, 'year' => 2026, 'completed' => true, 'status' => 1]);
$sponsorJson = fn (?int $sponsorId) => (object) ['is_sponsor' => $sponsorId !== null, 'user_id' => $sponsorId ?? false];
foreach ([[$root, null], [$stayed, $root->id], [$movedAway, $root->id], [$grandChild, $movedAway->id]] as [$user, $sponsorId]) {
UserBusiness::forceCreate(['user_id' => $user->id, 'month' => 8, 'year' => 2026, 'b_structure_id' => $structure->id, 'sponsor' => $sponsorJson($sponsorId), 'version' => 2]);
}
$service = app(BackofficeDashboardService::class);
$august = $service->overview($root, 8, 2026);
expect($august['tree_source'])->toBe('stored_structure')
->and(collect($august['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$stayed->id, $movedAway->id])->sort()->values()->all())
->and($august['lines'][2]['user_ids'])->toBe([$grandChild->id])
->and((float) $august['totals']['total_points'])->toBe(350.0);
// Der laufende Monat bleibt live (heutiger Baum), auch wenn ein Nachtlauf existiert.
UserBusinessStructure::create(['month' => 9, 'year' => 2026, 'completed' => true, 'status' => 1]);
$september = $service->overview($root, 9, 2026);
expect($september['tree_source'])->toBe('live')
->and(collect($september['lines'][1]['user_ids'])->sort()->values()->all())->toBe(collect([$stayed->id, $joinedLater->id])->sort()->values()->all());
});

View file

@ -145,9 +145,16 @@ it('zeigt negative Monatssalden im Drilldown und bleibt mit der Uebersicht gleic
$overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026);
expect((float) $overview['totals']['total_points'])->toBe(60.0)
->and($details['summary']['total_points'])->toBe(60.0)
->and($details['rows'])->toHaveCount(2);
// Knotenregel des Provisionsbaums (K11): ein negativer Monatssaldo zaehlt 0,
// die Kachel zeigt also 100 und weist die 40 gesondert als nicht abgezogen aus.
// Der Berater bleibt in der Liste (Eigenpunkte -40, Gesamt 0).
expect((float) $overview['totals']['total_points'])->toBe(100.0)
->and((float) $overview['storno_floor_points'])->toBe(40.0)
->and($details['summary']['total_points'])->toBe(100.0)
->and($details['summary']['storno_floor_points'])->toBe(40.0)
->and($details['rows'])->toHaveCount(2)
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['own_points'])->toBe(-40.0)
->and(collect($details['rows'])->firstWhere('user_id', $minus->id)['total_points'])->toBe(0.0);
});
it('behaelt die Schwelle der 1000-Punkte-Liste', function () {

View file

@ -16,6 +16,8 @@ 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');
@ -160,11 +162,32 @@ beforeEach(function () {
$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();