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>
234 lines
9.3 KiB
PHP
234 lines
9.3 KiB
PHP
<?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)';
|
||
}
|
||
}
|