mivita/app/Services/Backoffice/BackofficeDashboardService.php
Kevin ce07bee589 Backoffice-Statistik: Neue-Kundenabos-Ranking & Führungskräfte-Abos
Zwei neue Listen unter der Team-Übersicht: Ranking der Berater nach neuen
Kundenabos im Monat (inkl. Gesamtbestand und Linie) sowie eigene und
Kundenabos aller Führungskräfte ab Stufe Vertriebsleiter mit Gesamtsumme.
Beides fließt mit in die Monats-Snapshots ein.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:24:01 +00:00

571 lines
20 KiB
PHP

<?php
namespace App\Services\Backoffice;
use App\Models\BackofficeStatisticsSnapshot;
use App\Models\UserAbo;
use App\Models\UserLevel;
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;
private const LEADER_LEVEL_NAME = 'Vertriebsleiter';
/**
* @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,
'new_customer_abos' => $this->newCustomerAboRanking($lineBuckets, $month, $year),
'leader_abos' => $this->leaderAboOverview($lineBuckets),
];
}
/**
* @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', 'user_level'])
->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();
}
/**
* IDs der gesamten Downline, aber nur aktive Berater
* (gleiche Definition wie die "Berater"-Spalte der Backoffice-Statistik).
*
* @return int[]
*/
public function activeTeamUserIds(int $rootUserId): array
{
return collect($this->lineBuckets($rootUserId))
->flatMap(fn (Collection $users) => $users)
->filter(fn (User $user) => $this->isActiveConsultant($user))
->map(fn (User $user) => (int) $user->id)
->values()
->all();
}
/**
* Ein aktiver Berater hat eine Mitgliedsebene und ein gesetztes Abrechnungskonto.
* Maßgeblich für die "Berater"-Zählung in den Team-Übersichten.
*/
public function isActiveConsultant(User $user): bool
{
return $user->m_level !== null && $user->payment_account !== null;
}
/**
* @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) => $this->isActiveConsultant($user))
->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(SalesVolumePoints::ownPoints().' as own_points')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->selectRaw(SalesVolumePoints::customerAboPoints().' as customer_abo_points')
->selectRaw(SalesVolumePoints::customerSingleOrderPoints().' as customer_single_order_points')
->selectRaw(SalesVolumePoints::customerOtherPoints().' as customer_other_points')
->selectRaw(SalesVolumePoints::turnoverNet().' 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),
];
}
/**
* Anzahl der Neupartner (aktiver Account, im Monat aktiv geworden).
* Konsistent mit der Drilldown-Liste {@see BackofficeDrilldownService::newPartnerRows()}.
*
* @param int[] $userIds
*/
public function newPartnersCount(array $userIds, int $month, int $year): int
{
if ($userIds === []) {
return 0;
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return User::query()
->whereIn('id', $userIds)
->whereNotNull('m_level')
->whereNotNull('payment_account')
->where('payment_account', '>', now())
->whereBetween('active_date', [$startDate, $endDate])
->count();
}
/**
* Ranking: Wer im Team hat im Monat wie viele neue Kundenabos abgeschlossen?
* Sortiert nach Anzahl absteigend (nicht nach Linie), Linie wird je Berater mitgeliefert,
* dazu der aktuelle Gesamtbestand an Kundenabos des Beraters.
* Zählweise identisch zu team_customer_abos_new {@see newAboCount()}.
*
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, abo_count: int, total_abo_count: int}>
*/
private function newCustomerAboRanking(array $lineBuckets, int $month, int $year): array
{
$usersByLine = $this->usersWithLine($lineBuckets);
if ($usersByLine === []) {
return [];
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
$counts = $this->activeAboQuery()
->whereIn('member_id', array_keys($usersByLine))
->where('is_for', 'ot')
->whereBetween('start_date', [$startDate, $endDate])
->selectRaw('member_id, COUNT(*) as abo_count')
->groupBy('member_id')
->pluck('abo_count', 'member_id');
$totalCounts = $this->customerAboCounts($counts->keys()->all());
return collect($usersByLine)
->filter(fn (array $entry, int $userId) => $counts->has($userId))
->map(fn (array $entry, int $userId) => [
'user_id' => $userId,
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'abo_count' => (int) $counts->get($userId),
'total_abo_count' => (int) $totalCounts->get($userId, 0),
])
->sortByDesc('abo_count')
->values()
->all();
}
/**
* Abos der Führungskräfte (ab Stufe Vertriebsleiter) im Team: Gesamtzahlen plus
* Liste aller Führungskräfte inklusive Linie, eigener Abos und Kundenabos.
*
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
* @return array{level_name: string, total: int, total_own: int, total_customer: int, rows: array<int, array{user_id: int, name: string, career_level: string, line: int, own_abo_count: int, customer_abo_count: int}>}
*/
private function leaderAboOverview(array $lineBuckets): array
{
$leaderMinPos = UserLevel::query()
->where('name', self::LEADER_LEVEL_NAME)
->value('pos');
$leadersByLine = $leaderMinPos === null ? [] : collect($this->usersWithLine($lineBuckets))
->filter(fn (array $entry) => ($entry['user']->user_level?->pos ?? 0) >= $leaderMinPos)
->all();
if ($leadersByLine === []) {
return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'rows' => []];
}
$leaderIds = array_keys($leadersByLine);
$ownCounts = $this->activeAboQuery()
->whereIn('user_id', $leaderIds)
->where('is_for', 'me')
->selectRaw('user_id, COUNT(*) as abo_count')
->groupBy('user_id')
->pluck('abo_count', 'user_id');
$customerCounts = $this->customerAboCounts($leaderIds);
$rows = collect($leadersByLine)
->map(fn (array $entry, int $userId) => [
'user_id' => $userId,
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'own_abo_count' => (int) $ownCounts->get($userId, 0),
'customer_abo_count' => (int) $customerCounts->get($userId, 0),
])
->sortByDesc(fn (array $row) => $row['own_abo_count'] + $row['customer_abo_count'])
->values();
$totalOwn = (int) $rows->sum('own_abo_count');
$totalCustomer = (int) $rows->sum('customer_abo_count');
return [
'level_name' => self::LEADER_LEVEL_NAME,
'total' => $totalOwn + $totalCustomer,
'total_own' => $totalOwn,
'total_customer' => $totalCustomer,
'rows' => $rows->all(),
];
}
/**
* Aktueller Bestand aktiver Kundenabos je Berater (member_id).
*
* @param int[] $memberIds
* @return \Illuminate\Support\Collection<int, int>
*/
private function customerAboCounts(array $memberIds): Collection
{
if ($memberIds === []) {
return collect();
}
return $this->activeAboQuery()
->whereIn('member_id', $memberIds)
->where('is_for', 'ot')
->selectRaw('member_id, COUNT(*) as abo_count')
->groupBy('member_id')
->pluck('abo_count', 'member_id');
}
/**
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
* @return array<int, array{user: \App\User, line: int}>
*/
private function usersWithLine(array $lineBuckets): array
{
$usersByLine = [];
foreach ($lineBuckets as $line => $users) {
foreach ($users as $user) {
$usersByLine[(int) $user->id] = ['user' => $user, 'line' => $line];
}
}
return $usersByLine;
}
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 : '-');
}
/**
* Anzahl der Berater mit >= 1000 Punkten Kundenumsatz (KP + Shop) im Monat.
*
* @param int[] $userIds
*/
public 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(SalesVolumePoints::totalPoints().' as total_points')
->groupBy('user_id')
->havingRaw(SalesVolumePoints::totalPoints().' >= 1000')
->get()
->count();
}
}