mivita/app/Services/BusinessPlan/SalesVolumeRunningTotalService.php
Kevin 96545364f1 Team-Statistik = Team-Struktur (Provisionsbaum, Umschalter) und Laufstand-Fix in user_sales_volumes
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>
2026-09-14 15:31:32 +00:00

141 lines
5 KiB
PHP

<?php
namespace App\Services\BusinessPlan;
use App\Models\UserBusinessStructure;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\SalesVolumePoints;
use Carbon\Carbon;
/**
* Laufstand-Drift in `user_sales_volumes` (Ticket C2, Entwicklungsplan
* September 2026).
*
* Jede Buchung traegt neben ihren eigenen Punkten (`points`) den kumulierten
* Monatsstand des Beraters (`month_TP_points`, `month_shop_points`), den
* {@see SalesPointsVolume::reCalculateSalesPointsVolume()} nach jedem Insert
* ueber alle Zeilen neu schreibt. Der TreeCalcBot liest nur den Laufstand der
* LETZTEN Zeile. Laufen zwei Zahlungs-Webhooks desselben Beraters parallel
* (Abo-Ausfuehrungen um 04:00), sah die spaetere Transaktion unter
* REPEATABLE READ die Zeile der frueheren nicht — ihre letzte Zeile traegt
* einen zu niedrigen Laufstand, und die Provision rechnet zu niedrig, obwohl
* alle Einzelbuchungen vollstaendig vorhanden sind.
*
* Dieser Service vergleicht je User die Zeilensumme der Provisionspunkte
* ({@see SalesVolumePoints::commissionPointsByUser()}, Basis der Statistik) mit
* dem Laufstand der letzten Zeile (Basis von Struktur und Provision) und rechnet
* abweichende User mit der Anwendungsfunktion neu. Die eigentliche Ursache
* schliesst das Locking in der Neuberechnung; dieser Lauf ist das
* Sicherheitsnetz vor dem Provisionslauf.
*/
class SalesVolumeRunningTotalService
{
/**
* User, deren letzte Zeile einen anderen Laufstand traegt als die
* Zeilensumme ergibt.
*
* @param int[]|null $userIds Einschraenkung auf diese User, sonst alle mit Buchungen im Monat
* @return array<int, array{rows: float, running: float}> user_id => Zeilensumme / Laufstand
*/
public function detectDrift(int $month, int $year, ?array $userIds = null): array
{
$userIds ??= UserSalesVolume::query()
->where('month', $month)
->where('year', $year)
->whereNotNull('user_id')
->distinct()
->pluck('user_id')
->map(fn ($id) => (int) $id)
->all();
if ($userIds === []) {
return [];
}
$rowSums = SalesVolumePoints::commissionPointsByUser($userIds, $month, $year);
$lastRows = UserSalesVolume::query()
->whereIn('user_id', $userIds)
->where('month', $month)
->where('year', $year)
->whereIn('id', function ($sub) use ($month, $year) {
$sub->selectRaw('MAX(id)')
->from('user_sales_volumes')
->where('month', $month)
->where('year', $year)
->groupBy('user_id');
})
->get(['user_id', 'month_TP_points', 'month_shop_points'])
->keyBy('user_id');
$drift = [];
foreach ($rowSums as $userId => $rowSum) {
$last = $lastRows->get($userId);
$runningTotal = $last ? (float) $last->month_TP_points + (float) $last->month_shop_points : 0.0;
if (abs($runningTotal - (float) $rowSum) > 0.005) {
$drift[(int) $userId] = ['rows' => (float) $rowSum, 'running' => $runningTotal];
}
}
ksort($drift);
return $drift;
}
/**
* Rechnet die Laufstaende der abweichenden User neu.
*
* @param int[]|null $userIds
* @return array<int, array{rows: float, running: float}> die reparierten User (Stand vor der Reparatur)
*/
public function repair(int $month, int $year, ?array $userIds = null): array
{
$drift = $this->detectDrift($month, $year, $userIds);
foreach (array_keys($drift) as $userId) {
SalesPointsVolume::reCalculateSalesPointsVolume($userId, $month, $year);
}
return $drift;
}
/**
* Monate, die der naechtliche Sicherheitsnetz-Lauf pruefen darf: der laufende
* Monat immer, der Vormonat nur, solange sein Provisionslauf noch nicht
* gelaufen ist (Monatserster vor 03:00 bzw. spaeterer Ausfuehrungstag). Ein
* abgeschlossener Monat wird nicht angefasst — seine Laufstaende sind so
* abgerechnet worden, und eine Korrektur waere eine Nachberechnung.
*
* @return array<int, array{month: int, year: int}>
*/
public function monthsToGuard(?Carbon $now = null): array
{
$now ??= now();
$months = [];
$previous = $now->copy()->subMonthNoOverflow();
if (! self::isMonthClosed((int) $previous->month, (int) $previous->year)) {
$months[] = ['month' => (int) $previous->month, 'year' => (int) $previous->year];
}
$months[] = ['month' => (int) $now->month, 'year' => (int) $now->year];
return $months;
}
/**
* Abgeschlossen ist ein Monat, sobald der Provisionslauf seine Struktur
* gespeichert hat.
*/
public static function isMonthClosed(int $month, int $year): bool
{
return UserBusinessStructure::query()
->where('month', $month)
->where('year', $year)
->where('completed', true)
->exists();
}
}