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>
This commit is contained in:
Kevin 2026-09-10 11:18:40 +00:00
parent 75c2790bd2
commit 1900860c74
10 changed files with 733 additions and 24 deletions

View file

@ -31,6 +31,10 @@ class PersonalStatisticsService
->where('user_id', $user->id)
->where('month', $month)
->where('year', $year)
// Dieselbe Abgrenzung wie die Team-Auswertung: nicht zugeordnete (0) und
// pending (3) Buchungen zaehlen nirgends mit, sonst weicht die persoenliche
// Summe von der gemeinsamen Punktebasis ab.
->whereIn('status', SalesVolumePoints::countableStatuses())
->orderBy('id')
->get();
@ -78,6 +82,37 @@ class PersonalStatisticsService
return $order->is_abo ? 'abo' : 'shop';
}
if ($status === 6) {
return $this->stornoGroupKey($volume, $order);
}
return 'other';
}
/**
* Ein Storno gehoert in das Segment seiner Ursprungsbuchung — sonst steht das
* Minus unter "Sonstige", waehrend der stornierte Umsatz weiter im Shop- oder
* Abo-Segment ausgewiesen wird.
*
* Altzeilen ohne festgehaltene Ursprungsart bleiben unter "Sonstige": ihre
* Herkunft ist nicht rekonstruierbar.
*/
private function stornoGroupKey(UserSalesVolume $volume, mixed $order): string
{
$origin = $volume->storno_origin_status;
if ($origin === null) {
return 'other';
}
if ((int) $origin === 1) {
return 'own';
}
if ((int) $origin === 2 && $order) {
return $order->is_abo ? 'abo' : 'shop';
}
return 'other';
}
@ -108,10 +143,11 @@ class PersonalStatisticsService
'points' => (float) $volume->points,
'total_net' => (float) $volume->total_net,
'status' => (int) $volume->status,
'status_label' => $volume->getStatusType(),
'status_label' => $volume->getStatusTypeWithAbo(),
'status_color' => $volume->getStatusColor(),
'order_id' => $order?->id,
'order_type' => $order ? ((int) $volume->status === 1 ? 'own' : 'shop') : null,
'is_abo' => $volume->isAboOrder(),
'customer_name' => $customer ? trim(($customer->billing_firstname ?? '').' '.($customer->billing_lastname ?? '')) : null,
'customer_email' => $customer?->billing_email,
'info' => $volume->info,

View file

@ -17,27 +17,64 @@ use Illuminate\Support\Collection;
* `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-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
{
private const OWN_PREDICATE = 'user_sales_volumes.status IN (1, 4, 5, 6)';
/**
* 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 SHOP_PREDICATE = '(user_sales_volumes.status = 2 OR (user_sales_volumes.status = 6 AND user_sales_volumes.status_turnover = 2))';
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');