2))'; private const SHOP_PREDICATE = '(user_sales_volumes.status = 2 OR (user_sales_volumes.status = 6 AND '.self::STORNO_ORIGIN.' = 2))'; private const TURNOVER_PREDICATE = 'user_sales_volumes.status IN (1, 2, 4, 5, 6)'; /** * Provisionspunkte (TP + Shop) je Zeile — dieselbe Abgrenzung, mit der * {@see SalesPointsVolume::reCalculateSalesPointsVolume()} die Laufstaende * `month_TP_points` und `month_shop_points` fuehrt, aus denen der TreeCalcBot * die Team-Punkte der Struktur-/Provisionsansicht bildet * ({@see UserSalesVolume::getPointsTPSum()}): * - Eigenbuchungen zaehlen nur, wenn sie NICHT als reine Karrierepunkte * gekennzeichnet sind (`status_points = 2` — Neupartner-Einschreibung, * manuelle Gutschriften); NULL gilt wie 1 (PHP-Vergleich `=== 2`). * - Shop-/Kundenbuchungen zaehlen immer. */ private const COMMISSION_PREDICATE = '(('.self::OWN_PREDICATE.' AND COALESCE(user_sales_volumes.status_points, 1) <> 2) OR '.self::SHOP_PREDICATE.')'; /** * Die zaehlbaren Buchungsstatus als Liste — fuer Auswertungen, die nicht ueber * die SQL-Ausdruecke hier gehen, aber dieselbe Abgrenzung brauchen. Status 0 * (nicht zugeordnet) und 3 (pending) zaehlen nicht. * * @return int[] */ public static function countableStatuses(): array { return [1, 2, 4, 5, 6]; } public static function ownPoints(): string { return self::sumWhere(self::OWN_PREDICATE, 'user_sales_volumes.points'); } /** * Das Kundenpunkte-Prädikat ohne SUM-Hülle — für Auswertungen, die dieselben * Zeilen brauchen, aber auf Positionsebene weiterrechnen (Aufteilung der * Abo-Punkte in wiederkehrende und einmalig hinzugebuchte). */ public static function shopPredicate(): string { return self::SHOP_PREDICATE; } 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 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'); } /** * Provisionspunkte als SUM-Ausdruck (ungefloort — der Provisionsbaum wertet * einen negativen Monatssaldo je Berater mit 0, siehe * {@see commissionPointsByUser()}). */ public static function commissionPoints(): string { return self::sumWhere(self::COMMISSION_PREDICATE, 'user_sales_volumes.points'); } /** * Provisionspunkte je User im Monat — Grundlage der Kennzahl „Umsatz (Punkte)" * der Backoffice-Statistik, damit sie mit der Team-Struktur uebereinstimmt. * * Bewusst je User statt als Gesamtsumme: der TreeCalcBot addiert je Knoten * nur Werte > 0 ({@see TreeCalcBotOptimized}), * ein negativer Monatssaldo (Storno ueber die Buchungen des Monats hinaus) * zaehlt dort also 0 und nicht negativ. Der Aufrufer floort deshalb je User. * * @param int[] $userIds * @return Collection user_id => Punkte (ungefloort) */ public static function commissionPointsByUser(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::commissionPoints().') as commission_points') ->pluck('commission_points', 'user_id') ->map(fn ($points) => (float) $points); } /** * Summe der Provisionspunkte mit der Knotenregel des Provisionsbaums (negativer * Saldo je User = 0) plus den dabei nicht abgezogenen Negativsaldo, damit die * Statistik ausweisen kann, warum die Summe nicht der reinen Zeilensumme entspricht. * * @param Collection $pointsByUser * @return array{points: float, storno_floor: float} */ public static function floorPerUser(Collection $pointsByUser): array { $points = 0.0; $stornoFloor = 0.0; foreach ($pointsByUser as $userPoints) { if ($userPoints > 0) { $points += $userPoints; } else { $stornoFloor += -$userPoints; } } return ['points' => round($points, 2), 'storno_floor' => round($stornoFloor, 2)]; } 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)'; } }