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>
This commit is contained in:
Kevin 2026-07-03 16:24:01 +00:00
parent 0660f4e0a0
commit d50be7135a
3 changed files with 417 additions and 1 deletions

View file

@ -4,6 +4,7 @@ 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;
@ -14,6 +15,8 @@ class BackofficeDashboardService
{
private const MAX_DEPTH_SAFETY_LIMIT = 100;
private const LEADER_LEVEL_NAME = 'Vertriebsleiter';
/**
* @return array<string, string>
*/
@ -94,6 +97,8 @@ class BackofficeDashboardService
'metric_labels' => $this->metricLabels(),
'lines' => $lines,
'totals' => $totals,
'new_customer_abos' => $this->newCustomerAboRanking($lineBuckets, $month, $year),
'leader_abos' => $this->leaderAboOverview($lineBuckets),
];
}
@ -128,7 +133,7 @@ class BackofficeDashboardService
for ($line = 1; $line <= self::MAX_DEPTH_SAFETY_LIMIT && $currentSponsorIds !== []; $line++) {
$users = User::query()
->with('account')
->with(['account', 'user_level'])
->whereIn('m_sponsor', $currentSponsorIds)
->whereNotIn('id', $visitedUserIds)
->whereColumn('id', '!=', 'm_sponsor')
@ -395,6 +400,156 @@ class BackofficeDashboardService
->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.
*

View file

@ -212,6 +212,144 @@
</div>
</div>
@php
$newCustomerAbos = $statistics['new_customer_abos'] ?? null;
$leaderAbos = $statistics['leader_abos'] ?? null;
@endphp
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">Neue Kundenabos</h5>
<div class="text-muted small">{{ $selectedMonthName }} {{ $selectedYear }} · nach Anzahl sortiert</div>
</div>
<span class="display-4 font-weight-bold text-primary mb-0">
{{ number_format((float) ($statistics['totals']['team_customer_abos_new'] ?? 0), 0, ',', '.') }}
</span>
</div>
@if ($newCustomerAbos === null)
<div class="card-body">
<p class="text-muted mb-0">
Für diesen Monat liegt noch ein älterer Snapshot ohne diese Liste vor.
</p>
</div>
@elseif (count($newCustomerAbos) === 0)
<div class="card-body">
<p class="text-muted mb-0">In diesem Monat wurden keine neuen Kundenabos abgeschlossen.</p>
</div>
@else
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Berater</th>
<th class="text-right">Gesamt</th>
<th class="text-right">Neu</th>
</tr>
</thead>
<tbody>
@foreach ($newCustomerAbos as $row)
<tr>
<td>
<div class="font-weight-semibold">{{ $row['name'] }}</div>
<div class="text-muted small">Linie {{ $row['line'] }} · {{ $row['career_level'] }}</div>
</td>
<td class="text-right align-middle">
<span class="badge badge-pill badge-outline-default backoffice-statistics-click-badge">
{{ number_format((float) ($row['total_abo_count'] ?? 0), 0, ',', '.') }}
</span>
</td>
<td class="text-right align-middle">
<span class="badge badge-pill badge-success backoffice-statistics-click-badge">
+{{ number_format((float) $row['abo_count'], 0, ',', '.') }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">Abos Führungskräfte</h5>
<div class="text-muted small">ab Stufe {{ $leaderAbos['level_name'] ?? 'Vertriebsleiter' }} · aktueller Stand</div>
</div>
<div class="text-right">
<span class="display-4 font-weight-bold text-primary mb-0">
{{ number_format((float) ($leaderAbos['total'] ?? 0), 0, ',', '.') }}
</span>
@if ($leaderAbos !== null)
<div class="text-muted small">
{{ number_format((float) ($leaderAbos['total_own'] ?? 0), 0, ',', '.') }} eigene ·
{{ number_format((float) ($leaderAbos['total_customer'] ?? 0), 0, ',', '.') }} Kunden
</div>
@endif
</div>
</div>
@if ($leaderAbos === null)
<div class="card-body">
<p class="text-muted mb-0">
Für diesen Monat liegt noch ein älterer Snapshot ohne diese Liste vor.
</p>
</div>
@elseif (count($leaderAbos['rows']) === 0)
<div class="card-body">
<p class="text-muted mb-0">
Im Team gibt es aktuell keine Führungskräfte ab Stufe {{ $leaderAbos['level_name'] }}.
</p>
</div>
@else
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Führungskraft</th>
<th class="text-right">Eigene Abos</th>
<th class="text-right">Kundenabos</th>
</tr>
</thead>
<tbody>
@foreach ($leaderAbos['rows'] as $row)
<tr>
<td>
<div class="font-weight-semibold">{{ $row['name'] }}</div>
<div class="text-muted small">Linie {{ $row['line'] }} · {{ $row['career_level'] }}</div>
</td>
<td class="text-right align-middle">
@if (($row['own_abo_count'] ?? 0) > 0)
<span class="badge badge-pill badge-outline-default backoffice-statistics-click-badge">
{{ number_format((float) $row['own_abo_count'], 0, ',', '.') }}
</span>
@else
<span class="badge badge-pill badge-light backoffice-statistics-empty-badge">0</span>
@endif
</td>
<td class="text-right align-middle">
@if (($row['customer_abo_count'] ?? 0) > 0)
<span class="badge badge-pill badge-outline-default backoffice-statistics-click-badge">
{{ number_format((float) $row['customer_abo_count'], 0, ',', '.') }}
</span>
@else
<span class="badge badge-pill badge-light backoffice-statistics-empty-badge">0</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card h-100">

View file

@ -0,0 +1,123 @@
<?php
use App\Models\UserAbo;
use App\Models\UserLevel;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function bslMakeLevel(string $name, int $pos): UserLevel
{
return UserLevel::forceCreate([
'name' => $name,
'pos' => $pos,
'active' => 1,
'default' => 0,
]);
}
function bslMakeUser(?int $sponsorId = null, ?int $levelId = null): User
{
return User::forceCreate([
'email' => 'bsl-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => $levelId ?? 1,
'm_sponsor' => $sponsorId,
'active_date' => '2026-01-15',
'payment_account' => now()->addYear(),
]);
}
function bslMakeAbo(int $userId, string $isFor, string $startDate, int $status = 2): UserAbo
{
return UserAbo::create([
'user_id' => $userId,
'member_id' => $userId,
'shopping_user_id' => 0,
'email' => 'bsl-abo-'.uniqid('', true).'@example.com',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => true,
'status' => $status,
'abo_interval' => 5,
'start_date' => $startDate,
'last_date' => $startDate,
'next_date' => '2026-12-10',
]);
}
it('rankt neue Kundenabos pro Berater absteigend inklusive Linie', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id);
$ben = bslMakeUser($anna->id);
bslMakeAbo($anna->id, 'ot', '2026-06-10');
bslMakeAbo($ben->id, 'ot', '2026-06-05');
bslMakeAbo($ben->id, 'ot', '2026-06-20');
bslMakeAbo($ben->id, 'ot', '2026-05-10'); // anderer Monat, zaehlt nicht
bslMakeAbo($anna->id, 'me', '2026-06-12'); // Eigenabo, zaehlt nicht
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
$ranking = $overview['new_customer_abos'];
expect($ranking)->toHaveCount(2)
->and($ranking[0]['user_id'])->toBe($ben->id)
->and($ranking[0]['abo_count'])->toBe(2)
->and($ranking[0]['total_abo_count'])->toBe(3) // inkl. Mai-Abo
->and($ranking[0]['line'])->toBe(2)
->and($ranking[1]['user_id'])->toBe($anna->id)
->and($ranking[1]['abo_count'])->toBe(1)
->and($ranking[1]['total_abo_count'])->toBe(1)
->and($ranking[1]['line'])->toBe(1);
});
it('zaehlt Fuehrungskraefte-Abos erst ab Stufe Vertriebsleiter inklusive Linie und Summe', function (): void {
$berater = bslMakeLevel('Berater', 3);
$vertriebsleiter = bslMakeLevel('Vertriebsleiter', 6);
$bronze = bslMakeLevel('Bronze Member', 7);
$root = bslMakeUser();
$anna = bslMakeUser($root->id, $vertriebsleiter->id);
$ben = bslMakeUser($root->id, $berater->id);
$carla = bslMakeUser($anna->id, $bronze->id);
bslMakeAbo($anna->id, 'me', '2026-06-01');
bslMakeAbo($anna->id, 'me', '2026-04-01');
bslMakeAbo($anna->id, 'me', '2026-03-01', 4); // storniert, zaehlt nicht
bslMakeAbo($anna->id, 'ot', '2026-06-01'); // Kundenabo von Anna
bslMakeAbo($carla->id, 'ot', '2026-05-01'); // Kundenabo von Carla
bslMakeAbo($ben->id, 'me', '2026-06-01'); // unterhalb Vertriebsleiter
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
$leaderAbos = $overview['leader_abos'];
expect($leaderAbos['total'])->toBe(4)
->and($leaderAbos['total_own'])->toBe(2)
->and($leaderAbos['total_customer'])->toBe(2)
->and($leaderAbos['rows'])->toHaveCount(2)
->and($leaderAbos['rows'][0]['user_id'])->toBe($anna->id)
->and($leaderAbos['rows'][0]['own_abo_count'])->toBe(2)
->and($leaderAbos['rows'][0]['customer_abo_count'])->toBe(1)
->and($leaderAbos['rows'][0]['line'])->toBe(1)
->and($leaderAbos['rows'][0]['career_level'])->toBe('Vertriebsleiter')
->and($leaderAbos['rows'][1]['user_id'])->toBe($carla->id)
->and($leaderAbos['rows'][1]['own_abo_count'])->toBe(0)
->and($leaderAbos['rows'][1]['customer_abo_count'])->toBe(1)
->and($leaderAbos['rows'][1]['line'])->toBe(2);
});
it('liefert eine leere Fuehrungskraefte-Liste, wenn die Stufe Vertriebsleiter nicht existiert', function (): void {
$root = bslMakeUser();
bslMakeUser($root->id);
$overview = app(BackofficeDashboardService::class)->overview($root, 6, 2026);
expect($overview['leader_abos']['total'])->toBe(0)
->and($overview['leader_abos']['rows'])->toBe([]);
});