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>
141 lines
5 KiB
PHP
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();
|
|
}
|
|
}
|