mivita/app/Services/Backoffice/SalesVolumePoints.php
Kevin 1900860c74 Statistik: Stornos mindern die richtige Punktekategorie
Ein Storno einer Shop-Bestellung wurde als Eigenpunkt gebucht und liess damit
die Shop-Provisionsbasis ungemindert. Betroffen sind 7 Faelle aus April bis
August 2026 (178,82 EUR Differenz in der Provisionsbasis, gegen user_businesses
geprueft).

Ursache: `status_turnover` beschreibt nur, wohin der UMSATZ gehoert. Die Punkte
folgen einer anderen Regel — eine Gutschrift (Status 4) bucht ihre Punkte immer
auf KP/TP, auch wenn ihr Umsatz dem Shop zugeordnet ist. Die Gegenbuchung kann
diese Aufteilung ohne die Ursprungsart nicht spiegeln, deshalb haelt
`storno_origin_status` sie jetzt auf der Stornozeile fest. NULL heisst
"Altzeile ohne Angabe" und faellt auf das bisherige Verhalten zurueck —
abgeschlossene Monate aendern sich dadurch nicht.

Stornoregel (Kundenentscheidung): Ein Storno mindert nur den Monat der
Ursprungsbestellung. Ist der Monat abgeschlossen, passiert nichts mehr, denn
Abrechnung und Gutschriften stehen. Ein Minus in den neuen Monat mitzunehmen
waere nicht nachvollziehbar. Fuer den Ausnahmefall gibt es den Settings-Schalter
`storno-points-current-month`, standardmaessig aus.

Ausserdem: Die persoenliche Monatsstatistik summierte Status 0 (nicht
zugeordnet) und 3 (pending) mit, die gemeinsame Punkteauswertung nicht. Beide
nutzen jetzt dieselbe Statusliste, und ein Storno mindert das Segment seiner
Ursprungsbuchung statt pauschal in "Sonstige" zu laufen.

Die Migration fuer `status_turnover` zieht nur das Schema auf den tatsaechlichen
Stand nach (nullable statt NOT NULL) — auf Produktion faktisch wirkungslos,
aber Neuinstallationen und die Testdatenbank haben damit dieselbe Semantik.

Audit-Befunde A02, A06, A15, R01-R03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:18:40 +00:00

157 lines
6.2 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 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)';
/**
* 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');
}
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)';
}
}