User Statistik

This commit is contained in:
Kevin Adametz 2026-05-18 17:23:28 +02:00
parent 70240d2b6a
commit 53bdba33cd
24 changed files with 2633 additions and 9 deletions

View file

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands;
use App\Models\BackofficeStatisticsSnapshot;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class BackofficeStoreStatisticsSnapshots extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backoffice:store-statistics-snapshots
{--user= : Nur einen bestimmten User berechnen (user_id)}
{--month= : Nur einen bestimmten Monat berechnen}
{--year= : Nur ein bestimmtes Jahr berechnen}
{--force : Bereits vorhandene Snapshots überschreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Speichert Backoffice-Statistik-Snapshots fuer abgeschlossene Monate';
/**
* Execute the console command.
*/
public function handle(BackofficeDashboardService $dashboardService): int
{
$months = $this->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<array{int, int}>
*/
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());
}
}

View file

@ -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');

View file

@ -0,0 +1,279 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\Services\HTMLHelper;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BackofficeStatisticsController extends Controller
{
private const SESSION_MONTH_KEY = 'backoffice_statistics_month';
private const SESSION_YEAR_KEY = 'backoffice_statistics_year';
public function __construct(
private BackofficeDashboardService $dashboardService,
private BackofficeDrilldownService $drilldownService
) {
$this->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<string, mixed> $row
* @return array<int, mixed>
*/
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<string, mixed> $row
* @return array<int, mixed>
*/
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<string, mixed> $details
* @return array<int, mixed>
*/
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', '', '', '', ''];
}
}

View file

@ -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',

View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class BackofficeStatisticsSnapshot extends Model
{
protected $table = 'backoffice_statistics_snapshots';
protected $fillable = [
'user_id',
'year',
'month',
'payload',
'calculated_at',
];
protected function casts(): array
{
return [
'user_id' => 'int',
'year' => 'int',
'month' => 'int',
'payload' => 'array',
'calculated_at' => 'datetime',
];
}
}

View file

@ -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<string, string>
*/
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',

View file

@ -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;

View file

@ -0,0 +1,365 @@
<?php
namespace App\Services\Backoffice;
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class BackofficeDashboardService
{
private const MAX_DEPTH_SAFETY_LIMIT = 100;
/**
* @return array<string, string>
*/
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<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
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<string, string>, lines: array<int, array<string, mixed>>, totals: array<string, mixed>}
*/
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<string, mixed> $overview
* @return array<string, mixed>
*/
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<int, \Illuminate\Support\Collection<int, \App\User>>
*/
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<int, \App\User> $users
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $totals
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
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<int, \App\User> $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<int, \App\User> $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();
}
}

View file

@ -0,0 +1,331 @@
<?php
namespace App\Services\Backoffice;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class BackofficeDrilldownService
{
public function __construct(private BackofficeDashboardService $dashboardService) {}
/**
* @return array{metric: string, metric_label: string, line: int, line_label: string, month: int, year: int, rows: array<int, array<string, mixed>>, summary: array<string, mixed>}
*/
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<int, array<string, mixed>> $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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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');
}
}