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>
120 lines
4.4 KiB
PHP
120 lines
4.4 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Backoffice;
|
||
|
||
use App\Models\UserSalesVolume;
|
||
use App\Services\BusinessPlan\SalesPointsVolume;
|
||
use Illuminate\Support\Collection;
|
||
|
||
/**
|
||
* Liefert SQL-Aggregat-Ausdrücke für die monatliche Punkte-/Umsatzauswertung
|
||
* der Backoffice-Statistik.
|
||
*
|
||
* Wichtig: In `user_sales_volumes` existiert pro Bestellung eine Zeile, und die
|
||
* Spalten `month_KP_points` / `month_shop_points` / `month_*_total_net` sind der
|
||
* kumulative Monats-Laufstand zum Zeitpunkt der Bestellung – KEINE Einzelbeträge.
|
||
* Eine SUM() über diese Spalten zählt mehrfach. Korrekt wird je Zeile die
|
||
* `points`/`total_net`-Spalte summiert und nach `status` gefiltert – analog zu
|
||
* {@see SalesPointsVolume::reCalculateSalesPointsVolume()}.
|
||
*
|
||
* Status-Zuordnung:
|
||
* - Eigenpunkte (KP): status 1, 4, 5, 6
|
||
* - Shop-/Kundenpunkte: status 2 (sowie status 6 mit status_turnover = 2)
|
||
* - status 0 (nicht zugeordnet) und 3 (pending) zählen nicht
|
||
*
|
||
* Alle Spalten werden voll qualifiziert, da `shopping_orders` ebenfalls eine
|
||
* `points`-Spalte besitzt (Join-Mehrdeutigkeit).
|
||
*/
|
||
class SalesVolumePoints
|
||
{
|
||
private const OWN_PREDICATE = 'user_sales_volumes.status IN (1, 4, 5, 6)';
|
||
|
||
private const SHOP_PREDICATE = '(user_sales_volumes.status = 2 OR (user_sales_volumes.status = 6 AND user_sales_volumes.status_turnover = 2))';
|
||
|
||
private const TURNOVER_PREDICATE = 'user_sales_volumes.status IN (1, 2, 4, 5, 6)';
|
||
|
||
public static function ownPoints(): string
|
||
{
|
||
return self::sumWhere(self::OWN_PREDICATE, 'user_sales_volumes.points');
|
||
}
|
||
|
||
public static function externalPoints(): string
|
||
{
|
||
return self::sumWhere(self::SHOP_PREDICATE, 'user_sales_volumes.points');
|
||
}
|
||
|
||
public static function customerAboPoints(): string
|
||
{
|
||
return self::sumWhere(self::SHOP_PREDICATE.' AND shopping_orders.is_abo = 1', 'user_sales_volumes.points');
|
||
}
|
||
|
||
public static function customerSingleOrderPoints(): string
|
||
{
|
||
return self::sumWhere(self::SHOP_PREDICATE.' AND shopping_orders.id IS NOT NULL AND (shopping_orders.is_abo = 0 OR shopping_orders.is_abo IS NULL)', 'user_sales_volumes.points');
|
||
}
|
||
|
||
public static function customerOtherPoints(): string
|
||
{
|
||
return self::sumWhere(self::SHOP_PREDICATE.' AND shopping_orders.id IS NULL', 'user_sales_volumes.points');
|
||
}
|
||
|
||
public static function totalPoints(): string
|
||
{
|
||
return self::ownPoints().' + '.self::externalPoints();
|
||
}
|
||
|
||
/**
|
||
* Monats-Gesamtpunkte je User als gebatchte Abfrage — die eine Stelle für
|
||
* "Gesamtpunkte pro Berater" (Firstlines-Seite, Recruiter-Ranking,
|
||
* Top-3-Karte), damit die Status-Semantik nicht in mehreren Kopien driftet.
|
||
*
|
||
* @param int[] $userIds
|
||
* @return Collection<int, float> user_id => Punkte
|
||
*/
|
||
public static function totalPointsByUser(array $userIds, int $month, int $year): Collection
|
||
{
|
||
if ($userIds === []) {
|
||
return collect();
|
||
}
|
||
|
||
return UserSalesVolume::query()
|
||
->whereIn('user_id', $userIds)
|
||
->where('month', $month)
|
||
->where('year', $year)
|
||
->groupBy('user_id')
|
||
->select('user_id')
|
||
->selectRaw('('.self::totalPoints().') as total_points')
|
||
->pluck('total_points', 'user_id')
|
||
->map(fn ($points) => (float) $points);
|
||
}
|
||
|
||
/**
|
||
* Punkte aus Abo-Bestellungen (eigene wie Kundenabos) — alle zählbaren Status.
|
||
* Benötigt den Join auf shopping_orders.
|
||
*/
|
||
public static function aboPoints(): string
|
||
{
|
||
return self::sumWhere(self::TURNOVER_PREDICATE.' AND shopping_orders.is_abo = 1', 'user_sales_volumes.points');
|
||
}
|
||
|
||
/**
|
||
* Punkte, die nur als Karrierepunkte (KP) des Beraters zählen und NICHT als
|
||
* Teampunkte in die Payline fließen (status_points = 2, z. B.
|
||
* Neupartner-Einschreibungspunkte, manuelle Gutschriften) — vgl.
|
||
* {@see SalesPointsVolume::add_KP_TP_Points()}.
|
||
*/
|
||
public static function nonPaylinePoints(): string
|
||
{
|
||
return self::sumWhere(self::OWN_PREDICATE.' AND user_sales_volumes.status_points = 2', 'user_sales_volumes.points');
|
||
}
|
||
|
||
public static function turnoverNet(): string
|
||
{
|
||
return self::sumWhere(self::TURNOVER_PREDICATE, 'user_sales_volumes.total_net');
|
||
}
|
||
|
||
private static function sumWhere(string $predicate, string $column): string
|
||
{
|
||
return 'COALESCE(SUM(CASE WHEN '.$predicate.' THEN '.$column.' ELSE 0 END), 0)';
|
||
}
|
||
}
|