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:
parent
384b2dcff1
commit
42ec51cb36
10 changed files with 733 additions and 24 deletions
|
|
@ -34,7 +34,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property int|null $user_invoice_id
|
||||
* @property int|null $month_shop_points
|
||||
* @property float|null $month_shop_total_net
|
||||
* @property-read \App\Models\UserInvoice|null $user_invoice
|
||||
* @property-read UserInvoice|null $user_invoice
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume newQuery()
|
||||
|
|
@ -70,8 +70,10 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume whereMonthTPPoints($value)
|
||||
*
|
||||
* @property int|null $status_turnover
|
||||
* @property int|null $storno_origin_status
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume whereStatusTurnover($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|UserSalesVolume whereStornoOriginStatus($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
|
|
@ -91,6 +93,7 @@ class UserSalesVolume extends Model
|
|||
'month_shop_points' => 'float',
|
||||
'status_points' => 'int',
|
||||
'status_turnover' => 'int',
|
||||
'storno_origin_status' => 'int',
|
||||
'total_net' => 'float',
|
||||
'month_total_net' => 'float',
|
||||
'month_shop_total_net' => 'float',
|
||||
|
|
@ -115,6 +118,7 @@ class UserSalesVolume extends Model
|
|||
'month_shop_points',
|
||||
'status_points',
|
||||
'status_turnover',
|
||||
'storno_origin_status',
|
||||
'total_net',
|
||||
'month_total_net',
|
||||
'month_shop_total_net',
|
||||
|
|
@ -247,6 +251,32 @@ class UserSalesVolume extends Model
|
|||
return isset(self::$statusTypes[$this->status]) ? __('payment.'.self::$statusTypes[$this->status]) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehoert die Buchung zu einer Abo-Lieferung?
|
||||
*
|
||||
* Setzt eine geladene `shopping_order`-Relation voraus — jede Liste, die das
|
||||
* auswertet, laedt sie bereits eager mit.
|
||||
*/
|
||||
public function isAboOrder(): bool
|
||||
{
|
||||
return (bool) $this->shopping_order?->is_abo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Art der Buchung, bei Abo-Lieferungen mit dem Abo-Zusatz dahinter
|
||||
* ("Shopbestellung (Abo)") — Kundenwunsch 2026-09-08.
|
||||
*/
|
||||
public function getStatusTypeWithAbo(): string
|
||||
{
|
||||
$type = (string) $this->getStatusType();
|
||||
|
||||
if (! $this->isAboOrder()) {
|
||||
return $type;
|
||||
}
|
||||
|
||||
return trim($type.' '.__('abo.abo_order_short'));
|
||||
}
|
||||
|
||||
public static function getTransStatusType()
|
||||
{
|
||||
$ret = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\BusinessPlan;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
|
|
@ -11,6 +12,14 @@ use stdClass;
|
|||
|
||||
class SalesPointsVolume
|
||||
{
|
||||
/**
|
||||
* Schalter fuer Stornos aus bereits abgeschlossenen Monaten (Kundenentscheidung
|
||||
* 2026-09-09): standardmaessig `false` — ein abgeschlossener Monat ist fixiert,
|
||||
* die Gutschriften sind erstellt, die Punkte bleiben unveraendert. Auf `true`
|
||||
* gesetzt wird der Stornobetrag stattdessen vom laufenden Monat abgezogen.
|
||||
*/
|
||||
public const SETTING_STORNO_CURRENT_MONTH = 'storno-points-current-month';
|
||||
|
||||
public static function changeSalesPointsVolumeUser(ShoppingOrder $shoppingOrder, $to_user_id)
|
||||
{
|
||||
|
||||
|
|
@ -103,10 +112,28 @@ class SalesPointsVolume
|
|||
$month_total_net += $userSalesVolume->total_net;
|
||||
break;
|
||||
case 6: // Storno - negative Punkte und Beträge
|
||||
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
|
||||
// Exaktes Spiegelbild der Ursprungsbuchung. Punkte und Umsatz folgen
|
||||
// dabei UNTERSCHIEDLICHEN Regeln:
|
||||
// - Punkte: nur eine stornierte Shop-Bestellung (Status 2) mindert die
|
||||
// Shop-Punkte. Gutschrift (4), Eigenbestellung (1) und Registrierung (5)
|
||||
// mindern KP/TP — genau wie ihre Ursprungszweige oben.
|
||||
// - Umsatz: folgt `status_turnover`, wie im Gutschrift-Zweig (Case 4).
|
||||
// Die Ursprungsart steht in `storno_origin_status`
|
||||
// ({@see self::cancelSalesPointsVolume()}). Altzeilen ohne diese Angabe
|
||||
// fallen auf `status_turnover` zurueck: abgeschlossene Monate bleiben so,
|
||||
// wie sie abgerechnet wurden.
|
||||
$originStatus = $userSalesVolume->storno_origin_status;
|
||||
$pointsToShop = $originStatus !== null
|
||||
? (int) $originStatus === 2
|
||||
: $userSalesVolume->status_turnover === 2;
|
||||
|
||||
if ($pointsToShop) {
|
||||
$month_shop_points += $userSalesVolume->points; // bereits negativ
|
||||
} else {
|
||||
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
|
||||
}
|
||||
|
||||
if ($userSalesVolume->status_turnover === 2) {
|
||||
$month_shop_points += $userSalesVolume->points; // bereits negativ
|
||||
$month_shop_total_net += $userSalesVolume->total_net; // bereits negativ
|
||||
} else {
|
||||
$month_total_net += $userSalesVolume->total_net; // bereits negativ
|
||||
|
|
@ -269,21 +296,92 @@ class SalesPointsVolume
|
|||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen Storno-Eintrag für eine stornierte Rechnung
|
||||
* Negative Punkte werden dem aktuellen Monat zugeordnet
|
||||
* Umsatzzuordnung der Stornobuchung: 2 = Shop-Umsatz, 1 = Eigenumsatz.
|
||||
*
|
||||
* Frueher wurde `status_turnover` unveraendert aus der Ursprungsbuchung
|
||||
* uebernommen. Das Feld wird auf dem regulaeren Bestellweg aber gar nicht
|
||||
* gesetzt (NULL), weshalb der Storno einer Kundenbestellung faelschlich als
|
||||
* Eigenumsatz galt. Eine Gutschrift bringt ihre Zuordnung dagegen selbst mit
|
||||
* und behaelt sie.
|
||||
*
|
||||
* Achtung: Dies steuert ausschliesslich den UMSATZ. Wohin die Punkte gehen,
|
||||
* entscheidet die Ursprungsart in `storno_origin_status`.
|
||||
*/
|
||||
public static function stornoTurnoverFor(UserSalesVolume $original_sales_volume): int
|
||||
{
|
||||
if ((int) $original_sales_volume->status === 2) { // Shop-/Kundenbestellung
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ((int) $original_sales_volume->status === 4) { // Gutschrift: eigene Zuordnung
|
||||
return (int) $original_sales_volume->status_turnover === 2 ? 2 : 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Faellt die Ursprungsbuchung in den laufenden Monat, ist dieser noch nicht
|
||||
* ausgewertet — der Storno darf ihn mindern. Ein abgeschlossener Monat ist
|
||||
* dagegen fixiert (monatliche Auswertung gelaufen, Gutschriften erstellt).
|
||||
*/
|
||||
public static function isStornoMonthOpen(UserSalesVolume $original_sales_volume): bool
|
||||
{
|
||||
// now() statt date(): Pruefung und Buchung muessen derselben Uhr folgen,
|
||||
// sonst haengt das Ergebnis in Tests an der echten Systemuhr.
|
||||
return (int) $original_sales_volume->month === (int) now()->month
|
||||
&& (int) $original_sales_volume->year === (int) now()->year;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sollen Stornos aus abgeschlossenen Monaten ersatzweise den laufenden Monat
|
||||
* mindern? {@see self::SETTING_STORNO_CURRENT_MONTH}
|
||||
*/
|
||||
public static function stornoDeductsInCurrentMonth(): bool
|
||||
{
|
||||
return Setting::getContentBySlug(self::SETTING_STORNO_CURRENT_MONTH) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen Storno-Eintrag für eine stornierte Rechnung.
|
||||
*
|
||||
* Stornoregel (Kundenentscheidung 2026-09-09): Nur solange der Monat der
|
||||
* Ursprungsbuchung noch laeuft, wird die Punktekorrektur gebucht. Ist der
|
||||
* Monat abgeschlossen, passiert nichts mehr — ein Minus im Folgemonat waere
|
||||
* fuer den Berater nicht nachvollziehbar. Ausnahme ist der Schalter
|
||||
* {@see self::SETTING_STORNO_CURRENT_MONTH}: steht er auf `true`, wird der
|
||||
* Betrag doch vom laufenden Monat abgezogen (bisheriges Verhalten).
|
||||
*
|
||||
* Die Stornorechnung selbst wird davon nicht beruehrt; hier geht es
|
||||
* ausschliesslich um die Punkte-/Umsatzbuchung.
|
||||
*
|
||||
* @param UserSalesVolume $original_sales_volume Der ursprüngliche Sales Volume Eintrag
|
||||
* @param int $cancellation_invoice_id Die ID der Stornorechnung
|
||||
* @return UserSalesVolume
|
||||
* @return UserSalesVolume|null null, wenn der Monat fixiert ist und nicht nachgebucht wird
|
||||
*/
|
||||
public static function cancelSalesPointsVolume(UserSalesVolume $original_sales_volume, $cancellation_invoice_id)
|
||||
public static function cancelSalesPointsVolume(UserSalesVolume $original_sales_volume, $cancellation_invoice_id): ?UserSalesVolume
|
||||
{
|
||||
$month = date('m');
|
||||
$year = date('Y');
|
||||
$date = date('d.m.Y');
|
||||
if (! self::isStornoMonthOpen($original_sales_volume) && ! self::stornoDeductsInCurrentMonth()) {
|
||||
\Log::info('Punktekorrektur für Stornorechnung übersprungen: Monat bereits abgeschlossen', [
|
||||
'original_invoice_id' => $original_sales_volume->user_invoice_id,
|
||||
'cancellation_invoice_id' => $cancellation_invoice_id,
|
||||
'original_points' => $original_sales_volume->points,
|
||||
'user_id' => $original_sales_volume->user_id,
|
||||
'original_month' => $original_sales_volume->month,
|
||||
'original_year' => $original_sales_volume->year,
|
||||
'setting' => self::SETTING_STORNO_CURRENT_MONTH,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$bookedAt = now();
|
||||
$month = $bookedAt->month;
|
||||
$year = $bookedAt->year;
|
||||
$date = $bookedAt->format('d.m.Y');
|
||||
|
||||
$syslog = [
|
||||
date('d.m.Y H:i:s') => 'Stornorechnung erstellt - Punkte korrigiert (Original Invoice: #'.$original_sales_volume->user_invoice_id.')',
|
||||
$bookedAt->format('d.m.Y H:i:s') => 'Stornorechnung erstellt - Punkte korrigiert (Original Invoice: #'.$original_sales_volume->user_invoice_id.')',
|
||||
];
|
||||
|
||||
// Negativen Eintrag erstellen (alle Werte negativ)
|
||||
|
|
@ -303,8 +401,9 @@ class SalesPointsVolume
|
|||
'month_total_net' => 0,
|
||||
'status' => 6, // Status 6 = cancelled/storniert
|
||||
'status_points' => $original_sales_volume->status_points,
|
||||
'status_turnover' => $original_sales_volume->status_turnover,
|
||||
'message' => 'Storniert am '.date('d.m.Y'),
|
||||
'status_turnover' => self::stornoTurnoverFor($original_sales_volume),
|
||||
'storno_origin_status' => (int) $original_sales_volume->status,
|
||||
'message' => 'Storniert am '.$bookedAt->format('d.m.Y'),
|
||||
'info' => 'Storno für Invoice #'.$original_sales_volume->user_invoice_id,
|
||||
'syslog' => $syslog,
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue