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>
424 lines
17 KiB
PHP
424 lines
17 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Backoffice;
|
||
|
||
use App\Models\UserAbo;
|
||
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, 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, bool $onlyNew = false): array
|
||
{
|
||
$metricLabels = $this->dashboardService->metricLabels();
|
||
|
||
if (! array_key_exists($metric, $metricLabels)) {
|
||
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, $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].($onlyNew ? ' – nur neue' : ''),
|
||
'only_new' => $onlyNew,
|
||
'line' => $line,
|
||
'line_label' => $line > 0 ? 'Linie '.$line : 'Alle Linien',
|
||
'month' => $month,
|
||
'year' => $year,
|
||
'rows' => $rows,
|
||
'summary' => $this->summary($rows),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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<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
|
||
{
|
||
return [
|
||
'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'),
|
||
'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();
|
||
|
||
$partners = 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())
|
||
->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),
|
||
])
|
||
->values()
|
||
->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, 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) => [
|
||
'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();
|
||
}
|
||
|
||
/**
|
||
* Teamkundenabos pro Teampartner (member_id) gruppiert: je Teampartner die
|
||
* Anzahl der Kundenabos, die Gesamt-Volumenpunkte sowie die einzelnen Abos
|
||
* mit Punkten je Abo. Kundennamen bleiben aus Datenschutzgründen außen vor.
|
||
*
|
||
* @param int[] $userIds
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
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()
|
||
->groupBy(fn (UserAbo $abo) => (int) $abo->member_id)
|
||
->map(function ($abos) use ($month, $year) {
|
||
$consultant = $abos->first()->member;
|
||
$memberId = (int) $abos->first()->member_id;
|
||
|
||
$aboItems = $abos->map(fn (UserAbo $abo) => [
|
||
'abo_id' => $abo->id,
|
||
'points' => (float) $abo->getTotalPoints(),
|
||
'start_date' => $this->formatDate($abo->getRawOriginal('start_date')),
|
||
'next_date' => $this->formatDate($abo->next_date),
|
||
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
|
||
])->values()->all();
|
||
|
||
return [
|
||
'type' => 'customer_abo_group',
|
||
'consultant_id' => $memberId,
|
||
'name' => $consultant ? $this->userName($consultant) : '#'.$memberId,
|
||
'email' => $consultant?->email,
|
||
'career_level' => $consultant ? $this->careerLevel($consultant) : '-',
|
||
'abo_count' => $abos->count(),
|
||
'points' => (float) $abos->sum(fn (UserAbo $abo) => $abo->getTotalPoints()),
|
||
'abos' => $aboItems,
|
||
];
|
||
})
|
||
->sortByDesc('points')
|
||
->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(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::totalPoints().' as total_points')
|
||
->groupBy('user_sales_volumes.user_id');
|
||
|
||
if ($metric === 'own_points') {
|
||
$rows->havingRaw(SalesVolumePoints::ownPoints().' > 0');
|
||
}
|
||
|
||
if ($metric === 'external_points') {
|
||
$rows->havingRaw(SalesVolumePoints::externalPoints().' > 0');
|
||
}
|
||
|
||
if ($metric === 'customer_abo_points') {
|
||
$rows->havingRaw(SalesVolumePoints::customerAboPoints().' > 0');
|
||
}
|
||
|
||
if ($metric === 'customer_single_order_points') {
|
||
$rows->havingRaw(SalesVolumePoints::customerSingleOrderPoints().' > 0');
|
||
}
|
||
|
||
if ($metric === 'customer_other_points') {
|
||
$rows->havingRaw(SalesVolumePoints::customerOtherPoints().' > 0');
|
||
}
|
||
|
||
if ($metric === 'total_points') {
|
||
$rows->havingRaw(SalesVolumePoints::totalPoints().' > 0');
|
||
}
|
||
|
||
if ($metric === 'shop_1000') {
|
||
$rows->havingRaw(SalesVolumePoints::totalPoints().' >= 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]);
|
||
}
|
||
|
||
/**
|
||
* 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 ?? ''));
|
||
|
||
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');
|
||
}
|
||
}
|