mivita/app/Services/Abo/PaylineForecastService.php
Kevin d65922adfe Statistiken: Kundenfeedback 2026-08-19 komplett (N1-N8) + Review-Fixes + VP-/Incentive-Repair gehärtet
Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen):
- N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch
- N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell)
- N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt'
- N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände)
- N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung)
- N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter)
- N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix
- N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav)

Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types

Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:44:44 +00:00

470 lines
18 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\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,
) {}
/**
* 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.
*
* @return array{paylines: int, member_ids: int[]}
*/
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)) {
return $cached;
}
[$context, $failed] = $this->buildPaylineContext($user, $month, $year);
if (! $failed) {
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) — für die "Neue Abos in der Payline"-Kachel (N1).
*/
public function newPaylineAboCount(User $user, int $month, int $year): int
{
$memberIds = $this->paylineContext($user, $month, $year)['member_ids'];
if ($memberIds === []) {
return 0;
}
$startDate = Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
return UserAbo::query()
->activeAbo()
->whereBetween('start_date', [$startDate, $endDate])
->where(function (Builder $query) use ($memberIds) {
$query->where(fn (Builder $inner) => $inner->where('is_for', 'me')->whereIn('user_id', $memberIds))
->orWhere(fn (Builder $inner) => $inner->where('is_for', 'ot')->whereIn('member_id', $memberIds));
})
->count();
}
/**
* 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 (next_date > heute, im laufenden Monat). 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();
$pointsByDay = [];
foreach ($upcomingAbos 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 [
'base' => $basePoints,
'days' => $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);
$qualLevel = $snapshot['qual_level'];
if (! is_array($qualLevel) || ! empty($snapshot['failed'])) {
return null;
}
$today = Carbon::today();
$lineByUserId = $this->lineByUserId($user);
$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'];
$nextLevel = is_array($snapshot['next_level']) ? $snapshot['next_level'] : null;
$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;
}
$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 gesamten Downline (Linie je User-ID).
*
* @return array<int, int>
*/
private function lineByUserId(User $user): array
{
$map = [];
foreach ($this->dashboardService->lineBuckets($user->id) 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;
}
/**
* @return array{0: array{paylines: int, member_ids: int[]}, 1: bool} [Kontext, Lauf fehlgeschlagen]
*/
private function buildPaylineContext(User $user, int $month, int $year): array
{
$snapshot = $this->commissionService->snapshot($user, $month, $year);
$paylines = $snapshot['paylines'];
if ($paylines <= 0) {
return [['paylines' => 0, 'member_ids' => []], (bool) ($snapshot['failed'] ?? false)];
}
$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], false];
}
/**
* 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)
->where('next_date', '>', $today->copy()->endOfDay())
->where('next_date', '<=', $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));
});
}
}