From 53bdba33cd55128d47a48c0665c651b9125e71e6 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 18 May 2026 17:23:28 +0200 Subject: [PATCH] User Statistik --- .../BackofficeStoreStatisticsSnapshots.php | 127 ++++++ app/Console/Kernel.php | 3 + .../User/BackofficeStatisticsController.php | 279 ++++++++++++ .../Controllers/Web/CheckoutController.php | 6 + app/Models/BackofficeStatisticsSnapshot.php | 29 ++ app/Models/ShoppingOrder.php | 25 ++ app/Repositories/CheckoutRepository.php | 6 + .../Backoffice/BackofficeDashboardService.php | 365 ++++++++++++++++ .../Backoffice/BackofficeDrilldownService.php | 331 ++++++++++++++ ..._backoffice_statistics_snapshots_table.php | 40 ++ ..._order_source_to_shopping_orders_table.php | 29 ++ .../ENTWICKLUNGSKONZEPT-BACKOFFICE.md | 80 +++- resources/lang/de/navigation.php | 1 + resources/lang/en/navigation.php | 1 + resources/lang/es/navigation.php | 1 + resources/lang/fr/navigation.php | 1 + resources/views/admin/sales/_detail.blade.php | 9 + .../layouts/includes/layout-sidenav.blade.php | 11 + .../backoffice/statistics/details.blade.php | 279 ++++++++++++ .../backoffice/statistics/index.blade.php | 237 ++++++++++ .../views/web/templates/checkout.blade.php | 25 ++ routes/domains/crm.php | 6 + .../BackofficeStatisticsAccessTest.php | 342 +++++++++++++++ .../BackofficeDashboardServiceTest.php | 409 ++++++++++++++++++ 24 files changed, 2633 insertions(+), 9 deletions(-) create mode 100644 app/Console/Commands/BackofficeStoreStatisticsSnapshots.php create mode 100644 app/Http/Controllers/User/BackofficeStatisticsController.php create mode 100644 app/Models/BackofficeStatisticsSnapshot.php create mode 100644 app/Services/Backoffice/BackofficeDashboardService.php create mode 100644 app/Services/Backoffice/BackofficeDrilldownService.php create mode 100644 database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php create mode 100644 database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php create mode 100644 resources/views/user/backoffice/statistics/details.blade.php create mode 100644 resources/views/user/backoffice/statistics/index.blade.php create mode 100644 tests/Feature/BackofficeStatisticsAccessTest.php create mode 100644 tests/Unit/Services/BackofficeDashboardServiceTest.php diff --git a/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php b/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php new file mode 100644 index 0000000..4110d18 --- /dev/null +++ b/app/Console/Commands/BackofficeStoreStatisticsSnapshots.php @@ -0,0 +1,127 @@ +monthsToStore(); + $force = (bool) $this->option('force'); + + if ($months === []) { + $this->info('Keine abgeschlossenen Monate zum Speichern.'); + + return self::SUCCESS; + } + + $userQuery = User::query() + ->where('admin', '>=', 1) + ->where('admin', '<', 4) + ->whereNull('deleted_at'); + + if ($userId = $this->option('user')) { + $userQuery->where('id', $userId); + } + + $users = $userQuery->get(); + $this->info('Berechne Backoffice-Snapshots fuer '.$users->count().' User und '.count($months).' Monate...'); + + $bar = $this->output->createProgressBar($users->count()); + $bar->start(); + + $stored = 0; + $skipped = 0; + + foreach ($users as $user) { + foreach ($months as [$year, $month]) { + $exists = BackofficeStatisticsSnapshot::query() + ->where('user_id', $user->id) + ->where('year', $year) + ->where('month', $month) + ->exists(); + + if ($exists && ! $force) { + $skipped++; + + continue; + } + + $dashboardService->storeSnapshot($user, $month, $year); + $stored++; + } + + $bar->advance(); + gc_collect_cycles(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Fertig. Gespeichert: {$stored}, uebersprungen: {$skipped}"); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function monthsToStore(): array + { + $monthOption = $this->option('month'); + $yearOption = $this->option('year'); + + if ($monthOption && $yearOption) { + $month = max(1, min(12, (int) $monthOption)); + $year = (int) $yearOption; + + if (! $this->isClosedMonth($month, $year)) { + return []; + } + + return [[$year, $month]]; + } + + $months = []; + $cursor = Carbon::create(2026, 1, 1)->startOfMonth(); + $lastClosedMonth = now()->startOfMonth()->subMonth(); + + while ($cursor->lte($lastClosedMonth)) { + $months[] = [(int) $cursor->year, (int) $cursor->month]; + $cursor->addMonth(); + } + + return $months; + } + + private function isClosedMonth(int $month, int $year): bool + { + return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth()); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 113f653..6da640b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -55,6 +55,9 @@ class Kernel extends ConsoleKernel // Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs) $schedule->command('abo:store-chart-snapshots')->dailyAt('04:30'); + // Backoffice-Statistik-Snapshots: abgeschlossene Monate fuer VIP-Statistiken einfrieren + $schedule->command('backoffice:store-statistics-snapshots')->dailyAt('04:45'); + // Incentive: Punkteberechnung täglich nach business:store-optimized $schedule->command('incentive:calculate')->dailyAt('05:00'); diff --git a/app/Http/Controllers/User/BackofficeStatisticsController.php b/app/Http/Controllers/User/BackofficeStatisticsController.php new file mode 100644 index 0000000..9524441 --- /dev/null +++ b/app/Http/Controllers/User/BackofficeStatisticsController.php @@ -0,0 +1,279 @@ +middleware('active.account'); + } + + public function index(Request $request): View + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $startTime = microtime(true); + $statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear); + $performance = [ + 'duration_ms' => round((microtime(true) - $startTime) * 1000, 2), + 'source_label' => $statistics['_meta']['source_label'] ?? 'Live', + 'calculated_at' => $statistics['_meta']['calculated_at'] ?? null, + ]; + + return view('user.backoffice.statistics.index', [ + 'selectedMonth' => $selectedMonth, + 'selectedYear' => $selectedYear, + 'filterMonths' => HTMLHelper::getTransMonths(), + 'filterYears' => HTMLHelper::getYearRange(2022), + 'statistics' => $statistics, + 'performance' => $performance, + ]); + } + + public function details(Request $request): View + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $line = (int) $request->get('line', 1); + $metric = (string) $request->get('metric', 'consultants'); + + return view('user.backoffice.statistics.details', [ + 'selectedMonth' => $selectedMonth, + 'selectedYear' => $selectedYear, + 'details' => $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear), + ]); + } + + public function export(Request $request): StreamedResponse + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $line = (int) $request->get('line', 1); + $metric = (string) $request->get('metric', 'consultants'); + $details = $this->drilldownService->details($request->user(), $line, $metric, $selectedMonth, $selectedYear); + $filename = sprintf( + 'backoffice-statistik-%s-linie-%s-%02d-%d.csv', + $metric, + $line === 0 ? 'alle' : $line, + $selectedMonth, + $selectedYear + ); + + return response()->streamDownload(function () use ($details): void { + $output = fopen('php://output', 'w'); + + fwrite($output, "\xEF\xBB\xBF"); + fputcsv($output, $this->csvHeaders($details['metric']), ';'); + + foreach ($details['rows'] as $row) { + fputcsv($output, $this->csvRow($details['metric'], $row), ';'); + } + + fputcsv($output, []); + fputcsv($output, $this->csvSummaryRow($details), ';'); + + fclose($output); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + public function overviewExport(Request $request): StreamedResponse + { + if (! $request->user()?->isVIP()) { + abort(404); + } + + [$selectedMonth, $selectedYear] = $this->selectedPeriod($request); + $statistics = $this->dashboardService->overview($request->user(), $selectedMonth, $selectedYear); + $filename = sprintf('backoffice-statistik-uebersicht-%02d-%d.csv', $selectedMonth, $selectedYear); + + return response()->streamDownload(function () use ($statistics): void { + $output = fopen('php://output', 'w'); + + fwrite($output, "\xEF\xBB\xBF"); + fputcsv($output, $this->overviewCsvHeaders(), ';'); + + foreach ($statistics['lines'] as $line) { + fputcsv($output, $this->overviewCsvRow($line), ';'); + } + + fputcsv($output, []); + fputcsv($output, $this->overviewCsvRow($statistics['totals']), ';'); + + fclose($output); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + ]); + } + + /** + * @return array{0: int, 1: int} + */ + private function selectedPeriod(Request $request): array + { + $selectedMonth = max(1, min(12, (int) $request->get('month', session(self::SESSION_MONTH_KEY, now()->month)))); + $selectedYear = (int) $request->get('year', session(self::SESSION_YEAR_KEY, now()->year)); + + session([ + self::SESSION_MONTH_KEY => $selectedMonth, + self::SESSION_YEAR_KEY => $selectedYear, + ]); + + return [$selectedMonth, $selectedYear]; + } + + /** + * @return string[] + */ + private function overviewCsvHeaders(): array + { + return [ + 'Linie', + 'Berater', + 'Neupartner', + 'Teamabos', + 'Neue Teamabos', + 'Teamkundenabos', + 'Neue Teamkundenabos', + 'Eigenpunkte', + 'Externe Punkte', + 'Kundenabo-Punkte', + 'Einzelbestellungs-Punkte', + 'Sonstige Kundenpunkte', + 'Gesamtpunkte', + '1000 Punkte Shop', + 'Umsatz Netto', + ]; + } + + /** + * @param array $row + * @return array + */ + private function overviewCsvRow(array $row): array + { + return [ + $row['label'] ?? '', + $row['consultants'] ?? 0, + $row['new_partners'] ?? 0, + $row['team_partner_abos'] ?? 0, + $row['team_partner_abos_new'] ?? 0, + $row['team_customer_abos'] ?? 0, + $row['team_customer_abos_new'] ?? 0, + $row['own_points'] ?? 0, + $row['external_points'] ?? 0, + $row['customer_abo_points'] ?? 0, + $row['customer_single_order_points'] ?? 0, + $row['customer_other_points'] ?? 0, + $row['total_points'] ?? 0, + $row['shop_1000'] ?? 0, + $row['turnover_net'] ?? 0, + ]; + } + + /** + * @return string[] + */ + private function csvHeaders(string $metric): array + { + if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) { + return ['Name', 'E-Mail', 'Karriere-Level', 'Berater', 'Abo-Punkte', 'Status', 'Status-Grund', 'Besteht seit', 'Naechste Ausfuehrung', 'Lieferungen']; + } + + if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) { + return ['Name', 'E-Mail', 'Karriere-Level', 'Eigenpunkte', 'Externe Punkte', 'Kundenabo-Punkte', 'Einzelbestellungs-Punkte', 'Sonstige Kundenpunkte', 'Gesamtpunkte']; + } + + return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status']; + } + + /** + * @param array $row + * @return array + */ + private function csvRow(string $metric, array $row): array + { + if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) { + return [ + $row['name'] ?? '', + $row['email'] ?? '', + $row['career_level'] ?? '', + $row['consultant_name'] ?? $row['name'] ?? '', + $row['points'] ?? 0, + $row['status_label'] ?? '', + $row['status_reason'] ?? '', + $row['start_date'] ?? '', + $row['next_date'] ?? '', + $row['deliveries'] ?? 0, + ]; + } + + if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) { + return [ + $row['name'] ?? '', + $row['email'] ?? '', + $row['career_level'] ?? '', + $row['own_points'] ?? 0, + $row['external_points'] ?? 0, + $row['customer_abo_points'] ?? 0, + $row['customer_single_order_points'] ?? 0, + $row['customer_other_points'] ?? 0, + $row['total_points'] ?? 0, + ]; + } + + return [ + $row['name'] ?? '', + $row['email'] ?? '', + $row['career_level'] ?? '', + $row['active_date'] ?? '', + $row['payment_account'] ?? '', + $row['account_status'] ?? '', + ]; + } + + /** + * @param array $details + * @return array + */ + private function csvSummaryRow(array $details): array + { + $metric = $details['metric']; + $summary = $details['summary']; + + if (in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) { + return ['Summe', $summary['count'].' Eintraege', '', '', $summary['points'], '', '', '', '', $summary['deliveries']]; + } + + if (in_array($metric, ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) { + return ['Summe', $summary['count'].' Eintraege', '', $summary['own_points'], $summary['external_points'], $summary['customer_abo_points'], $summary['customer_single_order_points'], $summary['customer_other_points'], $summary['total_points']]; + } + + return ['Summe', $summary['count'].' Eintraege', '', '', '', '']; + } +} diff --git a/app/Http/Controllers/Web/CheckoutController.php b/app/Http/Controllers/Web/CheckoutController.php index adf9424..e9383ca 100644 --- a/app/Http/Controllers/Web/CheckoutController.php +++ b/app/Http/Controllers/Web/CheckoutController.php @@ -87,6 +87,7 @@ class CheckoutController extends Controller 'is_for' => $is_for, 'is_abo' => $is_abo, 'abo_interval' => $abo_interval, + 'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(), 'shopping_data' => $shopping_data, 'user_shop' => Util::getUserShop(), 'shopping_user' => $shopping_user, @@ -225,6 +226,11 @@ class CheckoutController extends Controller 'accepted_data_checkbox' => 'accepted', ]; + if (Request::get('is_from') === 'shopping') { + $rules['customer_order_source'] = 'required|in:'.implode(',', array_keys(ShoppingOrder::customerOrderSourceOptions())); + $rules['customer_order_source_comment'] = 'nullable|string|max:500'; + } + if (Request::get('same_as_billing')) { $rules = array_merge($rules, [ 'shipping_firstname' => 'required', diff --git a/app/Models/BackofficeStatisticsSnapshot.php b/app/Models/BackofficeStatisticsSnapshot.php new file mode 100644 index 0000000..d4df187 --- /dev/null +++ b/app/Models/BackofficeStatisticsSnapshot.php @@ -0,0 +1,29 @@ + 'int', + 'year' => 'int', + 'month' => 'int', + 'payload' => 'array', + 'calculated_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index 5b6d89b..fd60876 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -167,6 +167,8 @@ class ShoppingOrder extends Model 'api_notice', 'api_status', 'mode', + 'customer_order_source', + 'customer_order_source_comment', 'shipped', 'tracking', ]; @@ -180,6 +182,29 @@ class ShoppingOrder extends Model 'points' => 'float', ]; + public const CUSTOMER_ORDER_SOURCE_OPTIONS = [ + 'recommendation' => 'Empfehlung', + 'social_media' => 'Social Media', + 'search_engine' => 'Google / Suchmaschine', + 'event' => 'Event / Messe', + 'consultant_link' => 'Berater-Link', + 'returning_customer' => 'Wiederbesteller', + 'other' => 'Sonstiges', + ]; + + /** + * @return array + */ + public static function customerOrderSourceOptions(): array + { + return self::CUSTOMER_ORDER_SOURCE_OPTIONS; + } + + public function getCustomerOrderSourceLabel(): string + { + return self::CUSTOMER_ORDER_SOURCE_OPTIONS[$this->customer_order_source] ?? ''; + } + public static $shippedTypes = [ 0 => 'open', 1 => 'in_process', diff --git a/app/Repositories/CheckoutRepository.php b/app/Repositories/CheckoutRepository.php index 9823d56..b47699e 100644 --- a/app/Repositories/CheckoutRepository.php +++ b/app/Repositories/CheckoutRepository.php @@ -28,6 +28,7 @@ class CheckoutRepository extends BaseRepository public function makeShoppingOrder($shopping_user, $data) { + $requestData = $data; $user_shop = Util::getUserShop(); if ($shopping_user->is_from === 'homeparty') { @@ -103,6 +104,11 @@ class CheckoutRepository extends BaseRepository 'txaction' => 'prev', 'mode' => Util::getUserShoppingMode(), ]; + + if ($shopping_user->is_from === 'shopping') { + $data['customer_order_source'] = $requestData['customer_order_source'] ?? null; + $data['customer_order_source_comment'] = $requestData['customer_order_source_comment'] ?? null; + } } $shopping_order = false; diff --git a/app/Services/Backoffice/BackofficeDashboardService.php b/app/Services/Backoffice/BackofficeDashboardService.php new file mode 100644 index 0000000..2f2019f --- /dev/null +++ b/app/Services/Backoffice/BackofficeDashboardService.php @@ -0,0 +1,365 @@ + + */ + public function metricLabels(): array + { + return [ + 'consultants' => 'Berater', + 'new_partners' => 'Neupartner', + 'team_partner_abos' => 'Teamabos', + 'team_customer_abos' => 'Teamkundenabos', + 'own_points' => 'Eigenpunkte', + 'external_points' => 'Externe Kundenpunkte', + 'customer_abo_points' => 'Kundenabo-Punkte', + 'customer_single_order_points' => 'Einzelbestellungs-Punkte', + 'customer_other_points' => 'Sonstige Kundenpunkte', + 'total_points' => 'Gesamtpunkte', + 'shop_1000' => '1000 Punkte Shop', + ]; + } + + /** + * @return array{month: int, year: int, metric_labels: array, lines: array>, totals: array} + */ + public function overview(User $user, int $month, int $year): array + { + if ($this->isClosedMonth($month, $year)) { + $snapshot = BackofficeStatisticsSnapshot::query() + ->where('user_id', $user->id) + ->where('month', $month) + ->where('year', $year) + ->first(); + + if ($snapshot) { + return $this->withMeta($snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i')); + } + } + + return $this->withMeta($this->buildOverview($user, $month, $year), 'live'); + } + + public function storeSnapshot(User $user, int $month, int $year): BackofficeStatisticsSnapshot + { + $payload = $this->buildOverview($user, $month, $year); + + return BackofficeStatisticsSnapshot::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'year' => $year, + 'month' => $month, + ], + [ + 'payload' => $payload, + 'calculated_at' => now(), + ] + ); + } + + /** + * @return array{month: int, year: int, metric_labels: array, lines: array>, totals: array} + */ + private function buildOverview(User $user, int $month, int $year): array + { + $lineBuckets = $this->lineBuckets($user->id); + $lines = []; + $totals = $this->emptyLine(0, []); + + foreach ($lineBuckets as $line => $users) { + $row = $this->buildLineRow($line, $users, $month, $year); + $lines[$line] = $row; + $totals = $this->addToTotals($totals, $row); + } + + $totals['label'] = 'Summe'; + + return [ + 'month' => $month, + 'year' => $year, + 'metric_labels' => $this->metricLabels(), + 'lines' => $lines, + 'totals' => $totals, + ]; + } + + /** + * @param array $overview + * @return array + */ + private function withMeta(array $overview, string $source, ?string $calculatedAt = null): array + { + $overview['_meta'] = [ + 'source' => $source, + 'source_label' => $source === 'snapshot' ? 'Snapshot' : 'Live', + 'calculated_at' => $calculatedAt, + ]; + + return $overview; + } + + private function isClosedMonth(int $month, int $year): bool + { + return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth()); + } + + /** + * @return array> + */ + public function lineBuckets(int $rootUserId): array + { + $lineBuckets = []; + $currentSponsorIds = [$rootUserId]; + $visitedUserIds = [$rootUserId]; + + for ($line = 1; $line <= self::MAX_DEPTH_SAFETY_LIMIT && $currentSponsorIds !== []; $line++) { + $users = User::query() + ->with('account') + ->whereIn('m_sponsor', $currentSponsorIds) + ->whereNotIn('id', $visitedUserIds) + ->whereColumn('id', '!=', 'm_sponsor') + ->whereNull('deleted_at') + ->get(); + + if ($users->isEmpty()) { + break; + } + + $lineBuckets[$line] = $users; + $currentSponsorIds = $users->pluck('id')->all(); + $visitedUserIds = array_merge($visitedUserIds, $currentSponsorIds); + } + + return $lineBuckets; + } + + /** + * @return int[] + */ + public function lineUserIds(int $rootUserId, int $line): array + { + if ($line === 0) { + return $this->teamUserIds($rootUserId); + } + + if ($line < 1) { + return []; + } + + return ($this->lineBuckets($rootUserId)[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all(); + } + + /** + * @return int[] + */ + public function teamUserIds(int $rootUserId): array + { + return collect($this->lineBuckets($rootUserId)) + ->flatMap(fn (Collection $users) => $users->pluck('id')) + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + } + + /** + * @param \Illuminate\Support\Collection $users + * @return array + */ + private function buildLineRow(int $line, Collection $users, int $month, int $year): array + { + $userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all(); + + if ($userIds === []) { + return $this->emptyLine($line, $userIds); + } + + $salesSummary = $this->salesSummary($userIds, $month, $year); + + return [ + 'line' => $line, + 'label' => 'Linie '.$line, + 'user_ids' => $userIds, + 'consultants' => $this->activeConsultants($users), + 'new_partners' => $this->newPartners($users, $month, $year), + 'team_partner_abos' => $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count(), + 'team_partner_abos_new' => $this->newAboCount($userIds, 'user_id', 'me', $month, $year), + 'team_customer_abos' => $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count(), + 'team_customer_abos_new' => $this->newAboCount($userIds, 'member_id', 'ot', $month, $year), + 'own_points' => $salesSummary['own_points'], + 'external_points' => $salesSummary['external_points'], + 'customer_abo_points' => $salesSummary['customer_abo_points'], + 'customer_single_order_points' => $salesSummary['customer_single_order_points'], + 'customer_other_points' => $salesSummary['customer_other_points'], + 'total_points' => $salesSummary['total_points'], + 'turnover_net' => $salesSummary['turnover_net'], + 'shop_1000' => $this->shop1000Count($userIds, $month, $year), + ]; + } + + /** + * @return array + */ + private function emptyLine(int $line, array $userIds): array + { + return [ + 'line' => $line, + 'label' => $line > 0 ? 'Linie '.$line : 'Summe', + 'user_ids' => $userIds, + 'consultants' => 0, + 'new_partners' => 0, + 'team_partner_abos' => 0, + 'team_partner_abos_new' => 0, + 'team_customer_abos' => 0, + 'team_customer_abos_new' => 0, + 'own_points' => 0.0, + 'external_points' => 0.0, + 'customer_abo_points' => 0.0, + 'customer_single_order_points' => 0.0, + 'customer_other_points' => 0.0, + 'total_points' => 0.0, + 'turnover_net' => 0.0, + 'shop_1000' => 0, + ]; + } + + /** + * @param array $totals + * @param array $row + * @return array + */ + private function addToTotals(array $totals, array $row): array + { + foreach (array_keys($this->metricLabels()) as $metric) { + $totals[$metric] += $row[$metric]; + } + + $totals['turnover_net'] += $row['turnover_net']; + $totals['team_partner_abos_new'] += $row['team_partner_abos_new']; + $totals['team_customer_abos_new'] += $row['team_customer_abos_new']; + $totals['user_ids'] = array_merge($totals['user_ids'], $row['user_ids']); + + return $totals; + } + + /** + * @param \Illuminate\Support\Collection $users + */ + private function activeConsultants(Collection $users): int + { + return $users + ->filter(fn (User $user) => $user->m_level !== null && $user->payment_account !== null) + ->count(); + } + + /** + * @param \Illuminate\Support\Collection $users + */ + private function newPartners(Collection $users, int $month, int $year): int + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = Carbon::create($year, $month, 1)->endOfMonth(); + + return $users + ->filter(function (User $user) use ($startDate, $endDate): bool { + if ($user->m_level === null || $user->active_date === null || ! $this->hasActivePaymentAccount($user)) { + return false; + } + + $activeDate = Carbon::parse($user->active_date); + + return $activeDate->betweenIncluded($startDate, $endDate); + }) + ->count(); + } + + private function hasActivePaymentAccount(User $user): bool + { + return $user->payment_account !== null && Carbon::parse($user->payment_account)->isFuture(); + } + + private function activeAboQuery(): Builder + { + return UserAbo::query() + ->where('active', true) + ->whereNotIn('status', [4, 5, 6]); + } + + /** + * @param int[] $userIds + */ + private function newAboCount(array $userIds, string $userColumn, string $isFor, int $month, int $year): int + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = Carbon::create($year, $month, 1)->endOfMonth(); + + return $this->activeAboQuery() + ->whereIn($userColumn, $userIds) + ->where('is_for', $isFor) + ->whereBetween('start_date', [$startDate, $endDate]) + ->count(); + } + + /** + * @param int[] $userIds + * @return array{own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, turnover_net: float} + */ + private function salesSummary(array $userIds, int $month, int $year): array + { + $summary = UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->selectRaw('COALESCE(SUM(month_KP_points), 0) as own_points') + ->selectRaw('COALESCE(SUM(month_shop_points), 0) as external_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) as customer_abo_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) as customer_single_order_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) as customer_other_points') + ->selectRaw('COALESCE(SUM(month_total_net), 0) + COALESCE(SUM(month_shop_total_net), 0) as turnover_net') + ->first(); + + $ownPoints = (float) ($summary->own_points ?? 0); + $externalPoints = (float) ($summary->external_points ?? 0); + + return [ + 'own_points' => $ownPoints, + 'external_points' => $externalPoints, + 'customer_abo_points' => (float) ($summary->customer_abo_points ?? 0), + 'customer_single_order_points' => (float) ($summary->customer_single_order_points ?? 0), + 'customer_other_points' => (float) ($summary->customer_other_points ?? 0), + 'total_points' => $ownPoints + $externalPoints, + 'turnover_net' => (float) ($summary->turnover_net ?? 0), + ]; + } + + /** + * @param int[] $userIds + */ + private function shop1000Count(array $userIds, int $month, int $year): int + { + return UserSalesVolume::query() + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->select('user_id') + ->selectRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) as total_points') + ->groupBy('user_id') + ->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) >= 1000') + ->get() + ->count(); + } +} diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php new file mode 100644 index 0000000..8f8da4f --- /dev/null +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -0,0 +1,331 @@ +>, summary: array} + */ + public function details(User $viewer, int $line, string $metric, int $month, int $year): array + { + $metricLabels = $this->dashboardService->metricLabels(); + + if (! array_key_exists($metric, $metricLabels)) { + abort(404); + } + + $userIds = $this->dashboardService->lineUserIds($viewer->id, $line); + $rows = match ($metric) { + 'consultants' => $this->consultantRows($userIds), + 'new_partners' => $this->newPartnerRows($userIds, $month, $year), + 'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year), + 'team_customer_abos' => $this->customerAboRows($userIds, $month, $year), + 'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric), + default => [], + }; + + return [ + 'metric' => $metric, + 'metric_label' => $metricLabels[$metric], + 'line' => $line, + 'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien', + 'month' => $month, + 'year' => $year, + 'rows' => $rows, + 'summary' => $this->summary($rows), + ]; + } + + /** + * @param array> $rows + * @return array{count: int, points: float, own_points: float, external_points: float, customer_abo_points: float, customer_single_order_points: float, customer_other_points: float, total_points: float, deliveries: int} + */ + private function summary(array $rows): array + { + return [ + 'count' => count($rows), + 'points' => (float) collect($rows)->sum('points'), + 'own_points' => (float) collect($rows)->sum('own_points'), + 'external_points' => (float) collect($rows)->sum('external_points'), + 'customer_abo_points' => (float) collect($rows)->sum('customer_abo_points'), + 'customer_single_order_points' => (float) collect($rows)->sum('customer_single_order_points'), + 'customer_other_points' => (float) collect($rows)->sum('customer_other_points'), + 'total_points' => (float) collect($rows)->sum('total_points'), + 'deliveries' => (int) collect($rows)->sum('deliveries'), + ]; + } + + /** + * @param int[] $userIds + * @return array> + */ + private function consultantRows(array $userIds): array + { + return User::query() + ->with(['account', 'user_level']) + ->whereIn('id', $userIds) + ->whereNotNull('m_level') + ->whereNotNull('payment_account') + ->orderBy('id') + ->get() + ->map(fn (User $user) => [ + 'type' => 'user', + 'user_id' => $user->id, + 'name' => $this->userName($user), + 'email' => $user->email, + 'career_level' => $this->careerLevel($user), + 'is_account_active' => Carbon::parse($user->payment_account)->isFuture(), + 'account_status' => Carbon::parse($user->payment_account)->isFuture() ? 'Aktiv' : 'Abgelaufen', + 'active_date' => $this->formatDate($user->active_date), + 'payment_account' => $this->formatDate($user->payment_account), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function newPartnerRows(array $userIds, int $month, int $year): array + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + $endDate = Carbon::create($year, $month, 1)->endOfMonth(); + + return User::query() + ->with(['account', 'user_level']) + ->whereIn('id', $userIds) + ->whereNotNull('m_level') + ->whereNotNull('payment_account') + ->whereBetween('active_date', [$startDate, $endDate]) + ->orderBy('active_date') + ->get() + ->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture()) + ->map(fn (User $user) => [ + 'type' => 'user', + 'user_id' => $user->id, + 'name' => $this->userName($user), + 'email' => $user->email, + 'career_level' => $this->careerLevel($user), + 'active_date' => $this->formatDate($user->active_date), + 'payment_account' => $this->formatDate($user->payment_account), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function partnerAboRows(array $userIds, int $month, int $year): array + { + return $this->activeAboQuery() + ->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions']) + ->whereIn('user_id', $userIds) + ->where('is_for', 'me') + ->orderBy('next_date') + ->get() + ->map(fn (UserAbo $abo) => [ + 'type' => 'abo', + 'abo_id' => $abo->id, + 'user_id' => $abo->user_id, + 'name' => $abo->user ? $this->userName($abo->user) : '#'.$abo->user_id, + 'email' => $abo->user?->email, + 'career_level' => $abo->user ? $this->careerLevel($abo->user) : '-', + 'points' => $abo->getTotalPoints(), + 'start_date' => $this->formatDate($abo->getRawOriginal('start_date')), + 'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year), + 'next_date' => $this->formatDate($abo->next_date), + 'deliveries' => $abo->getCountOrders(), + 'status' => $abo->status, + 'status_label' => $abo->getStatusType(), + 'status_badge' => $abo->getStatusFormated(), + 'status_reason' => $this->aboStatusReason($abo), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function customerAboRows(array $userIds, int $month, int $year): array + { + return $this->activeAboQuery() + ->with(['member.account', 'member.user_level', 'user.account', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions']) + ->whereIn('member_id', $userIds) + ->where('is_for', 'ot') + ->orderBy('member_id') + ->orderBy('next_date') + ->get() + ->map(fn (UserAbo $abo) => [ + 'type' => 'customer_abo', + 'abo_id' => $abo->id, + 'user_id' => $abo->user_id, + 'member_id' => $abo->member_id, + 'name' => $abo->user ? $this->userName($abo->user) : ($abo->email ?: '#'.$abo->user_id), + 'email' => $abo->email ?: $abo->user?->email, + 'consultant_name' => $abo->member ? $this->userName($abo->member) : '#'.$abo->member_id, + 'career_level' => $abo->member ? $this->careerLevel($abo->member) : '-', + 'points' => $abo->getTotalPoints(), + 'start_date' => $this->formatDate($abo->getRawOriginal('start_date')), + 'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year), + 'next_date' => $this->formatDate($abo->next_date), + 'deliveries' => $abo->getCountOrders(), + 'status' => $abo->status, + 'status_label' => $abo->getStatusType(), + 'status_badge' => $abo->getStatusFormated(), + 'status_reason' => $this->aboStatusReason($abo), + ]) + ->values() + ->all(); + } + + /** + * @param int[] $userIds + * @return array> + */ + private function pointsRows(array $userIds, int $month, int $year, string $metric): array + { + $rows = UserSalesVolume::query() + ->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id') + ->whereIn('user_id', $userIds) + ->where('month', $month) + ->where('year', $year) + ->select('user_sales_volumes.user_id') + ->selectRaw('COALESCE(SUM(month_KP_points), 0) as own_points') + ->selectRaw('COALESCE(SUM(month_shop_points), 0) as external_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) as customer_abo_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) as customer_single_order_points') + ->selectRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) as customer_other_points') + ->selectRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) as total_points') + ->groupBy('user_sales_volumes.user_id'); + + if ($metric === 'own_points') { + $rows->havingRaw('COALESCE(SUM(month_KP_points), 0) > 0'); + } + + if ($metric === 'external_points') { + $rows->havingRaw('COALESCE(SUM(month_shop_points), 0) > 0'); + } + + if ($metric === 'customer_abo_points') { + $rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.is_abo = 1 THEN month_shop_points ELSE 0 END), 0) > 0'); + } + + if ($metric === 'customer_single_order_points') { + $rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL) THEN month_shop_points ELSE 0 END), 0) > 0'); + } + + if ($metric === 'customer_other_points') { + $rows->havingRaw('COALESCE(SUM(CASE WHEN shopping_orders.id IS NULL THEN month_shop_points ELSE 0 END), 0) > 0'); + } + + if ($metric === 'total_points') { + $rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) > 0'); + } + + if ($metric === 'shop_1000') { + $rows->havingRaw('COALESCE(SUM(month_KP_points), 0) + COALESCE(SUM(month_shop_points), 0) >= 1000'); + } + + $salesRows = $rows->orderByDesc('total_points')->get(); + $users = User::query()->with(['account', 'user_level'])->whereIn('id', $salesRows->pluck('user_id'))->get()->keyBy('id'); + + return $salesRows + ->map(fn (UserSalesVolume $row) => [ + 'type' => 'points', + 'user_id' => $row->user_id, + 'name' => $users->has($row->user_id) ? $this->userName($users->get($row->user_id)) : '#'.$row->user_id, + 'email' => $users->get($row->user_id)?->email, + 'career_level' => $users->has($row->user_id) ? $this->careerLevel($users->get($row->user_id)) : '-', + 'own_points' => (float) $row->own_points, + 'external_points' => (float) $row->external_points, + 'customer_abo_points' => (float) $row->customer_abo_points, + 'customer_single_order_points' => (float) $row->customer_single_order_points, + 'customer_other_points' => (float) $row->customer_other_points, + 'total_points' => (float) $row->total_points, + ]) + ->values() + ->all(); + } + + private function activeAboQuery(): Builder + { + return UserAbo::query() + ->where('active', true) + ->whereNotIn('status', [4, 5, 6]); + } + + private function userName(User $user): string + { + $name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? '')); + + return $name !== '' ? $name : ($user->email ?: '#'.$user->id); + } + + private function careerLevel(User $user): string + { + return $user->user_level?->name ?: ($user->m_level ? 'Level '.$user->m_level : '-'); + } + + private function aboStatusReason(UserAbo $abo): ?string + { + if ((int) $abo->status === 2) { + return null; + } + + $transaction = $abo->user_abo_orders + ->sortByDesc('created_at') + ->pluck('shopping_order') + ->filter() + ->map(fn ($order) => $order->getLastShoppingPaymentTransaction()) + ->filter() + ->first(); + + if (! $transaction) { + return null; + } + + $message = $transaction->errormessage ?: $transaction->customermessage; + + if (! $message) { + return null; + } + + return $transaction->errorcode ? '['.$transaction->errorcode.'] '.$message : $message; + } + + private function isAboNewInMonth(UserAbo $abo, int $month, int $year): bool + { + $startDate = $abo->getRawOriginal('start_date'); + + if (! $startDate) { + return false; + } + + $date = Carbon::parse($startDate); + + return (int) $date->month === $month && (int) $date->year === $year; + } + + private function formatDate(mixed $date): ?string + { + if ($date === null || $date === '') { + return null; + } + + return Carbon::parse($date)->format('d.m.Y'); + } +} diff --git a/database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php b/database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php new file mode 100644 index 0000000..b59aa88 --- /dev/null +++ b/database/migrations/2026_05_18_115047_create_backoffice_statistics_snapshots_table.php @@ -0,0 +1,40 @@ +id(); + $table->unsignedInteger('user_id'); + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->json('payload'); + $table->timestamp('calculated_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'year', 'month'], 'backoffice_statistics_snapshot_unique'); + $table->index(['year', 'month']); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backoffice_statistics_snapshots'); + } +}; diff --git a/database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php b/database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php new file mode 100644 index 0000000..22b3ea8 --- /dev/null +++ b/database/migrations/2026_05_18_134807_add_customer_order_source_to_shopping_orders_table.php @@ -0,0 +1,29 @@ +string('customer_order_source')->nullable()->after('mode'); + $table->text('customer_order_source_comment')->nullable()->after('customer_order_source'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('shopping_orders', function (Blueprint $table) { + $table->dropColumn(['customer_order_source', 'customer_order_source_comment']); + }); + } +}; diff --git a/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md index 503f52f..d1b94a8 100644 --- a/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md +++ b/dev/2026-05-13-backoffice/ENTWICKLUNGSKONZEPT-BACKOFFICE.md @@ -10,7 +10,7 @@ Das Partner-Backoffice soll von einer statischen Monats-Kachelansicht zu einer k Kernziel ist eine einheitliche Datenbasis für: -- Dashboard-Kennzahlen pro Linie 1 bis 8 inklusive Summenzeile +- Dashboard-Kennzahlen pro vorhandener Linie inklusive Summenzeile - Detailansichten je Linie, Firstline und Kennzahl - Kundenabos, Teamabos, Kundenabos im Team, Umsatz und Punkte - neue Spezial-Kennzahl "1000 Punkte Shop" @@ -37,7 +37,7 @@ Die aktuelle Statistik wird direkt im Blade-Partial berechnet. Sie nutzt unter a Bewertung: - Die vorhandene Ansicht ist ein guter Einstieg, aber fachlich zu grob. -- Es gibt noch keine Linien 1 bis 8, keine Summenzeile und keine Drill-down-Routen. +- Es gibt noch keine dynamische Linienübersicht, keine Summenzeile und keine Drill-down-Routen. - Die Kennzahl "Kundenabos" vermischt aktuell eigene Abos und Kundenabos. - "Team-Abos" zählt aktuell Beraterabos im Team, aber nicht die separat geforderten Kundenabos im Team. @@ -296,6 +296,48 @@ Geplante Actions: Die vorhandenen Team-Views bleiben bestehen. Das neue Dashboard verweist für Detailseiten aber auf eigene, schlankere Statistik-Views. +### Erster MVP: VIP-Menüpunkt Statistik + +Der erste technische Schritt ist ein eigener Navigationspunkt `Statistik` im User-Backoffice. Dieser Punkt ist bewusst nicht unter `Mein Team` einsortiert, sondern als eigener Einstieg sichtbar, damit die neue Auswertung fachlich als eigenes Modul wahrgenommen wird. + +Bereits angelegte Basis: + +- Route: `GET /user/backoffice/statistics` +- Detailroute: `GET /user/backoffice/statistics/details?line=...&metric=...&month=...&year=...` +- Name: `user_backoffice_statistics` +- Controller: `App\Http\Controllers\User\BackofficeStatisticsController@index` +- View: `resources/views/user/backoffice/statistics/index.blade.php` +- Services: `App\Services\Backoffice\BackofficeDashboardService` und `App\Services\Backoffice\BackofficeDrilldownService` +- Navigation: eigener Sidenav-Punkt `Statistik` mit `VIP`-Badge +- Zugriff: nur `Auth::user()->isVIP()`, normale aktive User erhalten `404` +- MVP-Übersicht: alle tatsächlich vorhandenen Linien mit Beratern inklusive abgelaufener, aber nicht gelöschter Accounts, Neupartnern, Teamabos, Teamkundenabos, Eigenpunkten, externen Punkten, Kundenabo-Punkten, Einzelbestellungs-Punkten, sonstigen Kundenpunkten, Gesamtpunkten und `1000 Punkte Shop` +- MVP-Details: klickbare Listen für Berater, Neupartner, Teamabos, Teamkundenabos und Punktekennzahlen +- Summenzeile: ebenfalls klickbar über `line=0` und damit Detailansicht über alle vorhandenen Linien +- Berater-Detail: abgelaufene, aber nicht gelöschte Accounts werden mit roter Tabellenzeile und Status `Abgelaufen` angezeigt +- Abo-Detail: zeigt `Besteht seit`; neue Abos im gewählten Monat werden grün markiert und in der Übersicht als Klammerzahl hinter den Abo-Counts gezeigt +- Zeitraum: gewählter Monat/Jahr bleibt über Query-Parameter und Session erhalten, auch beim Rücksprung aus Detailansichten +- Detailtabellen: clientseitige Suche, klickbare Sortierung der Spalten und Summenzeile am Tabellenende +- Detailtabellen: CSV-Export über `GET /user/backoffice/statistics/export?line=...&metric=...&month=...&year=...` +- Übersicht: Kennzahlen werden als gut anklickbare Badges dargestellt; Nullwerte bleiben sichtbar, aber nicht klickbar +- Punktetrennung: externe Kundenpunkte werden zusätzlich nach Kundenabo-Punkten, Einzelbestellungs-Punkten und sonstigen Kundenpunkten getrennt +- Snapshots: abgeschlossene Monate können in `backoffice_statistics_snapshots` gespeichert werden; vorhandene Snapshots werden bevorzugt vor Live-Berechnung geladen +- Command: `backoffice:store-statistics-snapshots` speichert Snapshots für VIP-User, optional mit `--user`, `--month`, `--year` und `--force` +- 1000-Punkte-Shop: Detailansicht zeigt die getrennten Punkte nach Eigen-, Abo-, Einzelbestellungs- und sonstigen Kundenpunkten; die vorherige Qualifikations-Einteilung wurde zugunsten des aktuellen Karriere-Levels entfernt +- Karriere-Level: Detailansichten zeigen den aktuellen Karriere-Level des jeweiligen Beraters +- Datenschutz: Detailansichten zeigen einen sichtbaren Hinweis, dass personenbezogene Detaildaten rechtlich noch final geklärt werden und aktuell nur für berechtigte VIP-Auswertungen vorgesehen sind +- Übersichts-Export: Die Linienübersicht kann als CSV exportiert werden, inklusive Summenzeile, neuer Abo-Zählungen und Punktetrennung +- Tests: CSV-Inhalte, Detail-CSV mit Karriere-Level, Zeitraum-Erhalt, neue Abo-Markierung und Abo-Statusgrund aus Zahlungsfehlern sind gezielt abgesichert +- Performance-Hinweis: Die Übersicht zeigt Datenquelle (`Live` oder `Snapshot`) und Laufzeit der Berechnung, um große VIP-Teams leichter prüfen zu können +- Checkout-Herkunft: Kundenbestellungen im Shop speichern eine vordefinierte Herkunft plus optionalen Freitext und zeigen diese im Bestelldetail an + +Weitere Umsetzung nach dem MVP: + +1. Snapshot-Command nach Migration auf Test-/Produktivdaten einmalig für abgeschlossene Monate laufen lassen. +2. Umsatzarten weiter fachlich verfeinern, falls neben `shopping_orders.is_abo` zusätzliche Herkunftsarten zuverlässig gespeichert werden. +3. Optional Excel-Export ergänzen, falls CSV für den Fachbereich nicht reicht. +4. Spätere rechtliche Einschränkungen für Kundendaten nach finaler Klärung einarbeiten. +5. `1000 Punkte Shop` nach fachlicher Abnahme ggf. um weitere Herkunftsarten erweitern. + ### Datenmodell und Definitionen Einheitliche Metriken: @@ -325,7 +367,7 @@ Jede Detailansicht muss sicherstellen: - Fachliche Definitionen finalisieren - `BackofficeDashboardService` erstellen -- Stufe-1-Linienübersicht mit Linien 1 bis 8 und Summenzeile bauen +- Stufe-1-Linienübersicht mit allen tatsächlich vorhandenen Linien und Summenzeile bauen - bestehende Dashboard-Kachel durch Link auf neue Statistikseite ergänzen oder neue Seite im Menü aufnehmen - Kennzahlen noch ohne vollständigen Deep Dive, aber bereits sauber berechnet @@ -346,10 +388,10 @@ Jede Detailansicht muss sicherstellen: ### Phase 4: Herkunftsabfrage im Checkout -- Migration und Model-Fillable ergänzen -- Checkout-Formular erweitern -- Validierung und Speicherung ergänzen -- Admin-/Bestelldetail oder Export um Feld erweitern +- umgesetzt: Migration `shopping_orders.customer_order_source` und `customer_order_source_comment` +- umgesetzt: Checkout-Formular für Shop-Kundenbestellungen (`is_from = shopping`) mit Auswahlfeld plus optionalem Freitext +- umgesetzt: Validierung und Speicherung in `CheckoutController` / `CheckoutRepository` +- umgesetzt: Anzeige im Bestelldetail für `payment_for = 6` ### Phase 5: Storno-Qualitätssicherung @@ -370,7 +412,7 @@ Jede Detailansicht muss sicherstellen: Feature-Tests: - Dashboard zeigt nur Daten der eigenen Downline. -- Linien 1 bis 8 werden korrekt gruppiert. +- alle tatsächlich vorhandenen Linien werden korrekt gruppiert. - Summenzeile entspricht Summe der Linien. - Klick auf Teamabos zeigt nur `is_for = 'me'` in der Downline. - Klick auf Kundenabos im Team zeigt nur `is_for = 'ot'` mit `member_id` in der Downline. @@ -395,13 +437,21 @@ Regressionsprüfung: ## Offene Fachfragen 1. Soll die neue Statistik die aktuelle Monatslogik nutzen oder standardmäßig den letzten abgeschlossenen Monat zeigen? +//beides, Wichtig ist immer der aktuelle Monat, das hier um Qualität zahlen gibt und natürlich auch die letzten abgeschlossenen Monat. Hier können wir natürlich auch in Datenbanken entsprechend die Kennzahlen speichern und nicht immer hohe Quere an die Datenbank zu senden. 2. Sollen Stornos im Stornomonat oder im ursprünglichen Umsatzmonat gegengerechnet werden? +//ist noch zu prüfen Wird umgesetzt, sobald hier eine deutliche Klärung stattgefunden hat 3. Wie genau wird "1000 Punkte Shop" definiert: nur Shop-Punkte, alle Kundenpunkte oder Kundenabos plus Einzelbestellungen? +Ich würde hier erst mal alle Punkte zusammenziehen also Kunden funkte Kunden Abos Einzelbestellungen etc. alles was in die einzelnen Punkte des Kunden geht. Zusätzlich würde ich's einmal trennen nach Eigenpunkten und externen Punkten. D.h. grundsätzlich würde ich hier auch eine Trennung vornehmen der einzelnen Shop Punkte, Kunden, Abos, Einzelbestellung etc. 4. Welche Kundendaten dürfen Berater in Deep-Dive-Listen sehen? +In der Entwicklung zeigen wir erst mal die gesamten Inhalte an. Mit einem Hinweis wird gerade rechtlich geklärt. 5. Ist die Herkunftsabfrage Freitext, Auswahlfeld oder Kombination? +Kombination ein Auswahlfeld von vordefinierten Sachen alternativ Freitext 6. Gilt die Herkunftsabfrage für alle Checkout-Flows oder nur für externe Kundenbestellungen? +Nur für Kundenbestellung in den Shops 7. Darf eine Incentive-Teilnahme bereits Name/Foto/Land freigeben oder braucht es ein separates Opt-in? -8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads? +Auch hier befindet sich noch beim Rechtsanwalt in Klärung. Hier würde ich erst mal einbauen Und mit Hinweisen versehen, die dann gegebenenfalls später rausgenommen werden müssen +8. Soll das Event-Archiv nur Bilder und Texte enthalten oder eine echte Galerie mit Mehrfachuploads? +Echte Galerie mit mehrfach Upload ## Empfehlung @@ -415,3 +465,15 @@ Priorität für die erste Umsetzung: 4. 1000-Punkte-Shop 5. Checkout-Herkunft und Storno-Tests 6. Incentive-Sichtbarkeit und Event-Archiv + + + +Snapshots/Caching: Abgeschlossene Monate speichern, damit große Teams nicht jedes Mal live berechnet werden. +php artisan migrate +php artisan backoffice:store-statistics-snapshots +php artisan list backoffice +1000-Punkte-Shop verfeinert: zählt weiter Berater ab 1000 Gesamtpunkten und zeigt in der Detailansicht den aktuellen Karriere-Level statt einer fachlich erklärungsbedürftigen Qualifikations-Einteilung. +Datenschutz-Hinweis umgesetzt: Detailansichten weisen sichtbar darauf hin, dass personenbezogene Daten rechtlich noch final geklärt werden. +Übersichts-Export umgesetzt: CSV-Export steht auch direkt in der Linienübersicht zur Verfügung. +Tests ausgebaut: CSV-Inhalte und Zeitraum-Erhalt sind näher am Controller-/Export-Flow abgesichert; Abo-Statusgrund und neue Abo-Markierung sind im Service-Test abgedeckt. +Performance prüfen: Bei echten VIP-Accounts mit großem Team messen, ob die Live-Queries schnell genug sind. \ No newline at end of file diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index d82edf3..bb1b357 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -47,6 +47,7 @@ return [ 'settings' => 'Einstellungen', 'shipping_costs' => 'Versandkosten', 'start_site' => 'Startseite Shop', + 'statistics' => 'Statistik', 'structure' => 'Struktur', 'system_settings' => 'System-E.', 'translate' => 'Übersetzungen', diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index ad14da3..f1c6837 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -56,6 +56,7 @@ return [ 'settings' => 'settings', 'shipping_costs' => 'shipping', 'start_site' => 'home page', + 'statistics' => 'Statistics', 'structure' => 'structure', 'system_settings' => 'system E.', 'tags' => 'Tags', diff --git a/resources/lang/es/navigation.php b/resources/lang/es/navigation.php index 1e9b361..1c0c1a8 100644 --- a/resources/lang/es/navigation.php +++ b/resources/lang/es/navigation.php @@ -56,6 +56,7 @@ return [ 'settings' => 'ajustes', 'shipping_costs' => 'envío', 'start_site' => 'pagina de inicio', + 'statistics' => 'Estadísticas', 'structure' => 'estructura', 'system_settings' => 'sistema E.', 'tags' => 'Etiquetas', diff --git a/resources/lang/fr/navigation.php b/resources/lang/fr/navigation.php index b26f810..e1d96ae 100644 --- a/resources/lang/fr/navigation.php +++ b/resources/lang/fr/navigation.php @@ -47,6 +47,7 @@ return [ 'settings' => 'Paramètres', 'shipping_costs' => 'Frais de livraison', 'start_site' => 'Page d’accueil boutique', + 'statistics' => 'Statistiques', 'structure' => 'Structure', 'system_settings' => 'Param. système', 'translate' => 'Traductions', diff --git a/resources/views/admin/sales/_detail.blade.php b/resources/views/admin/sales/_detail.blade.php index 3ea69e8..4ee2346 100644 --- a/resources/views/admin/sales/_detail.blade.php +++ b/resources/views/admin/sales/_detail.blade.php @@ -185,6 +185,15 @@
{{ __('order.points_total') }}
{{ $shopping_order->getFormattedPoints() }} + @if ((int) $shopping_order->payment_for === 6 && $shopping_order->customer_order_source) +
+
Herkunft
+ {{ $shopping_order->getCustomerOrderSourceLabel() }} + @if ($shopping_order->customer_order_source_comment) +
{{ $shopping_order->customer_order_source_comment }}
+ @endif +
+ @endif
diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index cbd5f2c..925fb94 100755 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -55,6 +55,17 @@
{{ __('navigation.news_archive') }}
+ @if (Auth::user()->isVIP()) +
  • + +
    {{ __('navigation.statistics') }}
    +
    +
    VIP
    +
    +
    +
  • + @endif
  • diff --git a/resources/views/user/backoffice/statistics/details.blade.php b/resources/views/user/backoffice/statistics/details.blade.php new file mode 100644 index 0000000..95dddcf --- /dev/null +++ b/resources/views/user/backoffice/statistics/details.blade.php @@ -0,0 +1,279 @@ +@extends('layouts.layout-2') + +@section('content') +

    +
    + {{ __('navigation.statistics') }} / {{ $details['metric_label'] }} + VIP +
    +
    +

    + +
    +
    +
    +
    +
    Linie
    + {{ $details['line_label'] }} +
    +
    +
    Zeitraum
    + {{ $details['month'] }}/{{ $details['year'] }} +
    +
    +
    Kennzahl
    + {{ $details['metric_label'] }} +
    +
    +
    Treffer
    + {{ number_format($details['summary']['count'], 0, ',', '.') }} +
    +
    +
    +
    + +
    +
    +
    + Datenschutz-Hinweis: + Die Anzeige personenbezogener Detaildaten befindet sich noch in rechtlicher Klärung und ist aktuell nur für berechtigte VIP-Auswertungen vorgesehen. +
    + @if ($details['rows'] === []) +
    +

    {{ __('tables.no_data_available') }}

    +
    + @else +
    +
    +
    + + + +
    + +
    + + Suche nach Name, E-Mail, Status, Punkten oder Datum. Die Summenzeile bleibt unverändert. + +
    +
    + + + + + + + @if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true)) + + + + + + + @elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) + + + + + + + @else + + + @endif + + + + @foreach ($details['rows'] as $row) + + + + + @if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true)) + + + + + + + @elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) + + + + + + + @else + + + @endif + + @endforeach + + + + + + + @if (in_array($details['metric'], ['team_partner_abos', 'team_customer_abos'], true)) + + + + + + + @elseif (in_array($details['metric'], ['own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000'], true)) + + + + + + + @else + + + @endif + + +
    Name E-Mail Karriere-Level Berater Abo-Punkte Status Besteht seit Nächste Ausführung Lieferungen Eigenpunkte Externe Punkte Kundenabo-Punkte Einzelbestellungs-Punkte Sonstige Kundenpunkte Gesamtpunkte Aktiv seit Account gültig bis
    Summe{{ number_format($details['summary']['count'], 0, ',', '.') }} Einträge{{ \App\Services\Util::formatNumber($details['summary']['points']) }}{{ number_format($details['summary']['deliveries'], 0, ',', '.') }}{{ \App\Services\Util::formatNumber($details['summary']['own_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['external_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['customer_abo_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['customer_single_order_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['customer_other_points']) }}{{ \App\Services\Util::formatNumber($details['summary']['total_points']) }}
    +
    + @endif +
    +
    + + @if ($details['rows'] !== []) + + + + @endif +@endsection diff --git a/resources/views/user/backoffice/statistics/index.blade.php b/resources/views/user/backoffice/statistics/index.blade.php new file mode 100644 index 0000000..f2213c3 --- /dev/null +++ b/resources/views/user/backoffice/statistics/index.blade.php @@ -0,0 +1,237 @@ +@extends('layouts.layout-2') + +@section('content') +

    +
    + {{ __('navigation.statistics') }} + VIP +
    +

    + +
    +
    +
    +
    +
    {{ __('navigation.statistics') }} / Backoffice MVP
    +

    + Linienbasierte Übersicht für alle vorhandenen Team-Linien. Klickbare Zahlen führen direkt in die + passenden Namen- und Detaillisten. +

    + @if (! empty($performance)) +
    + Datenquelle: + {{ $performance['source_label'] }} + Berechnet in {{ number_format($performance['duration_ms'], 2, ',', '.') }} ms + @if (! empty($performance['calculated_at'])) + Snapshot vom {{ $performance['calculated_at'] }} + @endif +
    + @endif +
    +
    +
    + + CSV + + + + +
    +
    +
    +
    +
    + + @php + $clickableMetrics = [ + 'consultants', + 'new_partners', + 'team_partner_abos', + 'team_customer_abos', + 'own_points', + 'external_points', + 'customer_abo_points', + 'customer_single_order_points', + 'customer_other_points', + 'total_points', + 'shop_1000', + ]; + + $formatValue = function ($value, string $metric): string { + if ( + in_array( + $metric, + [ + 'own_points', + 'external_points', + 'customer_abo_points', + 'customer_single_order_points', + 'customer_other_points', + 'total_points', + ], + true, + ) + ) { + return \App\Services\Util::formatNumber($value); + } + + return number_format((float) $value, 0, ',', '.'); + }; + + $newAboMetric = function (array $row, string $metric): ?string { + return match ($metric) { + 'team_partner_abos' => 'team_partner_abos_new', + 'team_customer_abos' => 'team_customer_abos_new', + default => null, + }; + }; + @endphp + +
    +
    +
    + + + + + + + + + + + + + + + + + + + @foreach ($statistics['lines'] as $line) + + + @foreach ($clickableMetrics as $metric) + + @endforeach + + @endforeach + + + + + @foreach ($clickableMetrics as $metric) + + @endforeach + + +
    LinieBeraterNeupartnerTeamabosTeamkundenabosEigenpunkteExterne PunkteKundenabo-PunkteEinzelbestellungs-PunkteSonstige KundenpunkteGesamtpunkte1000 Punkte Shop
    {{ $line['label'] }} + @if ($line[$metric] > 0) + + {{ $formatValue($line[$metric], $metric) }} + + @php $newMetric = $newAboMetric($line, $metric); @endphp + @if ($newMetric && $line[$newMetric] > 0) + +{{ number_format($line[$newMetric], 0, ',', '.') }} + @endif + @else + 0 + @endif +
    {{ $statistics['totals']['label'] }} + @if ($statistics['totals'][$metric] > 0) + + {{ $formatValue($statistics['totals'][$metric], $metric) }} + + @php $newMetric = $newAboMetric($statistics['totals'], $metric); @endphp + @if ($newMetric && $statistics['totals'][$newMetric] > 0) + +{{ number_format($statistics['totals'][$newMetric], 0, ',', '.') }} + @endif + @else + 0 + @endif +
    +
    +
    +
    + +
    +
    +
    +
    +
    Begriffe
    +

    + Teamabos sind Berater-/Eigenabos im Team. Teamkundenabos sind Kundenabos, die einem Berater + aus der jeweiligen Linie zugeordnet sind. +

    +

    + Externe Punkte kommen aktuell aus `month_shop_points`, Eigenpunkte aus `month_KP_points`. +

    +
    +
    +
    +
    +
    +
    +
    Nächster Ausbau
    +

    + Die Detailansichten liefern bereits Namen und Basisdaten. Im nächsten Schritt werden Abo-Punkte + und Umsatzarten noch feiner nach Abo, Einzelbestellung und Shop getrennt. +

    +
    +
    +
    +
    + + +@endsection diff --git a/resources/views/web/templates/checkout.blade.php b/resources/views/web/templates/checkout.blade.php index a33426b..fc7e0bb 100644 --- a/resources/views/web/templates/checkout.blade.php +++ b/resources/views/web/templates/checkout.blade.php @@ -550,6 +550,31 @@ {{ __('customer.language_hint') }} +
    +
    +
    + + + @if ($errors->has('customer_order_source')) + + @endif +
    +
    +
    +
    + + {!! Form::textarea('customer_order_source_comment', old('customer_order_source_comment'), ['class' => 'form-control '.($errors->has('customer_order_source_comment') ? 'error' : ''), 'id'=>'customer_order_source_comment', 'rows' => 3, 'maxlength' => 500]) !!} + @if ($errors->has('customer_order_source_comment')) + + @endif +
    +
    +
    @else
    diff --git a/routes/domains/crm.php b/routes/domains/crm.php index 4e495bf..efd48cd 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -125,6 +125,12 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::post('/user/shop/api/orders/checkout', 'User\ShopApiController@checkout')->name('user_shop_api_orders_checkout'); Route::get('/user/shop/api/orders/datatable', 'User\ShopApiController@ordersDatatable')->name('user_shop_api_orders_datatable'); + // user backoffice statistics + Route::get('/user/backoffice/statistics', 'User\BackofficeStatisticsController@index')->name('user_backoffice_statistics'); + Route::get('/user/backoffice/statistics/details', 'User\BackofficeStatisticsController@details')->name('user_backoffice_statistics_details'); + Route::get('/user/backoffice/statistics/export', 'User\BackofficeStatisticsController@export')->name('user_backoffice_statistics_export'); + Route::get('/user/backoffice/statistics/export-overview', 'User\BackofficeStatisticsController@overviewExport')->name('user_backoffice_statistics_overview_export'); + // user team Route::get('/user/team/add/member', 'User\TeamController@addMember')->name('user_team_add_member'); diff --git a/tests/Feature/BackofficeStatisticsAccessTest.php b/tests/Feature/BackofficeStatisticsAccessTest.php new file mode 100644 index 0000000..d76bd0c --- /dev/null +++ b/tests/Feature/BackofficeStatisticsAccessTest.php @@ -0,0 +1,342 @@ +forceFill([ + 'admin' => $admin, + 'lang' => 'de', + 'active' => 1, + 'blocked' => 0, + 'wizard' => 100, + ]); +} + +function makeBackofficeStatisticsRequest(User $user): Request +{ + $request = Request::create('/user/backoffice/statistics', 'GET'); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function makeBackofficeStatisticsController(?BackofficeDashboardService $dashboardService = null, ?BackofficeDrilldownService $drilldownService = null): BackofficeStatisticsController +{ + $dashboardService ??= Mockery::mock(BackofficeDashboardService::class); + $drilldownService ??= Mockery::mock(BackofficeDrilldownService::class); + + return new BackofficeStatisticsController($dashboardService, $drilldownService); +} + +function backofficeStatisticsStreamedContent(StreamedResponse $response): string +{ + ob_start(); + $response->sendContent(); + + return (string) ob_get_clean(); +} + +it('zeigt die Backoffice-Statistik fuer VIP-User', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $dashboardService + ->shouldReceive('overview') + ->once() + ->andReturn([ + 'month' => now()->month, + 'year' => now()->year, + 'metric_labels' => [], + 'lines' => [], + 'totals' => [], + '_meta' => [ + 'source_label' => 'Live', + 'calculated_at' => null, + ], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService); + + $response = $controller->index(makeBackofficeStatisticsRequest($vip)); + + expect($response->getName())->toBe('user.backoffice.statistics.index'); + expect($response->getData())->toHaveKeys(['selectedMonth', 'selectedYear', 'statistics', 'performance']); + expect($response->getData()['performance']['source_label'])->toBe('Live'); +}); + +it('behaelt den zuletzt gewaehlten Statistik-Zeitraum in der Session', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $dashboardService + ->shouldReceive('overview') + ->twice() + ->andReturn([ + 'month' => 4, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [], + 'totals' => [], + '_meta' => [ + 'source_label' => 'Snapshot', + 'calculated_at' => '17.05.2026 04:45', + ], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService); + $firstRequest = makeBackofficeStatisticsRequest($vip); + $firstRequest->query->set('month', 4); + $firstRequest->query->set('year', 2026); + + $controller->index($firstRequest); + $response = $controller->index(makeBackofficeStatisticsRequest($vip)); + + expect($response->getData()['selectedMonth'])->toBe(4); + expect($response->getData()['selectedYear'])->toBe(2026); + expect($response->getData()['performance']['source_label'])->toBe('Snapshot'); +}); + +it('blockiert die Backoffice-Statistik fuer normale aktive User', function () { + $user = makeBackofficeStatisticsUser(0); + $controller = makeBackofficeStatisticsController(); + + expect(fn () => $controller->index(makeBackofficeStatisticsRequest($user))) + ->toThrow(NotFoundHttpException::class); +}); + +it('erstellt einen CSV-Export fuer die Statistik-Uebersicht', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $dashboardService + ->shouldReceive('overview') + ->once() + ->andReturn([ + 'month' => 5, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [ + [ + 'label' => 'Linie 1', + 'consultants' => 2, + 'new_partners' => 1, + 'team_partner_abos' => 1, + 'team_partner_abos_new' => 1, + 'team_customer_abos' => 1, + 'team_customer_abos_new' => 1, + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + 'shop_1000' => 1, + 'turnover_net' => 300, + ], + ], + 'totals' => [ + 'label' => 'Summe', + 'consultants' => 2, + 'new_partners' => 1, + 'team_partner_abos' => 1, + 'team_partner_abos_new' => 1, + 'team_customer_abos' => 1, + 'team_customer_abos_new' => 1, + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + 'shop_1000' => 1, + 'turnover_net' => 300, + ], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService); + $request = makeBackofficeStatisticsRequest($vip); + $request->query->set('month', 5); + $request->query->set('year', 2026); + + $response = $controller->overviewExport($request); + $content = backofficeStatisticsStreamedContent($response); + + expect($response)->toBeInstanceOf(StreamedResponse::class); + expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-uebersicht-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'); + expect($content)->toContain('Summe;2;1;1;1;1;1;400;700;700;0;0;1100;1;300'); +}); + +it('erstellt einen CSV-Export fuer Detaildaten mit Karriere-Level und Summenzeile', function () { + $vip = makeBackofficeStatisticsUser(1); + $drilldownService = Mockery::mock(BackofficeDrilldownService::class); + $drilldownService + ->shouldReceive('details') + ->once() + ->with($vip, 0, 'shop_1000', 5, 2026) + ->andReturn([ + 'metric' => 'shop_1000', + 'metric_label' => '1000 Punkte Shop', + 'line' => 0, + 'line_label' => 'Alle Linien', + 'month' => 5, + 'year' => 2026, + 'rows' => [ + [ + 'name' => 'Max Mustermann', + 'email' => 'max@example.test', + 'career_level' => 'Partner', + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + ], + ], + 'summary' => [ + 'count' => 1, + 'points' => 0, + 'own_points' => 400, + 'external_points' => 700, + 'customer_abo_points' => 700, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 1100, + 'deliveries' => 0, + ], + ]); + + $controller = makeBackofficeStatisticsController(null, $drilldownService); + $request = makeBackofficeStatisticsRequest($vip); + $request->query->set('line', 0); + $request->query->set('metric', 'shop_1000'); + $request->query->set('month', 5); + $request->query->set('year', 2026); + + $response = $controller->export($request); + $content = backofficeStatisticsStreamedContent($response); + + expect($response)->toBeInstanceOf(StreamedResponse::class); + expect($response->headers->get('content-disposition'))->toContain('backoffice-statistik-shop_1000-linie-alle-05-2026.csv'); + expect($content)->toContain('Name;E-Mail;Karriere-Level;Eigenpunkte;"Externe Punkte"'); + expect($content)->toContain('"Max Mustermann";max@example.test;Partner;400;700;700;0;0;1100'); + expect($content)->toContain('Summe;"1 Eintraege";;400;700;700;0;0;1100'); +}); + +it('nutzt den gespeicherten Zeitraum fuer Detailansicht und Export', function () { + $vip = makeBackofficeStatisticsUser(1); + $dashboardService = Mockery::mock(BackofficeDashboardService::class); + $drilldownService = Mockery::mock(BackofficeDrilldownService::class); + + $dashboardService + ->shouldReceive('overview') + ->once() + ->andReturn([ + 'month' => 4, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [], + 'totals' => [], + ]); + + $drilldownService + ->shouldReceive('details') + ->once() + ->with($vip, 1, 'consultants', 4, 2026) + ->andReturn([ + 'metric' => 'consultants', + 'metric_label' => 'Berater', + 'line' => 1, + 'line_label' => 'Linie 1', + 'month' => 4, + 'year' => 2026, + 'rows' => [], + 'summary' => ['count' => 0], + ]); + + $drilldownService + ->shouldReceive('details') + ->once() + ->with($vip, 1, 'consultants', 4, 2026) + ->andReturn([ + 'metric' => 'consultants', + 'metric_label' => 'Berater', + 'line' => 1, + 'line_label' => 'Linie 1', + 'month' => 4, + 'year' => 2026, + 'rows' => [], + 'summary' => ['count' => 0], + ]); + + $controller = makeBackofficeStatisticsController($dashboardService, $drilldownService); + $indexRequest = makeBackofficeStatisticsRequest($vip); + $indexRequest->query->set('month', 4); + $indexRequest->query->set('year', 2026); + $controller->index($indexRequest); + + $detailsRequest = makeBackofficeStatisticsRequest($vip); + $detailsRequest->query->set('line', 1); + $detailsRequest->query->set('metric', 'consultants'); + $detailsResponse = $controller->details($detailsRequest); + + $exportRequest = makeBackofficeStatisticsRequest($vip); + $exportRequest->query->set('line', 1); + $exportRequest->query->set('metric', 'consultants'); + $exportResponse = $controller->export($exportRequest); + + expect($detailsResponse->getData()['selectedMonth'])->toBe(4); + expect($detailsResponse->getData()['selectedYear'])->toBe(2026); + expect($exportResponse->headers->get('content-disposition'))->toContain('backoffice-statistik-consultants-linie-1-04-2026.csv'); +}); + +it('rendert eine Suche in der Detailtabelle', function () { + $html = file_get_contents(resource_path('views/user/backoffice/statistics/details.blade.php')); + + expect($html)->toContain('backoffice-statistics-detail-search'); + expect($html)->toContain('backoffice-statistics-detail-table'); + expect($html)->toContain('data-sortable="true"'); + expect($html)->toContain('getSortValue'); + expect($html)->toContain('user_backoffice_statistics_export'); + expect($html)->toContain('Datenschutz-Hinweis'); + expect($html)->toContain('Karriere-Level'); + expect($html)->not->toContain('Qualifikation'); + + $indexHtml = file_get_contents(resource_path('views/user/backoffice/statistics/index.blade.php')); + + expect($indexHtml)->toContain('user_backoffice_statistics_overview_export'); + expect($indexHtml)->toContain('Datenquelle:'); + expect($indexHtml)->toContain('Berechnet in'); +}); + +it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () { + $checkoutView = file_get_contents(resource_path('views/web/templates/checkout.blade.php')); + $checkoutController = file_get_contents(app_path('Http/Controllers/Web/CheckoutController.php')); + $checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php')); + $orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php')); + + expect($checkoutView)->toContain('customer_order_source'); + expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?'); + expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'"); + expect($checkoutController)->toContain('customer_order_source'); + expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\''); + expect($checkoutRepository)->toContain('customer_order_source_comment'); + expect($orderDetail)->toContain('getCustomerOrderSourceLabel'); + + $shoppingOrder = (new ShoppingOrder)->forceFill([ + 'customer_order_source' => 'social_media', + ]); + + expect($shoppingOrder->getCustomerOrderSourceLabel())->toBe('Social Media'); +}); diff --git a/tests/Unit/Services/BackofficeDashboardServiceTest.php b/tests/Unit/Services/BackofficeDashboardServiceTest.php new file mode 100644 index 0000000..186381e --- /dev/null +++ b/tests/Unit/Services/BackofficeDashboardServiceTest.php @@ -0,0 +1,409 @@ +increments('id'); + $table->string('first_name')->nullable(); + $table->string('last_name')->nullable(); + }); + + Schema::create('user_levels', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->unsignedInteger('pos')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + + Schema::create('users', function ($table) { + $table->increments('id'); + $table->string('email')->unique(); + $table->string('password'); + $table->unsignedInteger('account_id')->nullable(); + $table->unsignedInteger('m_level')->nullable(); + $table->unsignedInteger('m_sponsor')->nullable(); + $table->boolean('active')->default(false); + $table->timestamp('active_date')->nullable(); + $table->unsignedTinyInteger('admin')->default(0); + $table->unsignedTinyInteger('wizard')->default(0); + $table->unsignedTinyInteger('blocked')->default(0); + $table->char('lang', 2)->default('de'); + $table->timestamp('payment_account')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_abos', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_id')->nullable(); + $table->unsignedInteger('member_id')->nullable(); + $table->char('is_for', 2)->nullable(); + $table->boolean('active')->default(true); + $table->unsignedTinyInteger('status')->default(2); + $table->date('start_date')->nullable(); + $table->date('next_date')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_abo_items', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_abo_id'); + $table->unsignedInteger('product_id')->nullable(); + $table->unsignedTinyInteger('comp')->nullable(); + $table->unsignedInteger('qty')->default(1); + $table->timestamps(); + }); + + Schema::create('user_abo_orders', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_abo_id'); + $table->unsignedInteger('shopping_order_id'); + $table->unsignedTinyInteger('status')->default(2); + $table->boolean('paid')->default(true); + $table->timestamps(); + }); + + Schema::create('shopping_orders', function ($table) { + $table->increments('id'); + $table->boolean('is_abo')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('shopping_payments', function ($table) { + $table->increments('id'); + $table->unsignedInteger('shopping_order_id'); + $table->string('clearingtype')->nullable(); + $table->string('reference')->nullable(); + $table->integer('amount')->nullable(); + $table->string('currency')->nullable(); + $table->timestamps(); + }); + + Schema::create('payment_transactions', function ($table) { + $table->increments('id'); + $table->unsignedInteger('shopping_payment_id'); + $table->string('request')->nullable(); + $table->unsignedInteger('errorcode')->nullable(); + $table->string('errormessage')->nullable(); + $table->string('customermessage')->nullable(); + $table->timestamps(); + }); + + Schema::create('user_sales_volumes', function ($table) { + $table->increments('id'); + $table->unsignedInteger('user_id'); + $table->unsignedInteger('shopping_order_id')->nullable(); + $table->unsignedTinyInteger('month')->nullable(); + $table->unsignedSmallInteger('year')->nullable(); + $table->decimal('month_KP_points', 13, 2)->nullable(); + $table->decimal('month_shop_points', 13, 2)->nullable(); + $table->decimal('month_total_net', 13, 2)->nullable(); + $table->decimal('month_shop_total_net', 13, 2)->nullable(); + $table->timestamps(); + }); + + Schema::create('backoffice_statistics_snapshots', function ($table) { + $table->id(); + $table->unsignedInteger('user_id'); + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->json('payload'); + $table->timestamp('calculated_at')->nullable(); + $table->timestamps(); + }); +}); + +it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function () { + UserLevel::forceCreate([ + 'id' => 1, + 'name' => 'Partner', + 'pos' => 1, + 'active' => true, + ]); + + $root = User::forceCreate([ + 'email' => 'root@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'admin' => 1, + ]); + + $lineOne = User::forceCreate([ + 'email' => 'line-one@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $root->id, + 'm_level' => 1, + 'active_date' => '2026-05-03 00:00:00', + 'payment_account' => '2030-01-01 00:00:00', + ]); + + $lineTwo = User::forceCreate([ + 'email' => 'line-two@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $lineOne->id, + 'm_level' => 1, + 'active_date' => '2026-04-03 00:00:00', + 'payment_account' => '2030-01-01 00:00:00', + ]); + + User::forceCreate([ + 'email' => 'expired-line-one@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $root->id, + 'm_level' => 1, + 'active_date' => '2026-01-03 00:00:00', + 'payment_account' => '2020-01-01 00:00:00', + ]); + + $sponsor = $lineTwo; + + foreach (range(3, 9) as $lineNumber) { + $sponsor = User::forceCreate([ + 'email' => 'line-'.$lineNumber.'@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'm_sponsor' => $sponsor->id, + 'm_level' => 1, + 'active_date' => '2026-04-03 00:00:00', + 'payment_account' => '2030-01-01 00:00:00', + ]); + } + + $activePartnerAbo = UserAbo::forceCreate([ + 'user_id' => $lineOne->id, + 'member_id' => $lineOne->id, + 'is_for' => 'me', + 'active' => true, + 'status' => 2, + 'start_date' => '2026-05-04', + 'next_date' => '2026-06-01', + ]); + + UserAbo::forceCreate([ + 'user_id' => $lineTwo->id, + 'member_id' => $lineOne->id, + 'is_for' => 'ot', + 'active' => true, + 'status' => 2, + 'start_date' => '2026-05-05', + 'next_date' => '2026-06-01', + ]); + + $pausedPartnerAbo = UserAbo::forceCreate([ + 'user_id' => $lineOne->id, + 'member_id' => $lineOne->id, + 'is_for' => 'me', + 'active' => true, + 'status' => 3, + 'start_date' => '2026-04-15', + 'next_date' => '2026-06-02', + ]); + + $aboOrderId = DB::table('shopping_orders')->insertGetId([ + 'is_abo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $singleOrderId = DB::table('shopping_orders')->insertGetId([ + 'is_abo' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $failedAboOrderId = DB::table('shopping_orders')->insertGetId([ + 'is_abo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $failedAboPaymentId = DB::table('shopping_payments')->insertGetId([ + 'shopping_order_id' => $failedAboOrderId, + 'clearingtype' => 'cc', + 'reference' => 'abo-failed', + 'amount' => 9900, + 'currency' => 'EUR', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payment_transactions')->insert([ + 'shopping_payment_id' => $failedAboPaymentId, + 'request' => 'authorization', + 'errorcode' => 902, + 'errormessage' => 'Bank hat abgelehnt', + 'customermessage' => 'Bitte Zahlungsmittel prüfen', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_abo_orders')->insert([ + 'user_abo_id' => $pausedPartnerAbo->id, + 'shopping_order_id' => $failedAboOrderId, + 'status' => 3, + 'paid' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + UserSalesVolume::forceCreate([ + 'user_id' => $lineOne->id, + 'shopping_order_id' => $aboOrderId, + 'month' => 5, + 'year' => 2026, + 'month_KP_points' => 400, + 'month_shop_points' => 700, + 'month_total_net' => 100, + 'month_shop_total_net' => 200, + ]); + + UserSalesVolume::forceCreate([ + 'user_id' => $lineTwo->id, + 'shopping_order_id' => $singleOrderId, + 'month' => 5, + 'year' => 2026, + 'month_KP_points' => 200, + 'month_shop_points' => 300, + 'month_total_net' => 50, + 'month_shop_total_net' => 60, + ]); + + $overview = (new BackofficeDashboardService)->overview($root, 5, 2026); + + expect($overview['lines'][1]['consultants'])->toBe(2); + expect($overview['lines'][1]['new_partners'])->toBe(1); + expect($overview['lines'][1]['team_partner_abos'])->toBe(2); + expect($overview['lines'][1]['team_partner_abos_new'])->toBe(1); + expect($overview['lines'][1]['team_customer_abos'])->toBe(1); + expect($overview['lines'][1]['team_customer_abos_new'])->toBe(1); + expect($overview['lines'][1]['own_points'])->toBe(400.0); + expect($overview['lines'][1]['external_points'])->toBe(700.0); + expect($overview['lines'][1]['customer_abo_points'])->toBe(700.0); + expect($overview['lines'][1]['customer_single_order_points'])->toBe(0.0); + expect($overview['lines'][1]['customer_other_points'])->toBe(0.0); + expect($overview['lines'][1]['total_points'])->toBe(1100.0); + expect($overview['lines'][1]['shop_1000'])->toBe(1); + expect($overview['lines'][2]['consultants'])->toBe(1); + expect($overview['lines'])->toHaveKey(9); + expect($overview['lines'])->not->toHaveKey(10); + expect($overview['lines'][9]['consultants'])->toBe(1); + expect($overview['totals']['total_points'])->toBe(1600.0); + + $details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'total_points', 5, 2026); + + expect($details['line_label'])->toBe('Alle Linien'); + expect($details['rows'])->toHaveCount(2); + expect($details['summary']['own_points'])->toBe(600.0); + expect($details['summary']['external_points'])->toBe(1000.0); + expect($details['summary']['customer_abo_points'])->toBe(700.0); + expect($details['summary']['customer_single_order_points'])->toBe(300.0); + expect($details['summary']['customer_other_points'])->toBe(0.0); + expect($details['summary']['total_points'])->toBe(1600.0); + + $shop1000Details = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 0, 'shop_1000', 5, 2026); + + expect($shop1000Details['rows'])->toHaveCount(1); + expect($shop1000Details['rows'][0]['career_level'])->toBe('Partner'); + expect($shop1000Details['rows'][0]['own_points'])->toBe(400.0); + expect($shop1000Details['rows'][0]['customer_abo_points'])->toBe(700.0); + + $consultantDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'consultants', 5, 2026); + + expect($consultantDetails['rows'])->toHaveCount(2); + expect($consultantDetails['summary']['count'])->toBe(2); + expect(collect($consultantDetails['rows'])->pluck('is_account_active')->all())->toBe([true, false]); + expect(collect($consultantDetails['rows'])->pluck('career_level')->all())->toBe(['Partner', 'Partner']); + + $aboDetails = (new BackofficeDrilldownService(new BackofficeDashboardService))->details($root, 1, 'team_partner_abos', 5, 2026); + + expect($aboDetails['rows'][0]['start_date'])->toBe('04.05.2026'); + expect($aboDetails['rows'][0]['is_new_this_month'])->toBeTrue(); + expect($aboDetails['rows'])->toHaveCount(2); + expect(collect($aboDetails['rows'])->firstWhere('abo_id', $activePartnerAbo->id)['status_reason'])->toBeNull(); + expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['status_reason'])->toBe('[902] Bank hat abgelehnt'); + expect(collect($aboDetails['rows'])->firstWhere('abo_id', $pausedPartnerAbo->id)['is_new_this_month'])->toBeFalse(); +}); + +it('verwendet gespeicherte Snapshots fuer abgeschlossene Monate', function () { + $root = User::forceCreate([ + 'email' => 'snapshot-root@test.test', + 'password' => 'secret', + 'lang' => 'de', + 'admin' => 1, + ]); + + $payload = [ + 'month' => 4, + 'year' => 2026, + 'metric_labels' => [], + 'lines' => [ + 1 => [ + 'line' => 1, + 'label' => 'Linie 1', + 'user_ids' => [], + 'consultants' => 99, + 'new_partners' => 0, + 'team_partner_abos' => 0, + 'team_partner_abos_new' => 0, + 'team_customer_abos' => 0, + 'team_customer_abos_new' => 0, + 'own_points' => 0, + 'external_points' => 0, + 'customer_abo_points' => 0, + 'customer_single_order_points' => 0, + 'customer_other_points' => 0, + 'total_points' => 0, + 'turnover_net' => 0, + 'shop_1000' => 0, + ], + ], + 'totals' => [ + 'label' => 'Summe', + 'consultants' => 99, + ], + ]; + + BackofficeStatisticsSnapshot::create([ + 'user_id' => $root->id, + 'year' => 2026, + 'month' => 4, + 'payload' => $payload, + 'calculated_at' => now(), + ]); + + $overview = (new BackofficeDashboardService)->overview($root, 4, 2026); + + expect($overview['lines'][1]['consultants'])->toBe(99); + expect($overview['_meta']['source'])->toBe('snapshot'); + expect($overview['_meta']['source_label'])->toBe('Snapshot'); +});