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>
452 lines
19 KiB
PHP
452 lines
19 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 Illuminate\Database\Eloquent\Builder;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Die Zeilen eines Beraters im Monat in Buchungsreihenfolge — SPERREND
|
|
* gelesen (SELECT ... FOR UPDATE). Ein sperrendes Lesen liefert immer den
|
|
* zuletzt committeten Stand, nicht den Transaktions-Snapshot: Laufen zwei
|
|
* Zahlungs-Webhooks desselben Beraters parallel (Abo-Ausfuehrungen), wartet
|
|
* der zweite auf den ersten und sieht dessen Zeile. Ohne die Sperre trug
|
|
* die spaetere Zeile einen Laufstand ohne die fruehere Buchung, und die
|
|
* Provision rechnete zu niedrig (Ticket C2). Ausserhalb einer Transaktion
|
|
* ist die Sperre wirkungslos, aber unschaedlich.
|
|
*
|
|
* @return Builder<UserSalesVolume>
|
|
*/
|
|
public static function runningTotalRows($user_id, $month, $year)
|
|
{
|
|
return UserSalesVolume::query()
|
|
->where('user_id', $user_id)
|
|
->where('month', $month)
|
|
->where('year', $year)
|
|
->orderBy('id', 'ASC')
|
|
->lockForUpdate();
|
|
}
|
|
|
|
public static function reCalculateSalesPointsVolume($user_id, $month, $year)
|
|
{
|
|
|
|
$userSalesVolumes = self::runningTotalRows($user_id, $month, $year)->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;
|
|
}
|
|
}
|