mivita/app/Services/BusinessPlan/SalesPointsVolume.php
Kevin 42ec51cb36 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

429 lines
18 KiB
PHP

<?php
namespace App\Services\BusinessPlan;
use App\Models\Setting;
use App\Models\ShoppingOrder;
use App\Models\UserSalesVolume;
use App\Services\Incentive\IncentiveTracker;
use App\Services\Util;
use App\User;
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)
{
if ($shoppingOrder->user_sales_volume) {
$to_user_id = intval($to_user_id);
if ($shoppingOrder->user_sales_volume->user_id === $to_user_id) {
\Session()->flash('alert-error', 'Keine Änderung: selber Berater');
return;
}
if (! $shoppingOrder->user_sales_volume->isCurrentMonthYear()) {
\Session()->flash('alert-error', 'Änderung muss im selben Monat sein');
return;
}
$month = $shoppingOrder->user_sales_volume->month;
$year = $shoppingOrder->user_sales_volume->year;
$form_user_id = $shoppingOrder->user_sales_volume->user_id;
$to_user = User::find($to_user_id);
$form_user = User::find($form_user_id);
$shoppingOrder->user_sales_volume->user_id = $to_user_id;
$shoppingOrder->user_sales_volume->message = 'zugewiesen: '.date('d.m.Y');
$syslog = $shoppingOrder->user_sales_volume->syslog;
$from_email = $form_user ? $form_user->email : '';
$to_email = $to_user ? $to_user->email : '';
$syslog[date('d.m.Y-h:i:s')] = 'change form: #'.$form_user_id.' '.$from_email.' to: #'.$to_user_id.' '.$to_email;
$shoppingOrder->user_sales_volume->syslog = $syslog;
$shoppingOrder->user_sales_volume->save();
// recalculate
self::reCalculateSalesPointsVolume($to_user_id, $month, $year);
self::reCalculateSalesPointsVolume($form_user_id, $month, $year);
\Session()->flash('alert-save', true);
}
}
private static function add_KP_TP_Points($userSalesVolume, $month_points)
{
if ($userSalesVolume->status_points === 2) { // KP
$month_points->KP += $userSalesVolume->points;
} else {
// === 1 //TP + KP
$month_points->KP += $userSalesVolume->points;
$month_points->TP += $userSalesVolume->points;
}
return $month_points;
}
public static function reCalculateSalesPointsVolume($user_id, $month, $year)
{
$userSalesVolumes = UserSalesVolume::where('user_id', $user_id)->where('month', $month)->where('year', $year)->orderBy('id', 'ASC')->get();
$month_points = new stdClass;
$month_points->KP = 0;
$month_points->TP = 0;
$month_total_net = 0;
$month_shop_points = 0;
$month_shop_total_net = 0;
// TDOO Status === 3???
foreach ($userSalesVolumes as $userSalesVolume) {
switch ($userSalesVolume->status) {
case 1: // Bestellung Berater
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
$month_total_net += $userSalesVolume->total_net;
break;
case 2: // Shop
$month_shop_points += $userSalesVolume->points;
$month_shop_total_net += $userSalesVolume->total_net;
break;
case 4: // Gutschrift
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
if ($userSalesVolume->status_turnover === 2) {
$month_shop_total_net += $userSalesVolume->total_net;
// ggf hier zu den Shop Points zählen wäre aber immer KP + TP kann nicht keine trennung bei month_shop_points
} else {
$month_total_net += $userSalesVolume->total_net;
}
break;
case 5: // Registrierung
$month_points = self::add_KP_TP_Points($userSalesVolume, $month_points);
$month_total_net += $userSalesVolume->total_net;
break;
case 6: // Storno - negative Punkte und Beträge
// 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_total_net += $userSalesVolume->total_net; // bereits negativ
} else {
$month_total_net += $userSalesVolume->total_net; // bereits negativ
}
break;
}
$userSalesVolume->month_shop_points = $month_shop_points;
$userSalesVolume->month_shop_total_net = $month_shop_total_net;
$userSalesVolume->month_KP_points = $month_points->KP;
$userSalesVolume->month_TP_points = $month_points->TP;
$userSalesVolume->month_total_net = $month_total_net;
$userSalesVolume->save();
}
}
public static function User(ShoppingOrder $shoppingOrder)
{
/*
status
1 => 'hinzugefügt aus Bestellung',
2 => 'hinzugefügt aus Shop',
3 => 'hinzugefügt aus Shop / pending',
*/
$status = self::getStatusByOrderPaymentFor($shoppingOrder);
$user_id = $shoppingOrder->auth_user_id ? $shoppingOrder->auth_user_id : $shoppingOrder->member_id;
// akuteller tag / Monat.
$month = date('m');
$year = date('Y');
$date = date('d.m.Y');
if ($status === 3) { // shop bestellung User pending if is_like
$user_id = null;
}
$user_sales_volume = UserSalesVolume::create([
'user_id' => $user_id,
'shopping_order_id' => $shoppingOrder->id,
'month' => $month,
'year' => $year,
'date' => $date,
'points' => $shoppingOrder->points,
'total_net' => $shoppingOrder->subtotal,
'status_points' => 1, // KP + TP
'message' => '',
'status' => $status,
]);
if ($status !== 3) {
self::reCalculateSalesPointsVolume($user_sales_volume->user_id, $user_sales_volume->month, $user_sales_volume->year);
}
return $user_sales_volume;
}
public static function setToUserAndReCalculate(UserSalesVolume $user_sales_volume, $user_id)
{
// set month year date new, calculate it in the currently month!
// If the month has changed, it can no longer be added to the month before
$month = date('m');
$year = date('Y');
$date = date('d.m.Y');
$user_sales_volume->user_id = $user_id;
$user_sales_volume->month = $month;
$user_sales_volume->year = $year;
$user_sales_volume->date = $date;
$user_sales_volume->status = 2; // hinzugefügt aus Shop can only Pending
$user_sales_volume->save();
self::reCalculateSalesPointsVolume($user_id, $month, $year);
}
public static function getStatusByOrderPaymentFor(ShoppingOrder $shoppingOrder)
{
if ($shoppingOrder->payment_for) {
if ($shoppingOrder->payment_for === 6) { // Kunde-Shop
if ($shoppingOrder->shopping_user && $shoppingOrder->shopping_user->is_like) {
return 3; // shop Kunden, berater zuordnen <- need?
}
return 2;
}
return 1;
}
return 0;
}
public static function editSalesPointsVolume($data)
{
$user_sales_volume = UserSalesVolume::findOrFail($data['id']);
if (! $user_sales_volume->isCurrentMonthYear()) {
\Session()->flash('alert-error', 'Änderung muss im selben Monat sein');
return;
}
$old_points = $user_sales_volume->points;
$old_total_net = $user_sales_volume->total_net;
$user_sales_volume->total_net = Util::reFormatNumber($data['total_net']);
$user_sales_volume->points = Util::reFormatNumber($data['points']);
$user_sales_volume->message = 'geändert: '.date('d.m.Y');
$user_sales_volume->info = $data['info'];
$user_sales_volume->status_points = $data['status_points'];
$user_sales_volume->status_turnover = isset($data['status_turnover']) ? intval($data['status_turnover']) : null;
$syslog = $user_sales_volume->syslog;
$syslog[date('d.m.Y-h:i:s')] = 'edit points: #'.$old_points.' '.$user_sales_volume->points.' total: #'.$old_total_net.' '.$user_sales_volume->total_net;
$user_sales_volume->syslog = $syslog;
$user_sales_volume->save();
self::reCalculateSalesPointsVolume($user_sales_volume->user_id, $user_sales_volume->month, $user_sales_volume->year);
\Session()->flash('alert-success', 'Points geändert');
}
public static function addSalesPointsVolume($data)
{
if (! isset($data['user_id'])) {
\Session()->flash('alert-error', 'Kein Berater ausgewählt');
return;
}
$user = User::findOrFail($data['user_id']);
$month = date('m');
$year = date('Y');
$date = date('d.m.Y');
$total_net = isset($data['total_net']) ? Util::reFormatNumber($data['total_net']) : 0;
$points = isset($data['points']) ? Util::reFormatNumber($data['points']) : 0;
$syslog[date('d.m.Y-h:i:s')] = 'add points: #'.$points.' total: #'.$total_net;
$status = isset($data['status']) ? intval($data['status']) : 4;
$status_turnover = isset($data['status_turnover']) ? intval($data['status_turnover']) : null;
$user_sales_volume = UserSalesVolume::create([
'user_id' => $user->id,
'shopping_order_id' => $data['shopping_order_id'] ?? null,
'month' => $month,
'year' => $year,
'date' => $date,
'points' => $points,
'status_points' => $data['status_points'],
'status_turnover' => $status_turnover,
'total_net' => $total_net,
'message' => 'hinzugefügt: '.date('d.m.Y'),
'info' => $data['info'],
'syslog' => $syslog,
'status' => $status,
]);
self::reCalculateSalesPointsVolume($user_sales_volume->user_id, $user_sales_volume->month, $user_sales_volume->year);
\Session()->flash('alert-success', 'Points hinzugefügt');
}
/**
* 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|null null, wenn der Monat fixiert ist und nicht nachgebucht wird
*/
public static function cancelSalesPointsVolume(UserSalesVolume $original_sales_volume, $cancellation_invoice_id): ?UserSalesVolume
{
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 = [
$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)
$cancellation_sales_volume = UserSalesVolume::create([
'user_id' => $original_sales_volume->user_id,
'shopping_order_id' => $original_sales_volume->shopping_order_id,
'user_invoice_id' => $cancellation_invoice_id,
'month' => $month,
'year' => $year,
'date' => $date,
'points' => -$original_sales_volume->points, // Negativ!
'month_points' => 0, // Wird durch reCalculate gesetzt
'month_KP_points' => 0,
'month_TP_points' => 0,
'month_shop_points' => 0,
'total_net' => -$original_sales_volume->total_net, // Negativ!
'month_total_net' => 0,
'status' => 6, // Status 6 = cancelled/storniert
'status_points' => $original_sales_volume->status_points,
'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,
]);
// Neuberechnung für aktuellen Monat
self::reCalculateSalesPointsVolume($original_sales_volume->user_id, $month, $year);
// Incentive: Track storno
IncentiveTracker::trackStorno($original_sales_volume, $cancellation_sales_volume);
\Log::info('Punktekorrektur für Stornorechnung durchgeführt', [
'original_invoice_id' => $original_sales_volume->user_invoice_id,
'cancellation_invoice_id' => $cancellation_invoice_id,
'original_points' => $original_sales_volume->points,
'cancellation_points' => $cancellation_sales_volume->points,
'user_id' => $original_sales_volume->user_id,
'month' => $month,
'year' => $year,
]);
return $cancellation_sales_volume;
}
}