mivita/app/Services/Backoffice/BackofficeDrilldownService.php
Kevin d57687b4e3 Team-Statistik = Team-Struktur (Provisionsbaum, Umschalter) und Laufstand-Fix in user_sales_volumes
K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026.

C1 – Statistik rechnet wie die Provision
- Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene,
  Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien).
  Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session.
- Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt
  eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt.
- Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf
  (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration).
- Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp.
- Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift).

C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen)
- Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month).
- Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und
  Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt.
- Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf;
  abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08).

Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein:
Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:31:32 +00:00

575 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 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, string $tree = BackofficeDashboardService::TREE_COMMISSION): array
{
$tree = BackofficeDashboardService::normalizeTree($tree);
$metricLabels = $this->dashboardService->metricLabels();
if (! array_key_exists($metric, $metricLabels)) {
abort(404);
}
$onlyNew = $onlyNew && in_array($metric, ['team_partner_abos', 'team_customer_abos'], true);
// Abgeschlossener Monat: Mitglieder und Abos kommen aus dem eingefrorenen
// Snapshot, damit die Liste zu der Zahl passt, aus der sie aufgerufen wurde.
// Ohne das rechnet die Liste mit der heutigen Struktur — ein spaeter
// hinzugekommener oder verschobener Teampartner taucht dann in einem
// laengst abgerechneten Monat auf (oder verschwindet daraus).
$frozen = $this->frozenLines($viewer, $month, $year, $tree);
$userIds = $frozen !== null
? $this->frozenUserIds($frozen, $line)
: $this->dashboardService->lineUserIds($viewer->id, $line, $month, $year, $tree);
if ($metric === 'shop_1000' && $line === 0) {
$userIds[] = (int) $viewer->id;
}
$frozenAboIds = $frozen !== null
? $this->frozenAboIds($frozen, $line, $metric, $onlyNew)
: null;
$rows = match ($metric) {
'consultants' => $this->consultantRows($userIds),
'new_partners' => $this->newPartnerRows($userIds, $month, $year),
'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds),
'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,
'tree' => $tree,
'tree_label' => BackofficeDashboardService::treeLabels()[$tree] ?? $tree,
'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'),
'storno_floor_points' => (float) collect($rows)->sum('storno_floor_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),
'active_date_sort' => $this->sortDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
'payment_account_sort' => $this->sortDate($user->payment_account),
])
->values()
->all();
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function newPartnerRows(array $userIds, int $month, int $year): array
{
$partners = User::query()
->with(['account', 'user_level'])
->whereIn('id', $userIds)
->newPartnerIn($month, $year)
->orderBy('active_date')
->get();
$sponsors = User::query()
->with('account')
->whereIn('id', $partners->pluck('m_sponsor')->filter()->unique())
->get()
->keyBy('id');
$monthlyPoints = $this->monthlyPointsByUser($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,
// Telefon mit Mobil-Fallback, gleiche Regel wie auf der
// Firstlines-Seite (Kundenwunsch 2026-09-07: zum Nachfassen).
'phone' => trim(($user->account?->phone ?? '') !== '' ? (string) $user->account->phone : (string) ($user->account?->mobil ?? '')),
'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) ($monthlyPoints->get((int) $user->id)['external_points'] ?? 0.0),
'total_points' => (float) ($monthlyPoints->get((int) $user->id)['total_points'] ?? 0.0),
'active_date' => $this->formatDate($user->active_date),
'active_date_sort' => $this->sortDate($user->active_date),
'payment_account' => $this->formatDate($user->payment_account),
'payment_account_sort' => $this->sortDate($user->payment_account),
])
->values()
->all();
}
/**
* Externe Kundenpunkte (Shop + Abo) sowie Gesamtpunkte (eigene + Kundenpunkte,
* inkl. Starterpaket Status 1) je User im gewählten Monat — tagesaktuell
* aus user_sales_volumes summiert (gleiche Status-Logik wie die Übersicht).
*
* @param int[] $userIds
* @return Collection<int, array{external_points: float, total_points: float}>
*/
private function monthlyPointsByUser(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')
->selectRaw(SalesVolumePoints::totalPoints().' as total_points')
->groupBy('user_id')
->get()
->keyBy('user_id')
->map(fn ($row) => [
'external_points' => (float) $row->external_points,
'total_points' => (float) $row->total_points,
]);
}
/**
* Die eingefrorenen Linienzeilen eines abgeschlossenen Monats, oder null, wenn
* der Monat noch laeuft bzw. kein Snapshot (oder ein aelterer ohne `abo_ids`)
* vorliegt. Aeltere Payloads fallen bewusst auf die Live-Auswertung zurueck,
* statt eine Genauigkeit vorzutaeuschen, die sie nicht haben.
*
* @return array<int, array<string, mixed>>|null
*/
private function frozenLines(User $viewer, int $month, int $year, string $tree): ?array
{
$snapshot = BackofficeStatisticsSnapshot::query()
->where('user_id', $viewer->id)
->where('month', $month)
->where('year', $year)
->where('tree', $tree)
->first();
if (! $snapshot) {
return null;
}
$payload = $snapshot->payload;
if ((int) ($payload['payload_version'] ?? 1) < BackofficeDashboardService::PAYLOAD_VERSION) {
return null;
}
return is_array($payload['lines'] ?? null) ? $payload['lines'] : null;
}
/**
* Mitglieder einer Linie zum Monatsabschluss; Linie 0 = das ganze Team.
*
* @param array<int, array<string, mixed>> $frozen
* @return int[]
*/
private function frozenUserIds(array $frozen, int $line): array
{
$ids = [];
foreach ($frozen as $key => $row) {
if ($line !== 0 && (int) ($row['line'] ?? $key) !== $line) {
continue;
}
foreach ($row['user_ids'] ?? [] as $id) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* Abos einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. Gibt es zu der
* Kennzahl keine eingefrorene Liste, wird null zurueckgegeben (Live-Auswertung).
*
* @param array<int, array<string, mixed>> $frozen
* @return int[]|null
*/
private function frozenAboIds(array $frozen, int $line, string $metric, bool $onlyNew): ?array
{
if (! in_array($metric, ['team_partner_abos', 'team_customer_abos'], true)) {
return null;
}
$key = $metric.($onlyNew ? '_new' : '');
$ids = [];
foreach ($frozen as $index => $row) {
if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) {
continue;
}
foreach ($row['abo_ids'][$key] ?? [] as $id) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* @param int[] $userIds
* @return array<int, array<string, mixed>>
*/
private function partnerAboRows(array $userIds, int $month, int $year, bool $onlyNew = false, ?array $frozenAboIds = null): array
{
return $this->aboScope($frozenAboIds)
->with(['user.account', 'user.user_level', 'user_abo_items.product', 'user_abo_orders.shopping_order.shopping_payments.payment_transactions'])
->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('user_id', $userIds)->where('is_for', 'me'))
->when($frozenAboIds === null && $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')),
'start_date_sort' => $this->sortDate($abo->getRawOriginal('start_date')),
'is_new_this_month' => $this->isAboNewInMonth($abo, $month, $year),
'next_date' => $this->formatDate($abo->getRawOriginal('next_date')),
'next_date_sort' => $this->sortDate($abo->getRawOriginal('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 $frozenAboIds = null): array
{
return $this->aboScope($frozenAboIds)
->with(['member.account', 'member.user_level', 'user_abo_items.product'])
->when($frozenAboIds === null, fn (Builder $query) => $query->whereIn('member_id', $userIds)->where('is_for', 'ot'))
->when($frozenAboIds === null && $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->getRawOriginal('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')
->selectRaw(SalesVolumePoints::commissionPoints().' as commission_points')
->groupBy('user_sales_volumes.user_id');
// `<> 0` statt `> 0`: ein Berater mit negativem Monatssaldo (Storno) steckt in
// der Uebersichtssumme (dort mit 0 gewertet, siehe Knotenregel) und muss
// deshalb auch in der Detailliste auftauchen — sonst ergibt die Detailsumme
// etwas anderes als die verlinkte Kachel. Nur die echte Qualifikationsliste
// behaelt ihre Schwelle.
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::commissionPoints().' <> 0');
}
if ($metric === 'shop_1000') {
$rows->havingRaw(SalesVolumePoints::totalPoints().' >= 1000');
}
// „Gesamtpunkte" der Liste = Provisionspunkte mit Knotenregel (negativer Saldo
// = 0), damit die Listensumme der Kachel „Umsatz (Punkte)" entspricht. Die
// 1000-Punkte-Liste behaelt ihre eigene Definition (Eigen- + Kundenpunkte).
$salesRows = $rows->orderByDesc($metric === 'shop_1000' ? 'total_points' : 'commission_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' => $metric === 'shop_1000' ? (float) $row->total_points : max(0.0, (float) $row->commission_points),
'storno_floor_points' => $metric === 'shop_1000' ? 0.0 : max(0.0, -(float) $row->commission_points),
])
->values()
->all();
}
private function activeAboQuery(): Builder
{
return UserAbo::query()
->where('active', true)
->whereNotIn('status', [4, 5, 6]);
}
/**
* Grundabfrage der Abo-Listen: im laufenden Monat der heutige Aktivbestand, in
* einem abgeschlossenen Monat exakt die eingefrorenen Abos.
*
* `withTrashed()` ist dort nicht optional — ein Abo, das der Monat zu Recht
* enthaelt, kann inzwischen gekuendigt oder geloescht sein. Ohne den Zusatz
* waere die Liste kuerzer als die Zahl, aus der sie aufgerufen wurde.
*
* @param int[]|null $frozenAboIds
*/
private function aboScope(?array $frozenAboIds): Builder
{
if ($frozenAboIds === null) {
return $this->activeAboQuery();
}
return UserAbo::query()->withTrashed()->whereIn('id', $frozenAboIds);
}
/**
* 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(\Util::formatDateDB());
}
/**
* Unformatierter Sortierwert zu einem Datum (A14). Die Anzeige über
* {@see formatDate()} ist lokalisiert — bei `en` liefert
* {@see \Util::formatDateDB()} `Y-m-d` statt `d.m.Y` —, deshalb bekommt die
* Tabelle den Sortierschlüssel getrennt vom Anzeigetext.
*/
private function sortDate(mixed $date): ?string
{
if ($date === null || $date === '') {
return null;
}
return Carbon::parse($date)->format('Y-m-d');
}
}