hasColumn('products', 'slug')) { Schema::connection('sqlite')->table('products', function ($table) { $table->string('slug')->nullable(); }); } }); afterEach(function () { Carbon::setTestNow(); }); function pflMakeUser(?int $sponsorId = null): User { return User::forceCreate([ 'email' => 'pfl-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'm_sponsor' => $sponsorId, 'payment_account' => now()->addYear(), ]); } function pflMakeAbo(int $userId, string $isFor, string $nextDate, int $points, int $status = 2): UserAbo { $abo = UserAbo::create([ 'user_id' => $userId, 'member_id' => $userId, 'shopping_user_id' => 0, 'email' => 'pfl-abo-'.uniqid('', true).'@example.com', 'is_for' => $isFor, 'payone_userid' => 0, 'clearingtype' => 'cc', 'amount' => 5000, 'active' => $status === 2, 'status' => $status, 'abo_interval' => (int) Carbon::parse($nextDate)->day, 'start_date' => '2026-01-05', 'last_date' => '2026-06-05', 'next_date' => $nextDate, ]); $product = Product::forceCreate([ 'name' => 'PFL-'.uniqid('', true), 'title' => 'Test', 'price' => 10.0, 'tax' => 19.0, 'active' => true, 'pos' => 1, 'weight' => 100, 'points' => $points, 'slug' => 'pfl-'.uniqid('', true), ]); UserAboItem::create([ 'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1, ]); return $abo->fresh(); } function pflSeedContext(User $user, array $memberIds, int $paylines = 2): void { Cache::put( "payline_context_{$user->id}_".now()->month.'_'.now()->year, ['paylines' => $paylines, 'member_ids' => $memberIds], now()->addHour() ); } it('berechnet die Prognose kumuliert je Ausfuehrungstermin inklusive eigener Abos', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); $fremd = pflMakeUser(); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'ot', '2026-07-05', 20); // Payline-Kundenabo zum 5. pflMakeAbo($root->id, 'me', '2026-07-10', 30); // eigenes Abo (F12: zaehlt mit) pflMakeAbo($anna->id, 'me', '2026-07-25', 10); // Payline-Beraterabo zum 25. pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline pflMakeAbo($anna->id, 'ot', '2026-08-05', 50); // naechster Monat, zaehlt nicht pflMakeAbo($anna->id, 'ot', '2026-07-20', 40, 3); // angehalten, zaehlt nicht $forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0); expect($forecast['base'])->toBe(100.0) ->and($forecast['days'])->toHaveCount(4) ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0, 'total' => 150.0]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 50.0, 'total' => 150.0]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 60.0, 'total' => 160.0]); }); it('markiert vergangene Termine und zaehlt nur noch offene Ausfuehrungen', function (): void { Carbon::setTestNow(Carbon::parse('2026-07-22')); $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'ot', '2026-07-25', 20); // noch offen pflMakeAbo($anna->id, 'me', '2026-08-05', 30); // bereits fortgeschrieben (naechster Monat) $forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0); expect($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'is_past' => true]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 0.0, 'is_past' => true]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]); }); it('liefert Soll und Ist des Payline-Abo-Umsatzes', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); $fremd = pflMakeUser(); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'me', '2026-07-10', 20); // Soll 20 pflMakeAbo($anna->id, 'ot', '2026-07-25', 30); // Soll 30 pflMakeAbo($anna->id, 'ot', '2026-07-25', 40, 4); // gekuendigt, kein Soll pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline $aboOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $anna->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, ]); $shopOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $anna->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => false, 'paid' => true, ]); UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 25, 'status' => 2]); UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $shopOrder->id, 'month' => 7, 'year' => 2026, 'points' => 50, 'status' => 2]); // kein Abo UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 6, 'year' => 2026, 'points' => 60, 'status' => 2]); // anderer Monat $turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026); expect($turnover['paylines'])->toBe(2) ->and($turnover['members'])->toBe(1) ->and($turnover['target'])->toBe(50.0) ->and($turnover['actual'])->toBe(25.0) ->and($turnover['team_abo_count'])->toBe(1) // nur Annas Eigenabo, nicht der Fremde ->and($turnover['team_customer_abo_count'])->toBe(1); // gekuendigtes Kundenabo zaehlt nicht }); it('liefert leere Kennzahlen ohne Payline, Prognose enthaelt aber eigene Abos', function (): void { $root = pflMakeUser(); pflSeedContext($root, [], 0); pflMakeAbo($root->id, 'me', '2026-07-10', 30); $turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026); $forecast = app(PaylineForecastService::class)->forecastDays($root, 10.0); expect($turnover['target'])->toBe(0.0) ->and($turnover['actual'])->toBe(0.0) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 40.0]); }); it('ermittelt den Payline-Kontext aus dem UserBusiness-Level und den Linien', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); // Linie 1 $ben = pflMakeUser($anna->id); // Linie 2 UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'qual_user_level' => ['paylines' => 1], ]); $context = app(PaylineForecastService::class)->paylineContext($root, 7, 2026); expect($context['paylines'])->toBe(1) ->and($context['member_ids'])->toBe([$anna->id]) ->and($context['member_ids'])->not->toContain($ben->id); }); it('rendert die Payline-Prognose auf der Monatsstatistik-Card', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); $html = view('dashboard._statistics', [ 'monthlyStats' => [ 'month' => 7, 'year' => 2026, 'customer_points' => 100.0, 'team_payline_points' => 400.0, 'payline_is_live' => true, 'payline_forecast' => [ 'base' => 500.0, 'days' => [ ['day' => 5, 'date' => '05.07.2026', 'upcoming' => 0.0, 'total' => 500.0, 'is_past' => true], ['day' => 10, 'date' => '10.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false], ['day' => 20, 'date' => '20.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false], ['day' => 25, 'date' => '25.07.2026', 'upcoming' => 60.0, 'total' => 560.0, 'is_past' => false], ], ], 'direct_new_partners' => 0, 'team_total_abos' => 0, 'team_partner_abos' => 0, 'team_partner_abos_new' => 0, 'team_customer_abos' => 0, 'team_customer_abos_new' => 0, 'shop_1000' => 0, 'new_partners' => 0, 'own_abos' => 0, 'own_abos_new' => 0, 'own_customer_abos' => 0, 'own_customer_abos_new' => 0, ], ])->render(); expect($html) ->toContain(e(__('home.payline_forecast_title'))) ->toContain(__('home.forecast_day', ['date' => '25.07.2026'])) ->toContain('560') ->toContain(__('home.forecast_done')); }); it('zaehlt neue Abos der Payline-Mitglieder im Monat', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); $fremd = pflMakeUser(); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'me', '2026-07-10', 20)->update(['start_date' => '2026-07-03']); // neu im Juli pflMakeAbo($anna->id, 'ot', '2026-07-25', 30)->update(['start_date' => '2026-07-05']); // neu im Juli pflMakeAbo($anna->id, 'ot', '2026-07-25', 40)->update(['start_date' => '2026-06-10']); // Bestand, nicht neu pflMakeAbo($fremd->id, 'me', '2026-07-10', 99)->update(['start_date' => '2026-07-04']); // ausserhalb Payline expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2); }); it('prognostiziert Level und Provision zum letzten Liefertag', function (): void { // Test-Schema hinkt der Produktions-Tabelle hinterher (bekannte Stolperfalle) foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) { if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) { Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) { $type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable(); }); } } $root = pflMakeUser(); $anna = pflMakeUser($root->id); // Ebene 1 pflSeedContext($root, [$anna->id]); $levelBase = [ 'qual_kp' => 0, 'growth_bonus' => 0, 'pr_line_1' => 7, 'pr_line_2' => 5, 'pr_line_3' => 0, 'pr_line_4' => 0, 'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0, ]; UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'payline_points' => 800.0, 'payline_points_qual_kp' => 800.0, 'sales_volume_points_KP_sum' => 500.0, 'commission_pp_total' => 0, 'commission_shop_sales' => 0, 'commission_growth_total' => 0, 'qual_user_level' => array_merge($levelBase, ['name' => 'Gold', 'paylines' => 2, 'qual_pp' => 500]), 'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 2000, 'qual_kp' => 600]), ]); // Heutiger TP-Laufstand von Anna (Ebene 1): 800 Punkte UserSalesVolume::forceCreate([ 'user_id' => $anna->id, 'month' => 7, 'year' => 2026, 'points' => 800, 'status' => 1, 'month_TP_points' => 800, 'month_shop_points' => 0, ]); // Offene Ausfuehrungen bis zum 25.: Anna +100 (Ebene 1), eigenes Kundenabo +50 (persoenlich) pflMakeAbo($anna->id, 'ot', '2026-07-20', 100); pflMakeAbo($root->id, 'ot', '2026-07-10', 50); $forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026); // Persoenlich: 500 + 50 = 550 -> zu Diamant (600) fehlen 50 // Payline: Ebene 1 = 800 + 100 = 900, kein KP-Ueberschuss -> zu 2000 fehlen 1100 // Provision (Level Gold, da nicht erreicht): 900 x 7 % = 63 expect($forecast)->not->toBeNull() ->and($forecast['next_name'])->toBe('Diamant') ->and($forecast['reached'])->toBeFalse() ->and($forecast['missing_pp'])->toBe(1100.0) ->and($forecast['missing_personal'])->toBe(50.0) ->and($forecast['personal_forecast'])->toBe(550.0) ->and($forecast['estimated_level_name'])->toBe('Gold') ->and($forecast['estimated_commission'])->toBe(63.0); });