Carbon::setTestNow()); /** * @param array $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()); });