'sc-'.uniqid('', true).'@example.test', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'm_sponsor' => $sponsor, 'active_date' => '2026-01-01', 'partner_paid_at' => '2026-01-01', 'payment_account' => '2027-01-01', ]); } function scVolume(User $user, float $points, int $status = 1, array $extra = []): UserSalesVolume { return UserSalesVolume::forceCreate(array_merge([ 'user_id' => $user->id, 'month' => 9, 'year' => 2026, 'date' => '2026-09-05', 'points' => $points, 'total_net' => $points, 'status' => $status, 'status_points' => 1, 'status_turnover' => 1, ], $extra)); } /** * Ruft eine private Methode des Controllers auf — die Exportzuordnung ist bewusst * nicht oeffentlich, muss aber gegen die Ueberschriften pruefbar bleiben. */ function scCsv(string $method, mixed ...$args): array { $controller = app(BackofficeStatisticsController::class); return (new ReflectionMethod($controller, $method))->invoke($controller, ...$args); } // ---------------------------------------------------------------- A09 it('haelt in jedem CSV-Export Kopfzeile und Summenzeile gleich breit', function (string $metric) { $headers = scCsv('csvHeaders', $metric); $summary = scCsv('csvSummaryRow', [ 'metric' => $metric, 'summary' => [ 'count' => 1, 'customer_points' => 100, 'total_points' => 300, 'own_points' => 10, 'external_points' => 20, 'customer_abo_points' => 30, 'customer_single_order_points' => 40, 'customer_other_points' => 50, 'abo_count' => 2, 'points' => 60, 'deliveries' => 3, ], ]); expect($summary)->toHaveCount(count($headers)); })->with(['new_partners', 'team_customer_abos', 'team_partner_abos', 'total_points', 'active_partners']); it('stellt die Neupartner-Summen unter die richtigen Ueberschriften', function () { $headers = scCsv('csvHeaders', 'new_partners'); $summary = scCsv('csvSummaryRow', [ 'metric' => 'new_partners', 'summary' => ['count' => 1, 'customer_points' => 100, 'total_points' => 300], ]); $byHeader = array_combine($headers, $summary); expect($byHeader['Punkte seiner Kunden'])->toBe(100) ->and($byHeader['Gesamt inkl. Eigenbestellung'])->toBe(300) ->and($byHeader['Sponsor'])->toBe(''); }); // ---------------------------------------------------------------- A02 it('zaehlt in der persoenlichen Statistik nur freigegebene Buchungen', function () { $user = scUser(); scVolume($user, 100, 1); scVolume($user, 50, 3); // pending scVolume($user, 25, 0); // nicht zugeordnet $personal = app(PersonalStatisticsService::class)->monthly($user, 9, 2026); $canonical = SalesVolumePoints::totalPointsByUser([$user->id], 9, 2026)->get($user->id); expect($personal['totals']['total_points'])->toBe(100.0) ->and($personal['totals']['total_points'])->toBe($canonical); }); it('bucht einen Storno im persoenlichen Segment seiner Ursprungsbuchung', function () { $user = scUser(); scVolume($user, 100, 1); scVolume($user, -100, 6, ['storno_origin_status' => 1]); $groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups']; expect($groups['own']['points'])->toBe(0.0) ->and($groups['other']['points'])->toBe(0.0); }); it('laesst Altstornos ohne Ursprungsart unter Sonstige', function () { $user = scUser(); scVolume($user, 100, 1); scVolume($user, -100, 6); $groups = app(PersonalStatisticsService::class)->monthly($user, 9, 2026)['groups']; expect($groups['own']['points'])->toBe(100.0) ->and($groups['other']['points'])->toBe(-100.0); }); // ---------------------------------------------------------------- A03 it('zeigt negative Monatssalden im Drilldown und bleibt mit der Uebersicht gleich', function () { $root = scUser(); $plus = scUser($root->id); $minus = scUser($root->id); scVolume($plus, 100, 1); scVolume($minus, -40, 1); $overview = app(BackofficeDashboardService::class)->overview($root, 9, 2026); $details = app(BackofficeDrilldownService::class)->details($root, 0, 'total_points', 9, 2026); // 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 () { $root = scUser(); $stark = scUser($root->id); $schwach = scUser($root->id); scVolume($stark, 1200, 1); scVolume($schwach, -40, 1); $details = app(BackofficeDrilldownService::class)->details($root, 0, 'shop_1000', 9, 2026); expect($details['rows'])->toHaveCount(1) ->and($details['rows'][0]['user_id'])->toBe($stark->id); }); // ---------------------------------------------------------------- A18 it('kennzeichnet Formelinhalte im Export als Text', function (string $value) { $safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow')) ->invoke(null, [$value]); expect($safe[0])->toBe("'".$value); })->with(['=1+1', '+49 170 1234567', '-SUM(A1)', '@import']); it('laesst unverfaengliche Werte im Export unveraendert', function () { $safe = (new ReflectionMethod(BackofficeStatisticsController::class, 'csvSafeRow')) ->invoke(null, ['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]); expect($safe)->toBe(['Anna Muster', 'anna@example.test', 1234.5, 0, '', null]); }); // ---------------------------------------------------------------- A10 it('laedt die Lieferungszahl der Kundenabo-Liste vor', function () { $user = scUser(); for ($i = 0; $i < 5; $i++) { $abo = UserAbo::create([ 'user_id' => $user->id, 'member_id' => $user->id, 'shopping_user_id' => 0, 'email' => 'a-'.uniqid('', true).'@example.test', 'is_for' => 'ot', 'payone_userid' => 0, 'clearingtype' => 'cc', 'amount' => 5000, 'active' => true, 'status' => 2, 'abo_interval' => 5, 'start_date' => '2026-08-01', 'next_date' => '2026-10-01', ]); UserAboOrder::create([ 'user_abo_id' => $abo->id, 'shopping_order_id' => 1, 'status' => 2, 'paid' => true, ]); } $relations = (new ReflectionClass(AboController::class))->getConstant('CUSTOMER_LIST_RELATIONS'); $abos = UserAbo::with($relations)->where('member_id', $user->id)->where('is_for', 'ot')->get(); DB::enableQueryLog(); DB::flushQueryLog(); foreach ($abos as $abo) { $abo->getCountPaidOrders(); } $queries = count(DB::getQueryLog()); DB::disableQueryLog(); expect($abos)->toHaveCount(5) ->and($queries)->toBe(0); }); // ---------------------------------------------------------------- A17 it('zaehlt am Monatsende nicht in die falsche Faelligkeitsgruppe', function () { $service = app(AnnualFeeDueService::class); $key = fn (string $due, string $now) => (new ReflectionMethod($service, 'groupKeyFor')) ->invoke($service, Carbon::parse($due), Carbon::parse($now)); // Vom 31.01. aus endet "innerhalb eines Monats" am 28.02., nicht am 03.03. expect($key('2026-03-02', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS) ->and($key('2026-02-28', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_1_MONTH) ->and($key('2026-03-31', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_2_MONTHS) ->and($key('2026-04-01', '2026-01-31'))->toBe(AnnualFeeDueService::GROUP_DUE_3_MONTHS); });