paylineContext() nahm die gesamte Downline, lineByUserId() und die Team-Statistik den Provisionsbaum. "Teampartner/Abos in der Payline" und die Payline-Prognose zaehlten damit auch Berater ohne Mitgliedsebene oder Abrechnungskonto samt ihrem Unterbaum, deren Abo-Punkte nie in die Provision eingehen. An der Live-Kopie betrifft das 102 von 162 Beratern mit Payline. Nach dem Deploy cache:clear (payline_context_*, 6 h). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
674 lines
28 KiB
PHP
674 lines
28 KiB
PHP
<?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;
|
||
|
||
/**
|
||
* Stichtage der Prognose-Kacheln. Seit Ticket A1 sind Liefertage frei (1-31);
|
||
* die vier klassischen Termine bleiben als Orientierung, dazu kommt das
|
||
* Monatsende, damit Abos nach dem 25. nicht aus der letzten Kachel fallen.
|
||
*/
|
||
public const FORECAST_DAYS = [5, 10, 20, 25];
|
||
|
||
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").
|
||
*
|
||
* Ohne das eigene Abo und ohne Payline, aber MIT dem, was im Monat aus den
|
||
* Kundenabos schon abgerechnet ist — und zwar taggenau: jeder Termin zeigt
|
||
* den Stand, den er an diesem Tag tatsaechlich hatte, plus die bis dahin noch
|
||
* offenen Lieferungen. Damit laesst sich die Kachelreihe gegen die Liste
|
||
* darunter nachrechnen, was mit einer pauschalen Monatssumme als Basis nicht
|
||
* ging: ein vergangener Termin haette dann auch spaetere Lieferungen enthalten.
|
||
*
|
||
* Anders als {@see forecastDays()} auf dem Dashboard, wo die Basis ein
|
||
* einzelner Tagesstand (Kundenumsatz + Payline-Punkte) ist und fuer alle
|
||
* Termine gleich bleibt.
|
||
*
|
||
* @return array{base: float, days: array<int, array{day: int, date: string, billed: float, 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();
|
||
|
||
$billedByDay = $this->billedCustomerAboPointsByDay($user, (int) $today->month, (int) $today->year);
|
||
|
||
return [
|
||
'base' => (float) array_sum($billedByDay),
|
||
'days' => $this->cumulateByDeliveryDay($abos, 0.0, $today, $billedByDay),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Grundabfrage fuer die im Monat abgerechneten Punkte aus den EIGENEN
|
||
* Kundenabos des Beraters.
|
||
*
|
||
* Abgrenzung wie in der Backoffice-Statistik
|
||
* ({@see SalesVolumePoints::shopPredicate()}): Shop-/Kundenbuchungen aus
|
||
* Abo-Bestellungen, Stornos vorzeichengetreu. Das eigene Abo des Beraters
|
||
* faellt heraus, es laeuft als Beraterbestellung (Status 1) nicht ueber
|
||
* dieses Praedikat.
|
||
*
|
||
* Gefiltert statt als SUM(CASE ...), damit dieselbe Menge sowohl je Liefertag
|
||
* als auch je Abo gruppiert werden kann — die Vorschau und die Liste darunter
|
||
* muessen auf dieselbe Summe kommen.
|
||
*/
|
||
private function billedCustomerAboQuery(User $user, int $month, int $year): Builder
|
||
{
|
||
return UserSalesVolume::query()
|
||
->leftJoin('shopping_orders', 'shopping_orders.id', '=', 'user_sales_volumes.shopping_order_id')
|
||
->where('user_sales_volumes.user_id', $user->id)
|
||
->where('user_sales_volumes.month', $month)
|
||
->where('user_sales_volumes.year', $year)
|
||
->where('shopping_orders.is_abo', 1)
|
||
->whereRaw(SalesVolumePoints::shopPredicate());
|
||
}
|
||
|
||
/**
|
||
* Im Monat abgerechnete Kundenabo-Punkte, nach dem Kalendertag der Buchung
|
||
* gruppiert. Gruppiert wird in PHP: es sind wenige Zeilen je Berater und
|
||
* Monat, und eine Datumsfunktion in SQL waere zwischen MySQL und der
|
||
* SQLite-Testdatenbank nicht dieselbe.
|
||
*
|
||
* @return array<int, float> Tag im Monat => Punkte
|
||
*/
|
||
private function billedCustomerAboPointsByDay(User $user, int $month, int $year): array
|
||
{
|
||
$byDay = [];
|
||
|
||
foreach ($this->billedCustomerAboQuery($user, $month, $year)
|
||
->select('user_sales_volumes.points', 'user_sales_volumes.created_at')
|
||
->get() as $row) {
|
||
$day = $row->created_at ? (int) $row->created_at->day : 1;
|
||
$byDay[$day] = ($byDay[$day] ?? 0.0) + (float) $row->points;
|
||
}
|
||
|
||
return $byDay;
|
||
}
|
||
|
||
/**
|
||
* Im Monat abgerechnete Kundenabo-Punkte je Abo — fuer die Spalte „diesen
|
||
* Monat abgerechnet" in der Kundenabo-Liste. Enthaelt auch Einmal-Artikel und
|
||
* sonstige Zubuchungen der jeweiligen Lieferung; die werden nach der
|
||
* Ausfuehrung aus dem Abo entfernt und stehen deshalb nicht mehr im
|
||
* wiederkehrenden Aboinhalt.
|
||
*
|
||
* @return Collection<int, float> user_abo_id => Punkte
|
||
*/
|
||
public function billedCustomerAboPointsByAbo(User $user, int $month, int $year): Collection
|
||
{
|
||
return $this->billedCustomerAboQuery($user, $month, $year)
|
||
->join('user_abo_orders', 'user_abo_orders.shopping_order_id', '=', 'user_sales_volumes.shopping_order_id')
|
||
->groupBy('user_abo_orders.user_abo_id')
|
||
->select('user_abo_orders.user_abo_id')
|
||
->selectRaw('COALESCE(SUM(user_sales_volumes.points), 0) as points')
|
||
->pluck('points', 'user_abo_id')
|
||
->map(fn ($points) => (float) $points);
|
||
}
|
||
|
||
/**
|
||
* Monatssumme der abgerechneten Kundenabo-Punkte. Massgeblich fuer die
|
||
* Vorschau; {@see billedCustomerAboPointsByAbo()} kann davon abweichen, wenn
|
||
* eine Buchung keiner Abo-Ausfuehrung zugeordnet ist — die Liste weist die
|
||
* Differenz dann gesondert aus, damit die Addition aufgeht.
|
||
*/
|
||
public function billedCustomerAboPoints(User $user, int $month, int $year): float
|
||
{
|
||
return (float) array_sum($this->billedCustomerAboPointsByDay($user, $month, $year));
|
||
}
|
||
|
||
/**
|
||
* 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 $billedByDay = null): array
|
||
{
|
||
$pointsByDay = [];
|
||
foreach ($abos as $abo) {
|
||
$day = (int) Carbon::parse($abo->getRawOriginal('next_date'))->day;
|
||
$pointsByDay[$day] = ($pointsByDay[$day] ?? 0.0) + (float) $abo->getTotalPoints();
|
||
}
|
||
|
||
$lastDeliveryDay = (int) $today->daysInMonth;
|
||
$deliveryDays = array_values(array_unique([...self::FORECAST_DAYS, $lastDeliveryDay]));
|
||
|
||
$days = [];
|
||
foreach ($deliveryDays as $deliveryDay) {
|
||
$upcoming = 0.0;
|
||
foreach ($pointsByDay as $day => $points) {
|
||
if ($day <= $deliveryDay) {
|
||
$upcoming += $points;
|
||
}
|
||
}
|
||
|
||
if ($billedByDay === null) {
|
||
$base = $basePoints;
|
||
} else {
|
||
$base = 0.0;
|
||
foreach ($billedByDay as $day => $points) {
|
||
// Buchungen nach dem letzten Liefertag (Nachzuegler, manuelle
|
||
// Gutschriften) haengen sich an den letzten Termin, damit die
|
||
// letzte Kachel immer den Monatsstand zeigt.
|
||
if ($day <= $deliveryDay || ($deliveryDay === $lastDeliveryDay && $day > $lastDeliveryDay)) {
|
||
$base += $points;
|
||
}
|
||
}
|
||
}
|
||
|
||
$days[] = [
|
||
'day' => $deliveryDay,
|
||
'date' => $today->copy()->startOfMonth()->addDays($deliveryDay - 1)->format(\Util::formatDateDB()),
|
||
'billed' => $base,
|
||
'upcoming' => $upcoming,
|
||
'total' => $base + $upcoming,
|
||
'is_past' => $deliveryDay < (int) $today->day,
|
||
'is_month_end' => $deliveryDay === $lastDeliveryDay,
|
||
];
|
||
}
|
||
|
||
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
|
||
* dem Provisionsbaum der Statistik nach
|
||
* ({@see BackofficeDashboardService::commissionLineBuckets()} — dieselbe
|
||
* Filter- und Kapp-Regel wie {@see BusinessUserItemOptimized::readParentsBusinessUsers()}).
|
||
* 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
|
||
{
|
||
$map = [];
|
||
|
||
foreach ($this->dashboardService->commissionLineBuckets($user->id, $month, $year) as $line => $users) {
|
||
foreach ($users as $member) {
|
||
$map[(int) $member->id] = (int) $line;
|
||
}
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
/**
|
||
* 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];
|
||
}
|
||
|
||
// Mitglieder aus dem Provisionsbaum, wie lineByUserId() und die Team-Statistik
|
||
// (Audit A07): Wer dort ausgefiltert oder gekappt ist, zaehlt fuer die Payline nicht.
|
||
$memberIds = collect($this->dashboardService->commissionLineBuckets($user->id, $month, $year))
|
||
->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));
|
||
});
|
||
}
|
||
}
|