Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)

Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert

Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-24 13:51:16 +00:00
parent 657d4bd5b7
commit ac3bd6e8ea
136 changed files with 7610 additions and 2688 deletions

View file

@ -50,7 +50,11 @@ class BackofficeDashboardService
->first();
if ($snapshot) {
return $this->withMeta($snapshot->payload, 'snapshot', $snapshot->calculated_at?->format('d.m.Y H:i'));
return $this->withMeta(
$this->overlayCurrentAboStock($user, $snapshot->payload),
'snapshot',
$snapshot->calculated_at?->format('d.m.Y H:i')
);
}
}
@ -89,6 +93,10 @@ class BackofficeDashboardService
$totals = $this->addToTotals($totals, $row);
}
// 1000-Punkte-Shop: der VIP selbst zählt mit (Kundenwunsch), obwohl er in
// keiner Linie steckt — nur diese Kennzahl bekommt ihn zusätzlich.
$totals['shop_1000'] += $this->shop1000Count([(int) $user->id], $month, $year);
$totals['label'] = 'Summe';
return [
@ -97,11 +105,91 @@ class BackofficeDashboardService
'metric_labels' => $this->metricLabels(),
'lines' => $lines,
'totals' => $totals,
'new_customer_abos' => $this->newCustomerAboRanking($lineBuckets, $month, $year),
'non_payline_points' => $this->nonPaylinePointsSum($totals['user_ids'], $month, $year),
'new_customer_abos' => $this->newCustomerAboRanking($user, $lineBuckets, $month, $year),
'leader_abos' => $this->leaderAboOverview($lineBuckets),
'top_customer_abos' => $this->topCustomerAboRanking($user, $lineBuckets, $month, $year),
];
}
/**
* Überlagert einen Snapshot-Payload mit den tagesaktuellen Abo-Bestandszahlen
* (Kundenentscheid F7): Teamabos/Teamkundenabos je Linie + Summe sowie die
* Führungskräfte- und Top-10-Listen sind Bestands-Kennzahlen ohne Monatsbezug
* und werden live nachgerechnet. Monatsbezogene Werte (Punkte, Umsätze,
* Neupartner, "+N neu", neue Kundenabos) bleiben eingefroren.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function overlayCurrentAboStock(User $user, array $payload): array
{
$lineBuckets = $this->lineBuckets($user->id);
$totalPartnerAbos = 0;
$totalCustomerAbos = 0;
$snapshotLines = collect($payload['lines'] ?? [])
->map(fn ($row, $key) => (int) ($row['line'] ?? $key))
->values()
->all();
foreach ($payload['lines'] ?? [] as $key => $row) {
$line = (int) ($row['line'] ?? $key);
$userIds = ($lineBuckets[$line] ?? collect())->pluck('id')->map(fn ($id) => (int) $id)->all();
$partnerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
$customerAbos = $userIds === [] ? 0 : $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
$payload['lines'][$key]['team_partner_abos'] = $partnerAbos;
$payload['lines'][$key]['team_customer_abos'] = $customerAbos;
$totalPartnerAbos += $partnerAbos;
$totalCustomerAbos += $customerAbos;
}
// Linien, die erst nach dem Snapshot entstanden sind (Team ist heute tiefer),
// tragen ebenfalls aktuellen Abo-Bestand: als neue Zeile anhängen, Monatswerte 0.
foreach ($lineBuckets as $line => $users) {
if (in_array((int) $line, $snapshotLines, true)) {
continue;
}
$userIds = $users->pluck('id')->map(fn ($id) => (int) $id)->all();
if ($userIds === []) {
continue;
}
$partnerAbos = $this->activeAboQuery()->whereIn('user_id', $userIds)->where('is_for', 'me')->count();
$customerAbos = $this->activeAboQuery()->whereIn('member_id', $userIds)->where('is_for', 'ot')->count();
if ($partnerAbos === 0 && $customerAbos === 0) {
continue;
}
$row = $this->emptyLine((int) $line, $userIds);
$row['team_partner_abos'] = $partnerAbos;
$row['team_customer_abos'] = $customerAbos;
$payload['lines'][(int) $line] = $row;
$totalPartnerAbos += $partnerAbos;
$totalCustomerAbos += $customerAbos;
}
$payload['lines'] = collect($payload['lines'] ?? [])->sortBy('line')->all();
if (isset($payload['totals'])) {
$payload['totals']['team_partner_abos'] = $totalPartnerAbos;
$payload['totals']['team_customer_abos'] = $totalCustomerAbos;
}
$payload['leader_abos'] = $this->leaderAboOverview($lineBuckets);
$payload['top_customer_abos'] = $this->topCustomerAboRanking($user, $lineBuckets, (int) ($payload['month'] ?? now()->month), (int) ($payload['year'] ?? now()->year));
$payload['abo_stock_live'] = true;
return $payload;
}
/**
* @param array<string, mixed> $overview
* @return array<string, mixed>
@ -123,7 +211,7 @@ class BackofficeDashboardService
}
/**
* @return array<int, \Illuminate\Support\Collection<int, \App\User>>
* @return array<int, Collection<int, User>>
*/
public function lineBuckets(int $rootUserId): array
{
@ -206,7 +294,7 @@ class BackofficeDashboardService
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
* @param Collection<int, User> $users
* @return array<string, mixed>
*/
private function buildLineRow(int $line, Collection $users, int $month, int $year): array
@ -286,7 +374,7 @@ class BackofficeDashboardService
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
* @param Collection<int, User> $users
*/
private function activeConsultants(Collection $users): int
{
@ -296,7 +384,7 @@ class BackofficeDashboardService
}
/**
* @param \Illuminate\Support\Collection<int, \App\User> $users
* @param Collection<int, User> $users
*/
private function newPartners(Collection $users, int $month, int $year): int
{
@ -376,6 +464,27 @@ class BackofficeDashboardService
];
}
/**
* Summe der Punkte des Teams, die nicht in die Payline fließen (nur-KP-Einträge
* wie Neupartner-Einschreibungspunkte) — erklärt die Differenz zwischen den
* Gesamtpunkten hier und der PP-Summe der Struktur-/Provisionsansicht.
*
* @param int[] $userIds
*/
private function nonPaylinePointsSum(array $userIds, int $month, int $year): float
{
if ($userIds === []) {
return 0.0;
}
return (float) UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->selectRaw(SalesVolumePoints::nonPaylinePoints().' as points')
->value('points');
}
/**
* Anzahl der Neupartner (aktiver Account, im Monat aktiv geworden).
* Konsistent mit der Drilldown-Liste {@see BackofficeDrilldownService::newPartnerRows()}.
@ -404,14 +513,15 @@ class BackofficeDashboardService
* 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.
* Der Statistik-Nutzer selbst zählt mit (Kundenwunsch, Linie 0 / "ich").
* 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}>
* @param array<int, Collection<int, User>> $lineBuckets
* @return array<int, array{user_id: int, name: string, career_level: string, line: int, is_self: bool, abo_count: int, total_abo_count: int}>
*/
private function newCustomerAboRanking(array $lineBuckets, int $month, int $year): array
private function newCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
{
$usersByLine = $this->usersWithLine($lineBuckets);
$usersByLine = $this->usersWithLine($lineBuckets, $rootUser);
if ($usersByLine === []) {
return [];
@ -437,6 +547,7 @@ class BackofficeDashboardService
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'is_self' => $entry['line'] === 0,
'abo_count' => (int) $counts->get($userId),
'total_abo_count' => (int) $totalCounts->get($userId, 0),
])
@ -447,10 +558,21 @@ class BackofficeDashboardService
/**
* Abos der Führungskräfte (ab Stufe Vertriebsleiter) im Team: Gesamtzahlen plus
* Liste aller Führungskräfte inklusive Linie, eigener Abos und Kundenabos.
* Liste aller Führungskräfte inklusive Linie, eigener Abos, Kundenabos sowie
* Team-Abos und Team-Kundenabos ihres Teilbaums und der Gesamtsumme aller
* vier Kennzahlen (Kundenwunsch B7).
*
* @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}>}
* Zwei Team-Sichten je Führungskraft (Kundenentscheid 2026-07-24, revidiert F9):
* - team_abo_count / team_customer_abo_count ("direkt"): Teilbaum wird an der
* nächsten Führungskraft abgeschnitten — überlappungsfrei, die Kopf-Summen
* (total_*) bleiben dadurch echte Gesamtwerte ohne Doppelzählung.
* - team_abo_count_total / team_customer_abo_count_total ("gesamt"): komplette
* Downline der Führungskraft; verschachtelte Führungskräfte zählen hier
* mehrfach, deshalb werden diese Spalten nicht aufsummiert.
* total_abo_count_full = eigene + Kundenabos + Team gesamt (Sortierung).
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array{level_name: string, total: int, total_own: int, total_customer: int, total_team: int, total_team_customer: int, rows: array<int, array{user_id: int, name: string, career_level: string, line: int, own_abo_count: int, customer_abo_count: int, team_abo_count: int, team_customer_abo_count: int, team_abo_count_total: int, team_customer_abo_count_total: int, total_abo_count: int, total_abo_count_full: int}>}
*/
private function leaderAboOverview(array $lineBuckets): array
{
@ -458,54 +580,227 @@ class BackofficeDashboardService
->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)
$usersWithLine = $this->usersWithLine($lineBuckets);
$isLeader = fn (User $user): bool => $leaderMinPos !== null && ($user->user_level?->pos ?? 0) >= $leaderMinPos;
$leadersByLine = $leaderMinPos === null ? [] : collect($usersWithLine)
->filter(fn (array $entry) => $isLeader($entry['user']))
->all();
if ($leadersByLine === []) {
return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'rows' => []];
return ['level_name' => self::LEADER_LEVEL_NAME, 'total' => 0, 'total_own' => 0, 'total_customer' => 0, 'total_team' => 0, 'total_team_customer' => 0, 'rows' => []];
}
$leaderIds = array_keys($leadersByLine);
$teamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, $isLeader);
$fullTeamIdsByLeader = $this->teamIdsPerLeader($usersWithLine, $leaderIds, fn (User $user): bool => false);
$allCountIds = array_values(array_unique(array_merge($leaderIds, ...array_values($fullTeamIdsByLeader))));
$ownCounts = $this->activeAboQuery()
->whereIn('user_id', $leaderIds)
->whereIn('user_id', $allCountIds)
->where('is_for', 'me')
->selectRaw('user_id, COUNT(*) as abo_count')
->groupBy('user_id')
->pluck('abo_count', 'user_id');
$customerCounts = $this->customerAboCounts($leaderIds);
$customerCounts = $this->customerAboCounts($allCountIds);
$rows = collect($leadersByLine)
->map(function (array $entry, int $userId) use ($ownCounts, $customerCounts, $teamIdsByLeader, $fullTeamIdsByLeader) {
$teamIds = $teamIdsByLeader[$userId] ?? [];
$fullTeamIds = $fullTeamIdsByLeader[$userId] ?? [];
$own = (int) $ownCounts->get($userId, 0);
$customer = (int) $customerCounts->get($userId, 0);
$team = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0));
$teamCustomer = (int) collect($teamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0));
$teamTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $ownCounts->get($teamUserId, 0));
$teamCustomerTotal = (int) collect($fullTeamIds)->sum(fn (int $teamUserId) => (int) $customerCounts->get($teamUserId, 0));
return [
'user_id' => $userId,
'name' => $this->userName($entry['user']),
'career_level' => $this->careerLevel($entry['user']),
'line' => $entry['line'],
'own_abo_count' => $own,
'customer_abo_count' => $customer,
'team_abo_count' => $team,
'team_customer_abo_count' => $teamCustomer,
'team_abo_count_total' => $teamTotal,
'team_customer_abo_count_total' => $teamCustomerTotal,
'total_abo_count' => $own + $customer + $team + $teamCustomer,
'total_abo_count_full' => $own + $customer + $teamTotal + $teamCustomerTotal,
];
})
->sortByDesc('total_abo_count_full')
->values();
$totalOwn = (int) $rows->sum('own_abo_count');
$totalCustomer = (int) $rows->sum('customer_abo_count');
$totalTeam = (int) $rows->sum('team_abo_count');
$totalTeamCustomer = (int) $rows->sum('team_customer_abo_count');
return [
'level_name' => self::LEADER_LEVEL_NAME,
'total' => $totalOwn + $totalCustomer + $totalTeam + $totalTeamCustomer,
'total_own' => $totalOwn,
'total_customer' => $totalCustomer,
'total_team' => $totalTeam,
'total_team_customer' => $totalTeamCustomer,
'rows' => $rows->all(),
];
}
/**
* Teilbaum-User-IDs je Führungskraft aus der gemeinsamen Sponsor-Map
* (ein Durchlauf über die bereits geladene Downline, keine Abfragen pro
* Führungskraft). Der Abstieg stoppt an jedem Kind, für das $isLeader true
* liefert (dieses und sein Team werden übersprungen); mit einem immer-false
* Callable ergibt sich die komplette Downline.
*
* @param array<int, array{user: User, line: int}> $usersWithLine
* @param int[] $leaderIds
* @return array<int, int[]>
*/
private function teamIdsPerLeader(array $usersWithLine, array $leaderIds, callable $isLeader): array
{
$childrenBySponsor = [];
foreach ($usersWithLine as $entry) {
$childrenBySponsor[(int) $entry['user']->m_sponsor][] = $entry['user'];
}
$teamIdsByLeader = [];
foreach ($leaderIds as $leaderId) {
$teamIds = [];
$queue = $childrenBySponsor[$leaderId] ?? [];
while ($queue !== []) {
$child = array_shift($queue);
if ($isLeader($child)) {
continue;
}
$teamIds[] = (int) $child->id;
foreach ($childrenBySponsor[(int) $child->id] ?? [] as $grandChild) {
$queue[] = $grandChild;
}
}
$teamIdsByLeader[$leaderId] = $teamIds;
}
return $teamIdsByLeader;
}
/**
* Top-10-Ranglisten der Kundenabos im eigenen Team (Kundenwunsch B5/B6,
* "unabhängig vom Incentive" = aktueller Gesamtbestand aktiver Kundenabos):
*
* - by_count: nach Anzahl aktiver Kundenabos je Berater
* - by_points: nach Umsatz mit Ist (im gewählten Monat abgerechnete
* Kundenabo-Punkte aus user_sales_volumes, Hauptwert und Sortierung —
* Kundenentscheid 2026-07-24) und Soll (Abo-Punkte-Bestand, Produktpunkte
* × Menge der laufenden Kundenabos) als Zweitwert
*
* Der Statistik-Nutzer selbst zählt in beiden Listen mit (Linie 0 / "ich").
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array{by_count: array<int, array<string, mixed>>, by_points: array<int, array<string, mixed>>}
*/
private function topCustomerAboRanking(User $rootUser, array $lineBuckets, int $month, int $year): array
{
$usersWithLine = $this->usersWithLine($lineBuckets, $rootUser);
if ($usersWithLine === []) {
return ['by_count' => [], 'by_points' => []];
}
$memberIds = array_keys($usersWithLine);
$aboCounts = $this->customerAboCounts($memberIds);
$targetPoints = $this->customerAboTargetPoints($memberIds);
$actualPoints = $this->customerAboActualPoints($memberIds, $month, $year);
$rows = collect($usersWithLine)
->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');
'is_self' => $entry['line'] === 0,
'abo_count' => (int) $aboCounts->get($userId, 0),
'points_target' => (float) $targetPoints->get($userId, 0.0),
'points_actual' => (float) $actualPoints->get($userId, 0.0),
]);
return [
'level_name' => self::LEADER_LEVEL_NAME,
'total' => $totalOwn + $totalCustomer,
'total_own' => $totalOwn,
'total_customer' => $totalCustomer,
'rows' => $rows->all(),
'by_count' => $rows
->filter(fn (array $row) => $row['abo_count'] > 0)
->sortByDesc('abo_count')
->take(10)
->values()
->all(),
'by_points' => $rows
->filter(fn (array $row) => $row['points_target'] > 0 || $row['points_actual'] > 0)
->sortByDesc('points_actual')
->take(10)
->values()
->all(),
];
}
/**
* Soll-Punkte je Berater: Abo-Punkte-Bestand der aktiven Kundenabos
* (Produktpunkte × Menge, nur Hauptartikel comp = 0 — gleiche Logik wie
* {@see UserAbo::getTotalPoints()}).
*
* @param int[] $memberIds
* @return Collection<int, float>
*/
private function customerAboTargetPoints(array $memberIds): Collection
{
return UserAbo::query()
->join('user_abo_items', 'user_abo_items.user_abo_id', '=', 'user_abos.id')
->join('products', 'products.id', '=', 'user_abo_items.product_id')
->whereNull('products.deleted_at')
->where('user_abos.active', true)
->whereNotIn('user_abos.status', [4, 5, 6])
->whereIn('user_abos.member_id', $memberIds)
->where('user_abos.is_for', 'ot')
->where('user_abo_items.comp', 0)
->selectRaw('user_abos.member_id, COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
->groupBy('user_abos.member_id')
->pluck('points', 'member_id')
->map(fn ($points) => (float) $points);
}
/**
* Ist-Punkte je Berater: im Monat tatsächlich abgerechnete Kundenabo-Punkte.
*
* @param int[] $memberIds
* @return Collection<int, float>
*/
private function customerAboActualPoints(array $memberIds, int $month, int $year): Collection
{
return UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $memberIds)
->where('month', $month)
->where('year', $year)
->select('user_sales_volumes.user_id')
->selectRaw(SalesVolumePoints::customerAboPoints().' as points')
->groupBy('user_sales_volumes.user_id')
->pluck('points', 'user_id')
->map(fn ($points) => (float) $points);
}
/**
* Aktueller Bestand aktiver Kundenabos je Berater (member_id).
*
* @param int[] $memberIds
* @return \Illuminate\Support\Collection<int, int>
* @return Collection<int, int>
*/
private function customerAboCounts(array $memberIds): Collection
{
@ -522,13 +817,20 @@ class BackofficeDashboardService
}
/**
* @param array<int, \Illuminate\Support\Collection<int, \App\User>> $lineBuckets
* @return array<int, array{user: \App\User, line: int}>
* Downline-User mit ihrer Linie; optional inklusive des Statistik-Nutzers
* selbst als Linie 0 (er steckt in keinem lineBucket, {@see lineBuckets()}).
*
* @param array<int, Collection<int, User>> $lineBuckets
* @return array<int, array{user: User, line: int}>
*/
private function usersWithLine(array $lineBuckets): array
private function usersWithLine(array $lineBuckets, ?User $rootUser = null): array
{
$usersByLine = [];
if ($rootUser !== null) {
$usersByLine[(int) $rootUser->id] = ['user' => $rootUser, 'line' => 0];
}
foreach ($lineBuckets as $line => $users) {
foreach ($users as $user) {
$usersByLine[(int) $user->id] = ['user' => $user, 'line' => $line];

View file

@ -7,15 +7,16 @@ use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
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>}
* @return array{metric: string, metric_label: string, only_new: bool, 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
public function details(User $viewer, int $line, string $metric, int $month, int $year, bool $onlyNew = false): array
{
$metricLabels = $this->dashboardService->metricLabels();
@ -23,19 +24,31 @@ class BackofficeDrilldownService
abort(404);
}
$onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true);
$userIds = $this->dashboardService->lineUserIds($viewer->id, $line);
if ($metric === 'shop_1000' && $line === 0) {
$userIds[] = (int) $viewer->id;
}
$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),
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew),
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew),
'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 => [],
};
if ($metric === 'shop_1000') {
$rows = $this->markViewerRow($rows, (int) $viewer->id);
}
return [
'metric' => $metric,
'metric_label' => $metricLabels[$metric],
'metric_label' => $metricLabels[$metric].($onlyNew ? ' – nur neue' : ''),
'only_new' => $onlyNew,
'line' => $line,
'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien',
'month' => $month,
@ -46,8 +59,26 @@ class BackofficeDrilldownService
}
/**
* Kennzeichnet die eigene Zeile des Betrachters (der VIP zählt beim
* 1000-Punkte-Shop selbst mit und soll in der Liste erkennbar sein).
*
* @param array<int, array<string, mixed>> $rows
* @return array{count: int, abo_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}
* @return array<int, array<string, mixed>>
*/
private function markViewerRow(array $rows, int $viewerId): array
{
return array_map(function (array $row) use ($viewerId): array {
if ((int) ($row['user_id'] ?? 0) === $viewerId) {
$row['name'] .= ' (ich)';
}
return $row;
}, $rows);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{count: int, abo_count: int, points: float, customer_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
{
@ -55,6 +86,7 @@ class BackofficeDrilldownService
'count' => count($rows),
'abo_count' => (int) collect($rows)->sum('abo_count'),
'points' => (float) collect($rows)->sum('points'),
'customer_points' => (float) collect($rows)->sum('customer_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'),
@ -102,7 +134,7 @@ class BackofficeDrilldownService
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = Carbon::create($year, $month, 1)->endOfMonth();
return User::query()
$partners = User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->whereNotNull('m_level')
@ -111,12 +143,25 @@ class BackofficeDrilldownService
->orderBy('active_date')
->get()
->filter(fn (User $user) => Carbon::parse($user->payment_account)->isFuture())
->values();
$sponsors = User::query()
->with('account')
->whereIn('id', $partners->pluck('m_sponsor')->filter()->unique())
->get()
->keyBy('id');
$customerPoints = $this->externalPointsByUser($partners->pluck('id')->map(fn ($id) => (int) $id)->all(), $month, $year);
return $partners
->map(fn (User $user) => [
'type' => 'user',
'user_id' => $user->id,
'name' => $this->userName($user),
'email' => $user->email,
'career_level' => $this->careerLevel($user),
'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-',
'customer_points' => (float) $customerPoints->get((int) $user->id, 0.0),
'active_date' => $this->formatDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
])
@ -124,16 +169,41 @@ class BackofficeDrilldownService
->all();
}
/**
* Externe Kundenpunkte (Shop + Abo) je User im gewählten Monat — tagesaktuell
* aus user_sales_volumes summiert (gleiche Status-Logik wie die Übersicht).
*
* @param int[] $userIds
* @return Collection<int, float>
*/
private function externalPointsByUser(array $userIds, int $month, int $year): Collection
{
if ($userIds === []) {
return collect();
}
return UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->select('user_id')
->selectRaw(SalesVolumePoints::externalPoints().' as external_points')
->groupBy('user_id')
->pluck('external_points', 'user_id')
->map(fn ($points) => (float) $points);
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function partnerAboRows(array $userIds, int $month, int $year): array
private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): 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')
->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
->orderBy('next_date')
->get()
->map(fn (UserAbo $abo) => [
@ -165,12 +235,13 @@ class BackofficeDrilldownService
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function customerAboRows(array $userIds, int $month, int $year): array
private function customerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false): array
{
return $this->activeAboQuery()
->with(['member.account', 'member.user_level', 'user_abo_items.product'])
->whereIn('member_id', $userIds)
->where('is_for', 'ot')
->when($onlyNew, fn (Builder $query) => $this->whereNewInMonth($query, $month, $year))
->orderBy('member_id')
->orderBy('next_date')
->get()
@ -279,6 +350,17 @@ class BackofficeDrilldownService
->whereNotIn('status', [4, 5, 6]);
}
/**
* Nur Abos, die im gewählten Monat gestartet sind (gleiche Zählweise wie
* die "+N neu"-Badges, {@see BackofficeDashboardService::newAboCount()}).
*/
private function whereNewInMonth(Builder $query, int $month, int $year): Builder
{
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
return $query->whereBetween('start_date', [$startDate, $startDate->copy()->endOfMonth()]);
}
private function userName(User $user): string
{
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));

View file

@ -2,6 +2,8 @@
namespace App\Services\Backoffice;
use App\Services\BusinessPlan\SalesPointsVolume;
/**
* Liefert SQL-Aggregat-Ausdrücke für die monatliche Punkte-/Umsatzauswertung
* der Backoffice-Statistik.
@ -11,7 +13,7 @@ namespace App\Services\Backoffice;
* kumulative Monats-Laufstand zum Zeitpunkt der Bestellung – KEINE Einzelbeträge.
* Eine SUM() über diese Spalten zählt mehrfach. Korrekt wird je Zeile die
* `points`/`total_net`-Spalte summiert und nach `status` gefiltert – analog zu
* {@see \App\Services\BusinessPlan\SalesPointsVolume::reCalculateSalesPointsVolume()}.
* {@see SalesPointsVolume::reCalculateSalesPointsVolume()}.
*
* Status-Zuordnung:
* - Eigenpunkte (KP): status 1, 4, 5, 6
@ -59,6 +61,26 @@ class SalesVolumePoints
return self::ownPoints().' + '.self::externalPoints();
}
/**
* Punkte aus Abo-Bestellungen (eigene wie Kundenabos) — alle zählbaren Status.
* Benötigt den Join auf shopping_orders.
*/
public static function aboPoints(): string
{
return self::sumWhere(self::TURNOVER_PREDICATE.' AND shopping_orders.is_abo = 1', 'user_sales_volumes.points');
}
/**
* Punkte, die nur als Karrierepunkte (KP) des Beraters zählen und NICHT als
* Teampunkte in die Payline fließen (status_points = 2, z. B.
* Neupartner-Einschreibungspunkte, manuelle Gutschriften) — vgl.
* {@see SalesPointsVolume::add_KP_TP_Points()}.
*/
public static function nonPaylinePoints(): string
{
return self::sumWhere(self::OWN_PREDICATE.' AND user_sales_volumes.status_points = 2', 'user_sales_volumes.points');
}
public static function turnoverNet(): string
{
return self::sumWhere(self::TURNOVER_PREDICATE, 'user_sales_volumes.total_net');