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>
This commit is contained in:
Kevin 2026-09-14 15:31:32 +00:00
parent 685f7a90b9
commit 96545364f1
33 changed files with 2321 additions and 139 deletions

View file

@ -0,0 +1,110 @@
<?php
namespace App\Console\Commands;
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
use App\User;
use Illuminate\Console\Command;
/**
* Sicherheitsnetz gegen Laufstand-Drift (Ticket C2): laeuft taeglich um 02:45,
* also vor dem Provisionslauf `business:store-optimized` um 03:00, und rechnet
* die Laufstaende aller User neu, deren letzte Zeile in `user_sales_volumes`
* nicht zur Zeilensumme passt. Ohne Argumente werden der laufende Monat und —
* solange er noch nicht abgerechnet ist — der Vormonat geprueft.
*
* Hintergrund: {@see SalesVolumeRunningTotalService}.
*/
class SalesRepairRunningTotals extends Command
{
protected $signature = 'sales:repair-running-totals
{month? : Monat (Standard: laufender Monat und, falls offen, Vormonat)}
{year? : Jahr}
{--dry-run : Nur anzeigen, nichts schreiben}
{--force : Auch einen bereits abgerechneten Monat neu rechnen (Nachberechnung!)}';
protected $description = 'Laufstaende in user_sales_volumes gegen die Zeilensumme pruefen und abweichende User neu berechnen';
public function handle(SalesVolumeRunningTotalService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$months = $this->months($service);
if ($months === null) {
return self::FAILURE;
}
foreach ($months as ['month' => $month, 'year' => $year]) {
$drift = $dryRun
? $service->detectDrift($month, $year)
: $service->repair($month, $year);
$label = sprintf('%02d/%d', $month, $year);
if ($drift === []) {
$this->info("Laufstaende {$label}: keine Abweichung.");
continue;
}
$users = User::query()->whereIn('id', array_keys($drift))->get()->keyBy('id');
$this->warn(sprintf(
'Laufstaende %s: %d User mit Drift%s',
$label,
count($drift),
$dryRun ? ' (DRY-RUN, nichts geschrieben)' : ', neu berechnet'
));
$this->table(
['User', 'E-Mail', 'Zeilensumme', 'Laufstand vorher', 'Diff'],
collect($drift)->map(fn (array $values, int $userId) => [
$userId,
$users->get($userId)?->email ?? '-',
number_format($values['rows'], 2, ',', '.'),
number_format($values['running'], 2, ',', '.'),
number_format($values['running'] - $values['rows'], 2, ',', '.'),
])->values()->all()
);
\Log::channel('cron')->warning('sales:repair-running-totals: Laufstand-Drift', [
'month' => $month,
'year' => $year,
'dry_run' => $dryRun,
'users' => $drift,
]);
}
return self::SUCCESS;
}
/**
* @return array<int, array{month: int, year: int}>|null
*/
private function months(SalesVolumeRunningTotalService $service): ?array
{
if (! $this->argument('month')) {
return $service->monthsToGuard();
}
$month = (int) $this->argument('month');
$year = (int) ($this->argument('year') ?: now()->year);
if ($month < 1 || $month > 12 || $year < 2020) {
$this->error('Ungueltiger Monat/Jahr.');
return null;
}
if (SalesVolumeRunningTotalService::isMonthClosed($month, $year) && ! $this->option('dry-run') && ! $this->option('force')) {
$this->error(sprintf(
'Monat %02d/%d ist abgerechnet — die Laufstaende sind so in die Provision eingegangen. Neu rechnen nur mit --force (aendert keine Gutschriften, nur die Datenbasis).',
$month,
$year
));
return null;
}
return [['month' => $month, 'year' => $year]];
}
}