hasColumn('products', 'slug')) { Schema::connection('sqlite')->table('products', function ($table) { $table->string('slug')->nullable(); }); } }); function boMakeUser(?int $sponsorId = null, ?string $activeDate = null, ?string $paymentAccount = null): User { return User::forceCreate([ 'email' => 'bo-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'm_sponsor' => $sponsorId, // 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(), ]); } function boMakeAbo(int $memberId, string $isFor, string $startDate, int $points = 0): UserAbo { $abo = UserAbo::create([ 'user_id' => $memberId, 'member_id' => $memberId, 'shopping_user_id' => 0, 'email' => 'a-'.uniqid('', true).'@example.com', 'is_for' => $isFor, 'payone_userid' => 0, 'clearingtype' => 'cc', 'amount' => 5000, 'active' => true, 'status' => 2, 'abo_interval' => 5, 'start_date' => $startDate, 'last_date' => $startDate, 'next_date' => '2026-12-10', ]); if ($points > 0) { $product = Product::forceCreate([ 'name' => 'P-'.uniqid('', true), 'title' => 'Test', 'price' => 10.0, 'tax' => 19.0, 'active' => true, 'pos' => 1, 'weight' => 100, 'points' => $points, 'slug' => 'p-'.uniqid('', true), ]); UserAboItem::create([ 'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1, ]); } return $abo->fresh(); } function boMakeSalesVolume(int $userId, int $month, int $year, float $points, int $status): UserSalesVolume { return UserSalesVolume::forceCreate([ 'user_id' => $userId, 'month' => $month, 'year' => $year, 'points' => $points, 'status' => $status, ]); } it('gruppiert Teamkundenabos pro Teampartner mit Punkten je Abo und gesamt', function (): void { $root = boMakeUser(); $anna = boMakeUser($root->id); $lena = boMakeUser($root->id); // Anna: 2 Kundenabos (20 + 30 Punkte), Lena: 1 Kundenabo (15 Punkte) boMakeAbo($anna->id, 'ot', '2026-06-10', 20); boMakeAbo($anna->id, 'ot', '2026-05-10', 30); boMakeAbo($lena->id, 'ot', '2026-06-12', 15); $details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 6, 2026); expect($details['rows'])->toHaveCount(2); // Nach Gesamtpunkten absteigend: Anna (50) vor Lena (15) $first = $details['rows'][0]; expect($first['type'])->toBe('customer_abo_group') ->and($first['consultant_id'])->toBe($anna->id) ->and($first['abo_count'])->toBe(2) ->and($first['points'])->toBe(50.0) ->and($first['abos'])->toHaveCount(2); expect($details['summary']['abo_count'])->toBe(3) ->and($details['summary']['points'])->toBe(65.0); }); it('markiert im Teamkundenabo-Drilldown die im Monat neuen Abos', function (): void { $root = boMakeUser(); $anna = boMakeUser($root->id); boMakeAbo($anna->id, 'ot', '2026-06-10', 20); // neu im Juni boMakeAbo($anna->id, 'ot', '2026-05-10', 30); // nicht neu $details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_customer_abos', 6, 2026); $abos = collect($details['rows'][0]['abos']); expect($abos->where('is_new_this_month', true))->toHaveCount(1) ->and($abos->where('is_new_this_month', false))->toHaveCount(1); }); it('zaehlt Neupartner nur fuer den gewaehlten Monat mit aktivem Account', function (): void { $root = boMakeUser(); $newInJune = boMakeUser($root->id, '2026-06-15'); $newInMay = boMakeUser($root->id, '2026-05-15'); // Account schon vor dem Monatsende abgelaufen -> zaehlt fuer Juni nicht $expiredAccount = boMakeUser($root->id, '2026-06-20', '2026-06-25'); $service = app(BackofficeDashboardService::class); $teamIds = $service->teamUserIds($root->id); expect($service->newPartnersCount($teamIds, 6, 2026))->toBe(1) ->and($service->newPartnersCount($teamIds, 5, 2026))->toBe(1); }); it('haelt die Neupartnerzahl vergangener Monate stabil, wenn Accounts spaeter ablaufen', function (): void { $root = boMakeUser(); // Im Juni aktiv geworden, Jahresgebuehr erst danach ausgelaufen: die Juni-Zahl // darf dadurch nicht nachtraeglich sinken. boMakeUser($root->id, '2026-06-15', now()->subDay()); $service = app(BackofficeDashboardService::class); $teamIds = $service->teamUserIds($root->id); expect($service->newPartnersCount($teamIds, 6, 2026))->toBe(1) // Im laufenden Monat gilt weiterhin "Account heute gueltig". ->and($service->newPartnersCount($teamIds, (int) now()->month, (int) now()->year))->toBe(0); }); it('rendert die kombinierte Monatsstatistik-Card mit Abo- und Neupartner-Kennzahlen', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); $html = view('dashboard._statistics', [ 'monthlyStats' => [ 'month' => 6, 'year' => 2026, 'customer_points' => 1200.0, 'team_payline_points' => 3400.0, 'payline_is_live' => true, 'direct_new_partners' => 2, 'team_total_abos' => 5, 'team_partner_abos' => 3, 'team_partner_abos_new' => 1, 'team_customer_abos' => 2, 'team_customer_abos_new' => 1, 'shop_1000' => 1, 'new_partners' => 4, 'own_abos' => 1, 'own_abos_new' => 0, 'own_customer_abos' => 2, 'own_customer_abos_new' => 1, ], ])->render(); expect($html) ->toContain(__('home.monthly_statistics')) ->toContain(__('home.team_customer_subscriptions')) ->toContain(__('abo.shop_1000')) ->toContain(__('home.team_new_partners')) ->toContain('user_abos_team_show') ->toContain('+1'); // "+neu"-Badge im gewählten Monat }); it('zeigt im Neupartner-Drilldown Sponsor und tagesaktuelle Kundenpunkte', function (): void { $root = boMakeUser(); $sponsor = boMakeUser($root->id); $partner = boMakeUser($sponsor->id, '2026-06-15'); boMakeSalesVolume($partner->id, 6, 2026, 250, 2); // Kundenpunkte (Shop) boMakeSalesVolume($partner->id, 6, 2026, 100, 1); // Eigenpunkte zaehlen nicht als Kundenpunkte $details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026); expect($details['rows'])->toHaveCount(1) ->and($details['rows'][0]['sponsor'])->toBe($sponsor->email) ->and($details['rows'][0]['customer_points'])->toBe(250.0) ->and($details['summary']['customer_points'])->toBe(250.0); }); it('zaehlt den Betrachter selbst beim 1000-Punkte-Shop mit', function (): void { $root = boMakeUser(); $partner = boMakeUser($root->id); boMakeSalesVolume($root->id, 6, 2026, 1200, 2); boMakeSalesVolume($partner->id, 6, 2026, 1500, 2); $details = app(BackofficeDrilldownService::class)->details($root, 0, 'shop_1000', 6, 2026); expect($details['rows'])->toHaveCount(2); $selfRow = collect($details['rows'])->firstWhere('user_id', $root->id); expect($selfRow['name'])->toContain('(ich)'); $overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026); expect($overview['totals']['shop_1000'])->toBe(2) ->and($overview['lines'][1]['shop_1000'])->toBe(1); }); it('zaehlt den Berater selbst bei den Dashboard-KPIs zum 1000-Punkte-Shop mit', function (): void { $root = boMakeUser(); boMakeSalesVolume($root->id, 6, 2026, 1100, 2); // auch ganz ohne Team zaehlt der eigene 1000-Punkte-Shop $kpis = app(TeamAboOverviewService::class)->teamKpis($root, 6, 2026); expect($kpis['shop_1000'])->toBe(1); $partner = boMakeUser($root->id); boMakeSalesVolume($partner->id, 6, 2026, 1300, 2); app(BackofficeDashboardService::class)->flushLineBucketsMemo(); // Struktur hat sich im selben Prozess geändert $kpis = app(TeamAboOverviewService::class)->teamKpis($root->fresh(), 6, 2026); expect($kpis['shop_1000'])->toBe(2); }); it('filtert Abo-Drilldowns mit only_new auf die im Monat neuen Abos', function (): void { $root = boMakeUser(); $anna = boMakeUser($root->id); $lena = boMakeUser($root->id); boMakeAbo($anna->id, 'me', '2026-06-10', 20); // neu im Juni boMakeAbo($lena->id, 'me', '2026-05-10', 30); // Bestand $service = app(BackofficeDrilldownService::class); $all = $service->details($root, 0, 'team_partner_abos', 6, 2026); $onlyNew = $service->details($root, 0, 'team_partner_abos', 6, 2026, true); expect($all['rows'])->toHaveCount(2) ->and($all['only_new'])->toBeFalse() ->and($onlyNew['rows'])->toHaveCount(1) ->and($onlyNew['rows'][0]['user_id'])->toBe($anna->id) ->and($onlyNew['only_new'])->toBeTrue() ->and($onlyNew['metric_label'])->toContain('nur neue'); boMakeAbo($anna->id, 'ot', '2026-06-12', 10); // neues Kundenabo boMakeAbo($anna->id, 'ot', '2026-04-01', 10); // Bestand $onlyNewCustomers = $service->details($root, 0, 'team_customer_abos', 6, 2026, true); expect($onlyNewCustomers['rows'])->toHaveCount(1) ->and($onlyNewCustomers['rows'][0]['abo_count'])->toBe(1); }); it('liefert im Neupartner-Drilldown die Telefonnummer mit Mobil-Fallback', function (): void { // Kundenwunsch 2026-09-07: zum Nachfassen gehoert die Telefonnummer in die Liste. $root = boMakeUser(); $mitFestnetz = boMakeUser($root->id, '2026-06-05'); $mitFestnetz->forceFill(['account_id' => UserAccount::forceCreate([ 'first_name' => 'Fritz', 'last_name' => 'Festnetz', 'phone' => '0301234', 'mobil' => '01700000', ])->id])->save(); $nurMobil = boMakeUser($root->id, '2026-06-06'); $nurMobil->forceFill(['account_id' => UserAccount::forceCreate([ 'first_name' => 'Mona', 'last_name' => 'Mobil', 'phone' => '', 'mobil' => '01711111', ])->id])->save(); $ohne = boMakeUser($root->id, '2026-06-07'); $rows = collect(app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026)['rows']) ->keyBy('user_id'); expect($rows->get($mitFestnetz->id)['phone'])->toBe('0301234') ->and($rows->get($nurMobil->id)['phone'])->toBe('01711111') ->and($rows->get($ohne->id)['phone'])->toBe(''); }); it('liefert Sortierwerte als ISO-Datum, unabhaengig von der Anzeigesprache (A14)', function (string $locale): void { app()->setLocale($locale); $root = boMakeUser(); $anna = boMakeUser($root->id, '2026-03-15'); boMakeAbo($anna->id, 'me', '2026-06-10', 20); $service = app(BackofficeDrilldownService::class); $abo = $service->details($root, 0, 'team_partner_abos', 6, 2026)['rows'][0]; expect($abo['start_date_sort'])->toBe('2026-06-10') ->and($abo['next_date_sort'])->toBe('2026-12-10'); $consultant = collect($service->details($root, 0, 'consultants', 6, 2026)['rows']) ->firstWhere('user_id', $anna->id); expect($consultant['active_date_sort'])->toBe('2026-03-15'); })->with(['de', 'en']); it('haengt die Sortierwerte an die Zellen der Drilldown-Tabelle (A14)', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); $root = boMakeUser(); $anna = boMakeUser($root->id, '2026-03-15'); boMakeAbo($anna->id, 'me', '2026-06-10', 20); $this->actingAs($root); $details = app(BackofficeDrilldownService::class)->details($root, 0, 'team_partner_abos', 6, 2026); $html = view('user.backoffice.statistics.details', [ 'details' => $details, 'selectedMonth' => 6, 'selectedYear' => 2026, ])->render(); expect($html) ->toContain('data-sort-value="2026-06-10"') ->toContain('data-sort-value="2026-12-10"') ->toContain('data-sort-value="20"'); }); it('rendert die Uebersicht mit dem Neu-Badge samt Baum-Parameter (Regression: Closure ohne $selectedTree)', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name.'?'.http_build_query($parameters)); $root = boMakeUser(); $anna = boMakeUser($root->id, '2026-03-15'); boMakeAbo($anna->id, 'me', '2026-06-10', 20); $this->actingAs($root); $statistics = app(BackofficeDashboardService::class)->overview($root, 6, 2026); expect($statistics['totals']['team_partner_abos_new'])->toBe(1); // Das gruene "+1"-Badge wird nur gerendert, wenn es neue Abos gibt — erst dann // laeuft die Link-Closure, die auf Produktion mit "Undefined variable // $selectedTree" abbrach (14.09.2026). $html = view('user.backoffice.statistics.index', [ 'selectedMonth' => 6, 'selectedYear' => 2026, 'selectedTree' => BackofficeDashboardService::TREE_COMMISSION, 'treeLabels' => BackofficeDashboardService::treeLabels(), 'filterMonths' => HTMLHelper::getTransMonths(), 'filterYears' => HTMLHelper::getYearRange(2022), 'statistics' => $statistics, 'performance' => ['duration_ms' => 1.0, 'source' => 'live', 'source_label' => 'Live', 'calculated_at' => null], 'commission' => null, ])->render(); expect($html) ->toContain('backoffice-statistics-click-badge') ->toContain('only_new=1') ->toContain('tree=commission'); });