mivita/app/Services/Abo/PaylineForecastService.php
Kevin 94e2b13384 Statistik: uebrige Audit-Korrekturen (Abo-Regeln, Prognose, Sortierung)
A01 — Abo-Regeln waren ueber das Aktiv-Feld umgehbar. Das
Selbstbedienungsformular hat gar keinen Aktiv-Schalter, es spiegelt den Zustand
nur in einem versteckten Feld zurueck; `updateStatus()` hat den Wert trotzdem
uebernommen. Wer den Request manipulierte, konnte sein eigenes Abo dauerhaft
deaktivieren und so die Mindestlaufzeit umgehen, am Pausenpfad mit seinen
Sperren vorbei. Das Feld wirkt jetzt nur noch im Admin-Pfad.

A16 — Statuswechsel wurden nur aus dem Formular protokolliert. Recovery-Zahlung,
Retry, externe Erledigung und die DSGVO-Anonymisierung schreiben `active` direkt
auf das Modell. Das ist heikel, weil die historische Bestandsrueckrechnung ein
Abo verwirft, solange eine protokollierte Deaktivierung nicht durch eine
spaetere protokollierte Reaktivierung aufgehoben ist — ein stiller
`active = true` nimmt es dauerhaft aus allen Folgemonaten.

Der Eintrag liegt deshalb jetzt am Model (`UserAbo::logStatusTransition()`) und
nicht in den einzelnen Diensten: so kann ihn ein kuenftiger Schreibpfad nicht
vergessen, und er entsteht in derselben Transaktion wie die Statusaenderung.
Nebeneffekt: Die DSGVO-Anonymisierung setzt `active = false` ohne `cancel_date`
— bisher fiel ein so abgeschaltetes Abo rueckwirkend aus allen vergangenen
Monaten, jetzt bleibt die Historie korrekt.

A13 — Ein heute faelliges Abo galt ab Mitternacht als erledigt, der
Ausfuehrungsjob laeuft aber erst um 04:00. In diesem Fenster fehlten die Punkte
in der Prognose UND im Ist. Der neue Scope `UserAbo::scopeOpenExecutionUntil()`
zaehlt den heutigen Liefertag mit, solange keine Ausfuehrung angelegt ist —
genau diese Bedingung verhindert die Doppelzaehlung. Alle drei Prognosen nutzen
jetzt denselben Scope statt je einer eigenen Kopie der Datumsregel.

A11 — Der Namens-Tiebreak der Top 3 lief nach dem Abschneiden auf 25
Kandidaten, bei Gleichstand entschied also die Reihenfolge der Abfrage. Die
Auswahlgrenze wird jetzt um alle Gleichstaende an dieser Grenze erweitert, und
Kandidaten ohne Account fallen vorher weg statt die Plaetze zu blockieren. Als
letzte Regel entscheidet die user_id, damit eine gecachte Rangliste
reproduzierbar bleibt.

A14 — Sortiert wurde ueber den angezeigten Zellentext. Unter `en` liefert die
Anwendung `Y-m-d` und `1,000.00`, damit ergab jedes Datum 0 und `1,000.00` wurde
zur 1. Die Zellen tragen jetzt `data-sort-value` mit dem unformatierten Wert;
ohne Attribut gilt weiter der Textweg. Dabei mitgenommen: Umlaute landeten hinter
dem Z (jetzt localeCompare), leere Werte lagen als 0 zwischen den negativen
Stornowerten, und `next_date` wurde doppelt formatiert.

Ausserdem A08 (Prognose auch ohne erreichte Qualifikationsstufe), A09
(Summenzeile im Neupartner-CSV), A10 (Lieferungszahl vorgeladen), A17
(Faelligkeitsgruppen ohne Monatsueberlauf), A18 (Formelinhalte im CSV als Text)
und A19 (fehlgeschlagene Provisionsberechnung ist als solche erkennbar statt
0,00 EUR).

Audit-Befunde A01, A08, A09, A10, A11, A13, A14, A16, A17, A18, A19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:19:38 +00:00

591 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\Abo;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\SalesVolumePoints;
use App\Services\BusinessPlan\LiveCommissionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Payline-bezogene Abo-Kennzahlen (Konzept B8 + B10):
*
* - Payline = die ersten N Ebenen der Downline; N ("paylines") kommt aus dem
* Provisionslevel des Users (Live-TreeCalcBot bzw. UserBusiness-Snapshot).
* - B8 aboTurnover(): Abo-Gesamtumsatz der Payline als Soll (Abo-Punkte-Bestand
* pro Zyklus) und Ist (im Monat abgerechnete Abo-Punkte, F10).
* - B10 forecastDays(): voraussichtlicher Payline-Gesamtumsatz zu den festen
* Ausführungsterminen 5./10./20./25. — Basis (Kundenumsatz + Payline-Punkte
* Stand heute) plus die bis zum jeweiligen Termin noch anstehenden
* Abo-Punkte, kumuliert (F11).
*/
class PaylineForecastService
{
/**
* F12 (dokumentierte Annahme, Kunde entscheidet nach Umsetzung): Die eigenen
* Abos und eigenen Kundenabos des Beraters zählen in der Prognose MIT, weil
* die Basis der Formel den aktuellen Kundenumsatz enthält und kommende
* eigene Ausführungen genau diesen Anteil erhöhen. Über diesen Schalter
* ohne Umbau umkehrbar.
*/
public const INCLUDE_OWN_ABOS = true;
private const CACHE_HOURS = 6;
public function __construct(
private BackofficeDashboardService $dashboardService,
private LiveCommissionService $commissionService,
private TeamAboOverviewService $teamAboOverview,
) {}
/**
* Payline-Kontext (Anzahl bezahlter Ebenen + Mitglieder-IDs), 6 h gecacht —
* die Payline-Tiefe kommt aus dem gemeinsamen Live-Lauf, die Abo-Summen
* bleiben live. Ein fehlgeschlagener Live-Lauf wird nicht gecacht, damit
* ein transienter Fehler die Karte nicht 6 h lang leert.
*
* Ebenso wenig gecacht wird eine Tiefe von 0: dieser Zustand tritt am
* Monatsanfang bei jedem Berater ohne Karriere-Level auf und löst sich mit
* den ersten Punkten des Monats von selbst auf — ein 6-h-Cache würde die
* leeren Kacheln bis in den Nachmittag festhalten.
*
* @return array{paylines: int, member_ids: int[], is_career_fallback: bool}
*/
public function paylineContext(User $user, int $month, int $year): array
{
$cacheKey = "payline_context_{$user->id}_{$month}_{$year}";
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
// Union statt direkter Rueckgabe: Eintraege aus der Zeit vor dem
// Karriere-Fallback kennen is_career_fallback noch nicht und wuerden
// beim Leser sonst in eine undefinierte Schluesselwarnung laufen.
return $cached + ['is_career_fallback' => false];
}
[$context, $failed] = $this->buildPaylineContext($user, $month, $year);
if (! $failed && $context['paylines'] > 0) {
Cache::put($cacheKey, $context, now()->addHours(self::CACHE_HOURS));
}
return $context;
}
/**
* B8: Abo-Gesamtumsatz in der Payline — Soll und Ist, plus die Anzahl der
* dahinterstehenden Abos (Berater- und Kundenabos, Kundenwunsch 2026-07-24).
*
* @return array{paylines: int, members: int, target: float, actual: float, team_abo_count: int, team_customer_abo_count: int}
*/
public function aboTurnover(User $user, int $month, int $year): array
{
$context = $this->paylineContext($user, $month, $year);
$memberIds = $context['member_ids'];
return [
'paylines' => $context['paylines'],
'members' => count($memberIds),
'target' => $this->aboTargetPoints($memberIds),
'actual' => $this->aboActualPoints($memberIds, $month, $year),
'team_abo_count' => $this->aboCount($memberIds, 'me', 'user_id'),
'team_customer_abo_count' => $this->aboCount($memberIds, 'ot', 'member_id'),
];
}
/**
* Neue Abos der Payline-Mitglieder im Monat (Berater- + Kundenabos,
* start_date im Monat) — das "+N"-Badge der "Abos in der Payline"-Kachel (N1).
*
* Zählt über {@see TeamAboOverviewService::newAboCount()} und damit über
* exakt dieselbe Stichtags-Logik wie der Bestand der Kachel: für vergangene
* Monate zählt, was am Monatsende bestand. Mit `activeAbo()` (heutiger Stand)
* wäre das Badge kleiner als der Bestand daneben und würde mit jedem
* beendeten Abo rückwirkend weiter schrumpfen.
*/
public function newPaylineAboCount(User $user, int $month, int $year): int
{
$memberIds = $this->paylineContext($user, $month, $year)['member_ids'];
return $this->teamAboOverview->newAboCount($memberIds, 'user_id', 'me', $month, $year)
+ $this->teamAboOverview->newAboCount($memberIds, 'member_id', 'ot', $month, $year);
}
/**
* Anzahl aktiver Abos der Payline-Mitglieder (gleiche Filter wie Soll).
*
* @param int[] $memberIds
*/
private function aboCount(array $memberIds, string $isFor, string $userColumn): int
{
if ($memberIds === []) {
return 0;
}
return UserAbo::query()
->activeAbo()
->where('is_for', $isFor)
->whereIn($userColumn, $memberIds)
->count();
}
/**
* B10: Prognose je Ausführungstermin des LAUFENDEN Monats. Basis ist der
* heutige Stand (Kundenumsatz + Payline-Punkte, vom Aufrufer übergeben);
* dazu kommen kumuliert die Abo-Punkte aller noch offenen Ausführungen bis
* zum jeweiligen Termin (im laufenden Monat, Abgrenzung siehe
* {@see UserAbo::scopeOpenExecutionUntil()}). Bereits ausgeführte Abos
* stecken im Ist-Umsatz und werden nicht doppelt gezählt.
*
* @return array{base: float, days: array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
*/
public function forecastDays(User $user, float $basePoints): array
{
$today = Carbon::today();
$context = $this->paylineContext($user, (int) $today->month, (int) $today->year);
$upcomingAbos = $this->upcomingAboQuery($user, $context['member_ids'], $today)
->with('user_abo_items.product')
->get();
return [
'base' => $basePoints,
'days' => $this->cumulateByDeliveryDay($upcomingAbos, $basePoints, $today),
];
}
/**
* Dieselbe Termin-Vorschau, aber ausschließlich über die EIGENEN Kundenabos
* des Beraters (Kundenwunsch 2026-09-07: „wieviel Volumenpunkte habe ich am
* 5. / 10. / 20. / 25. — nur mit meinen Kundenabos").
*
* Bewusst ohne das eigene Abo und ohne Payline: Basis ist 0, die Kacheln
* zeigen also die Punkte, die aus den eigenen Kundenabos bis zum jeweiligen
* Termin noch hereinkommen. Das ist ein anderer Zuschnitt als
* {@see forecastDays()}, wo eigene Abos in einer Team-Zahl mitlaufen.
*
* @return array{base: float, days: array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>}
*/
public function personalCustomerAboForecast(User $user): array
{
$today = Carbon::today();
$abos = UserAbo::query()
->where('active', true)
->where('status', 2)
->where('is_for', 'ot')
->where('member_id', $user->id)
->openExecutionUntil($today, $today->copy()->endOfMonth())
->with('user_abo_items.product')
->get();
return [
'base' => 0.0,
'days' => $this->cumulateByDeliveryDay($abos, 0.0, $today),
];
}
/**
* Offene Ausführungen auf die vier Liefertermine kumulieren: je Termin die
* Punkte aller Abos, deren nächste Ausführung bis einschließlich zu diesem
* Tag fällt. Eine Stelle für beide Prognosen, damit Payline- und
* Kundenabo-Sicht nicht mit unterschiedlichen Terminregeln rechnen.
*
* @param Collection<int, UserAbo> $abos
* @return array<int, array{day: int, date: string, upcoming: float, total: float, is_past: bool}>
*/
private function cumulateByDeliveryDay($abos, float $basePoints, Carbon $today): array
{
$pointsByDay = [];
foreach ($abos as $abo) {
$day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day;
$pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints();
}
$days = [];
foreach (UserAbo::$aboDeliveryDays as $deliveryDay) {
$upcoming = 0.0;
foreach ($pointsByDay as $day => $points) {
if ($day <= $deliveryDay) {
$upcoming += $points;
}
}
$days[] = [
'day' => $deliveryDay,
'date' => $today->copy()->startOfMonth()->addDays($deliveryDay - 1)->format(\Util::formatDateDB()),
'upcoming' => $upcoming,
'total' => $basePoints + $upcoming,
'is_past' => $deliveryDay < (int) $today->day,
];
}
return $days;
}
/**
* N4: Level- und Provisions-Prognose zum letzten Liefertag des laufenden
* Monats (Kundenentscheid F5: gekennzeichnete Schätzung).
*
* - Prognose-Basis: heutige Punkte je Payline-Ebene (TP-Laufstände) plus die
* noch offenen Abo-Ausführungen, ebenengenau zugeordnet; eigene Abos und
* eigene Kundenabos zählen auf das persönliche Volumen.
* - Level-Prognose: erreicht die prognostizierte Payline-Punktzahl (inkl.
* KP-Überschuss, Logik wie {@see BusinessUserItemOptimized::setNextUserLevel()})
* das nächste Level — und falls nicht, wie viel fehlt laut Prognose?
* - Provisions-Schätzung: Ebenen-Punkte × Provisionssätze des laut Prognose
* erreichten Levels; Shop-/Wachstumsanteile werden mit dem heutigen
* Live-Wert fortgeschrieben. Rangverschiebungen im Team bleiben außen
* vor — deshalb im UI als unverbindlich kennzeichnen.
*
* @return array{next_name: string|null, reached: bool, missing_pp: float, missing_personal: float, personal_required: float, personal_forecast: float, estimated_commission: float, estimated_level_name: string, is_live: bool}|null
*/
public function levelAndCommissionForecast(User $user, int $month, int $year): ?array
{
$cacheKey = "level_forecast_{$user->id}_{$month}_{$year}";
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$forecast = $this->buildLevelAndCommissionForecast($user, $month, $year);
if ($forecast !== null) {
Cache::put($cacheKey, $forecast, now()->addHours(self::CACHE_HOURS));
}
return $forecast;
}
/**
* @return array{next_name: string|null, reached: bool, missing_pp: float, missing_personal: float, personal_required: float, personal_forecast: float, estimated_commission: float, estimated_level_name: string, is_live: bool}|null
*/
private function buildLevelAndCommissionForecast(User $user, int $month, int $year): ?array
{
$snapshot = $this->commissionService->snapshot($user, $month, $year);
if (! empty($snapshot['failed'])) {
return null; // echter Berechnungsfehler — dafuer gibt es keine Prognose
}
$qualLevel = is_array($snapshot['qual_level']) ? $snapshot['qual_level'] : null;
$nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null;
// "Noch nicht qualifiziert" ist ein gueltiger Ausgangszustand: wer im Monat
// noch keine Stufe erreicht hat, kann die erste durch kommende eigene
// Abo-Punkte trotzdem erreichen. Nur wenn es weder eine erreichte noch eine
// naechste Stufe gibt, fehlt der Prognose jeder Bezugspunkt.
if ($qualLevel === null && $nextLevel === null) {
return null;
}
$today = Carbon::today();
$lineByUserId = $this->lineByUserId($user, $month, $year);
$upcoming = $this->upcomingPointsByLine($user, $lineByUserId, $today, $month, $year);
$linePointsForecast = $this->currentLinePoints($lineByUserId, $month, $year);
foreach ($upcoming['by_line'] as $line => $points) {
$linePointsForecast[$line] = ($linePointsForecast[$line] ?? 0.0) + $points;
}
$personalForecast = (float) $snapshot['personal_points'] + $upcoming['personal'];
$reached = false;
$missingPp = 0.0;
$missingPersonal = 0.0;
if ($nextLevel !== null) {
$restKp = max(0.0, $personalForecast - (float) ($nextLevel['qual_kp'] ?? 0));
$paylineForecast = $this->sumLinePoints($linePointsForecast, (int) ($nextLevel['paylines'] ?? 0)) + $restKp;
$missingPp = max(0.0, (float) ($nextLevel['qual_pp'] ?? 0) - $paylineForecast);
$missingPersonal = max(0.0, (float) ($nextLevel['qual_kp'] ?? 0) - $personalForecast);
$reached = $missingPp <= 0.0 && $missingPersonal <= 0.0;
}
// Ohne erreichte Stufe und ohne prognostizierten Aufstieg gibt es keine
// Provisionssaetze — die PP-Provision bleibt dann 0, Shop und Wachstum zaehlen
// trotzdem.
$estimationLevel = ($reached && $nextLevel !== null) ? $nextLevel : $qualLevel;
$commissionPp = 0.0;
for ($line = 1; $line <= (int) ($estimationLevel['paylines'] ?? 0); $line++) {
$rate = (float) ($estimationLevel['pr_line_'.$line] ?? 0);
$commissionPp += ($linePointsForecast[$line] ?? 0.0) * $rate / 100;
}
return [
'next_name' => $nextLevel !== null ? (string) ($nextLevel['name'] ?? '') : null,
'reached' => $reached,
'missing_pp' => round($missingPp, 2),
'missing_personal' => round($missingPersonal, 2),
'personal_required' => (float) ($nextLevel['qual_kp'] ?? 0),
'personal_forecast' => round($personalForecast, 2),
'estimated_commission' => round($commissionPp + (float) $snapshot['shop'] + (float) $snapshot['growth'], 2),
'estimated_level_name' => (string) ($estimationLevel['name'] ?? ''),
'is_live' => (bool) $snapshot['is_live'],
];
}
/**
* Ebenen-Zuordnung der Downline (Linie je User-ID) — beschränkt auf die
* Mitglieder, die auch im Provisionsbaum stehen.
*
* Das ist entscheidend für die Vergleichbarkeit: Die Level-Kachel zeigt die
* payline_points aus dem TreeCalcBot, dieser Forecast rechnet die Ebenen aus
* {@see BackofficeDashboardService::lineBuckets()} nach. lineBuckets() nimmt
* aber JEDEN nicht gelöschten Downline-User, der TreeCalcBot filtert
* ({@see BusinessUserItemOptimized::readParentsBusinessUsers()}) und kappt
* den Unterbaum unter jedem ausgefilterten Knoten. Ohne denselben Filter
* stünden auf einer Seite zwei verschiedene "fehlen noch X Punkte".
*
* @return array<int, int>
*/
private function lineByUserId(User $user, int $month, int $year): array
{
$endOfMonth = Carbon::create($year, $month, 1)->endOfMonth();
$map = [];
$pruned = [];
// lineBuckets() liefert die Ebenen aufsteigend (BFS) — der Sponsor ist
// damit immer schon entschieden, wenn sein Teampartner drankommt.
foreach ($this->dashboardService->lineBuckets($user->id) as $line => $users) {
foreach ($users as $member) {
$memberId = (int) $member->id;
if (isset($pruned[(int) $member->m_sponsor]) || ! $this->countsInCommissionTree($member, $endOfMonth)) {
$pruned[$memberId] = true;
continue;
}
$map[$memberId] = (int) $line;
}
}
return $map;
}
/**
* Dieselben Bedingungen, mit denen der TreeCalcBot seine Struktur lädt:
* kein System-/Adminkonto, Mitgliedsebene und Abrechnungskonto gesetzt, bis
* zum Monatsende aktiv geworden.
*/
private function countsInCommissionTree(User $member, Carbon $endOfMonth): bool
{
if ((int) $member->id === 1 || $member->admin === null || (int) $member->admin >= 4) {
return false;
}
if ($member->m_level === null || $member->payment_account === null || $member->active_date === null) {
return false;
}
return Carbon::parse($member->active_date)->lte($endOfMonth);
}
/**
* Offene Abo-Ausführungen des Monats, aufgeteilt in persönliches Volumen
* (eigene Abos + eigene Kundenabos) und Team-Punkte je Ebene.
*
* @param array<int, int> $lineByUserId
* @return array{personal: float, by_line: array<int, float>}
*/
private function upcomingPointsByLine(User $user, array $lineByUserId, Carbon $today, int $month, int $year): array
{
if ((int) $today->month !== $month || (int) $today->year !== $year) {
return ['personal' => 0.0, 'by_line' => []];
}
$memberIds = array_keys($lineByUserId);
$personal = 0.0;
$byLine = [];
$abos = $this->upcomingAboQuery($user, $memberIds, $today)
->with('user_abo_items.product')
->get();
foreach ($abos as $abo) {
$ownerId = $abo->is_for === 'me' ? (int) $abo->user_id : (int) $abo->member_id;
$points = (float) $abo->getTotalPoints();
if ($ownerId === (int) $user->id) {
$personal += $points;
} elseif (isset($lineByUserId[$ownerId])) {
$byLine[$lineByUserId[$ownerId]] = ($byLine[$lineByUserId[$ownerId]] ?? 0.0) + $points;
}
}
return ['personal' => $personal, 'by_line' => $byLine];
}
/**
* Heutige Team-Punkte je Ebene: TP + Shop-Laufstand (letzte
* user_sales_volumes-Zeile je User) — dieselbe Punktbasis wie die
* business_lines des TreeCalcBot ({@see UserSalesVolume::getPointsTPSum()}).
*
* @param array<int, int> $lineByUserId
* @return array<int, float>
*/
private function currentLinePoints(array $lineByUserId, int $month, int $year): array
{
if ($lineByUserId === []) {
return [];
}
$rows = UserSalesVolume::query()
->whereIn('user_id', array_keys($lineByUserId))
->where('month', $month)
->where('year', $year)
->whereIn('id', function ($sub) use ($month, $year) {
$sub->selectRaw('MAX(id)')
->from('user_sales_volumes')
->where('month', $month)
->where('year', $year)
->groupBy('user_id');
})
->get(['user_id', 'month_TP_points', 'month_shop_points']);
$byLine = [];
foreach ($rows as $row) {
$line = $lineByUserId[(int) $row->user_id] ?? null;
if ($line !== null) {
$byLine[$line] = ($byLine[$line] ?? 0.0) + (float) $row->month_TP_points + (float) $row->month_shop_points;
}
}
return $byLine;
}
/**
* @param array<int, float> $linePoints
*/
private function sumLinePoints(array $linePoints, int $maxLine): float
{
$sum = 0.0;
foreach ($linePoints as $line => $points) {
if ($line <= $maxLine) {
$sum += $points;
}
}
return $sum;
}
/**
* Payline-Tiefe bestimmen und die Mitglieder der bezahlten Ebenen einsammeln.
*
* Die Tiefe kommt aus der MONATLICHEN Qualifikationsstufe. Die wird jeden
* Monat neu erarbeitet und ist am 1. für jeden Berater noch leer
* ({@see BusinessUserItemOptimized::calcuQualLevel()} findet erst ab der
* qual_kp des niedrigsten Levels ein Ergebnis) — die Struktur-Kacheln
* "Teampartner in der Payline" und "Abos in der Payline" stünden dann auf 0,
* obwohl sich an der Teamstruktur nichts geändert hat.
*
* Solange die Monatsquali aussteht, gilt deshalb die Ebenenzahl des
* Karriere-Levels (Kundenentscheid 2026-09-01). Punkte- und Provisionswerte
* bleiben davon unberührt: die kommen direkt aus dem Snapshot und sind ohne
* Qualifikation korrekt 0.
*
* @return array{0: array{paylines: int, member_ids: int[], is_career_fallback: bool}, 1: bool} [Kontext, Lauf fehlgeschlagen]
*/
private function buildPaylineContext(User $user, int $month, int $year): array
{
$snapshot = $this->commissionService->snapshot($user, $month, $year);
$failed = (bool) ($snapshot['failed'] ?? false);
$paylines = $snapshot['paylines'];
$isCareerFallback = false;
if ($paylines <= 0) {
$paylines = (int) ($user->user_level?->paylines ?? 0);
$isCareerFallback = $paylines > 0;
}
if ($paylines <= 0) {
return [['paylines' => 0, 'member_ids' => [], 'is_career_fallback' => false], $failed];
}
$memberIds = collect($this->dashboardService->lineBuckets($user->id))
->filter(fn ($users, int $line) => $line <= $paylines)
->flatMap(fn ($users) => $users->pluck('id'))
->map(fn ($id) => (int) $id)
->values()
->all();
return [['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => $isCareerFallback], $failed];
}
/**
* Soll: Abo-Punkte-Bestand (Produktpunkte × Menge, Hauptartikel comp = 0)
* aller aktiven Abos der Payline-Mitglieder — Berater-Abos und Kundenabos.
*
* @param int[] $memberIds
*/
private function aboTargetPoints(array $memberIds): float
{
if ($memberIds === []) {
return 0.0;
}
return (float) 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])
->where('user_abo_items.comp', 0)
->where(function (Builder $query) use ($memberIds) {
$query->where(fn (Builder $inner) => $inner->where('user_abos.is_for', 'me')->whereIn('user_abos.user_id', $memberIds))
->orWhere(fn (Builder $inner) => $inner->where('user_abos.is_for', 'ot')->whereIn('user_abos.member_id', $memberIds));
})
->selectRaw('COALESCE(SUM(products.points * user_abo_items.qty), 0) as points')
->value('points');
}
/**
* Ist: im Monat abgerechnete Abo-Punkte der Payline-Mitglieder.
*
* @param int[] $memberIds
*/
private function aboActualPoints(array $memberIds, int $month, int $year): float
{
if ($memberIds === []) {
return 0.0;
}
return (float) UserSalesVolume::query()
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
->whereIn('user_id', $memberIds)
->where('month', $month)
->where('year', $year)
->selectRaw(SalesVolumePoints::aboPoints().' as points')
->value('points');
}
/**
* Noch offene Abo-Ausführungen des laufenden Monats: aktive Abos der
* Payline-Mitglieder plus — je nach {@see INCLUDE_OWN_ABOS} — die eigenen
* Abos/Kundenabos des Betrachters.
*
* @param int[] $memberIds
*/
private function upcomingAboQuery(User $user, array $memberIds, Carbon $today): Builder
{
$aboUserIds = self::INCLUDE_OWN_ABOS
? array_values(array_unique(array_merge([(int) $user->id], $memberIds)))
: $memberIds;
return UserAbo::query()
->where('active', true)
->where('status', 2)
->openExecutionUntil($today, $today->copy()->endOfMonth())
->where(function (Builder $query) use ($aboUserIds) {
$query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $aboUserIds))
->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $aboUserIds));
});
}
}