mivita/app/Services/Backoffice/SalesVolumePoints.php
Kevin 96545364f1 Team-Statistik = Team-Struktur (Provisionsbaum, Umschalter) und Laufstand-Fix in user_sales_volumes
K11 aus dem September-Protokoll (Tickets C1 und C2 im Entwicklungsplan), live seit 14.09.2026.

C1 – Statistik rechnet wie die Provision
- Standard-Baum ist der Provisionsbaum (Mitglieder wie TreeCalcBot: Mitgliedsebene,
  Abrechnungskonto, active_date; ausgefilterte Knoten kappen ihren Unterbaum, max. 21 Linien).
  Gesamte Downline bleibt per Umschalter erreichbar, Wahl in der Session.
- Punkte-Definition: Provisionspunkte (TP + Shop), reine Karrierepunkte ausgewiesen statt
  eingerechnet, negative Monatssalden je User wie in der Provision auf 0 gekappt.
- Abgeschlossene Monate nehmen die Mitglieder je Linie aus dem gespeicherten Provisionslauf
  (user_businesses), Snapshots je Baum-Definition (Payload-Version 3, Migration).
- Struktur-Modal: Gesamt aus den Linien-Punkten mit Dezimalen statt der int-Spalte total_pp.
- Messlauf statistics:compare-trees (Downline / Provisionsbaum / Struktur je VIP, Laufstand-Drift).

C2 – Laufstand-Race (Provision zu niedrig bei zeitgleichen Buchungen)
- Neuberechnung liest die Zeilen des Beraters sperrend (FOR UPDATE), Index (user_id, year, month).
- Paid-Pfad der Payone- und Stripe-Webhooks als DB::transaction mit 3 Anlaeufen; Deadlock und
  Lock-Timeout aus der Rechnungserstellung werden nicht mehr geschluckt.
- Sicherheitsnetz sales:repair-running-totals taeglich 02:45 vor dem Provisionslauf;
  abgeschlossene Monate werden nicht angefasst (Entscheid 14.09.: keine Nachzahlung fuer 06/08).

Doku: Entwicklungsplan zum Protokoll, Deploy-Notiz 14.09. (inkl. Migrations-Stolperstein:
Migrationen tragen Werte als Literale, nicht als Konstanten aus dem Anwendungscode).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:31:32 +00:00

234 lines
9.3 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\Backoffice;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\BusinessPlan\TreeCalcBotOptimized;
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 (überschneidungsfrei — jede Zeile zählt genau einmal):
* - Eigenpunkte (KP): status 1, 4, 5 sowie Stornos (6), die KEINE Shop-Bestellung
* betreffen
* - Shop-/Kundenpunkte: status 2 sowie Stornos (6) einer Shop-Bestellung
* - status 0 (nicht zugeordnet) und 3 (pending) zählen nicht
*
* Massgeblich ist bei Stornos die Ursprungsart in `storno_origin_status` — NICHT
* `status_turnover`, denn eine stornierte Gutschrift bucht ihre Punkte auf KP/TP
* und nur ihren Umsatz auf den Shop. Altzeilen ohne Ursprungsart bleiben bei den
* Eigenpunkten; abgeschlossene Monate ändern sich dadurch nicht.
*
* Alle Spalten werden voll qualifiziert, da `shopping_orders` ebenfalls eine
* `points`-Spalte besitzt (Join-Mehrdeutigkeit).
*/
class SalesVolumePoints
{
/**
* Welche Buchungsart wurde storniert? Entspricht der Auswertung in
* {@see SalesPointsVolume::reCalculateSalesPointsVolume()}, Case 6: bevorzugt
* die festgehaltene Ursprungsart, fuer Altzeilen ersatzweise die
* Umsatzzuordnung. COALESCE/CASE statt NOT(...), damit NULL-Spalten nicht zu
* einem unbestimmten Vergleich fuehren und die Zeile aus beiden Kategorien fliegt.
*/
private const STORNO_ORIGIN = 'COALESCE(user_sales_volumes.storno_origin_status, CASE WHEN user_sales_volumes.status_turnover = 2 THEN 2 ELSE 1 END)';
private const OWN_PREDICATE = '(user_sales_volumes.status IN (1, 4, 5) OR (user_sales_volumes.status = 6 AND '.self::STORNO_ORIGIN.' <> 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<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');
}
/**
* 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<int, float> 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<int, float> $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)';
}
}