mivita/app/Console/Commands/BackofficeStoreStatisticsSnapshots.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

147 lines
4.7 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\BackofficeStatisticsSnapshot;
use App\Services\Backoffice\BackofficeDashboardService;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class BackofficeStoreStatisticsSnapshots extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backoffice:store-statistics-snapshots
{--user= : Nur einen bestimmten User berechnen (user_id)}
{--month= : Nur einen bestimmten Monat berechnen}
{--year= : Nur ein bestimmtes Jahr berechnen}
{--tree= : Nur eine Baum-Definition (commission|downline), Standard: beide}
{--force : Bereits vorhandene Snapshots überschreiben}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Speichert Backoffice-Statistik-Snapshots fuer abgeschlossene Monate';
/**
* Der Snapshot IST der abgeschlossene Monat — siehe
* {@see BackofficeDashboardService} zur fachlichen Begruendung. Er wird am
* ersten Tag des Folgemonats gebaut, wenn Abrechnung und Gutschriften stehen.
*
* Wichtig bei `--force`: Ein neu gebauter Snapshot friert die Struktur des
* AUSFUEHRUNGSTAGS ein, nicht die des damaligen Monatsendes — frueheres
* Sponsoring wird nirgends historisiert. Ein Rebuild vergangener Monate ist
* deshalb nur dann richtig, wenn man bewusst einen einheitlichen Stand
* herstellen will (z. B. bei einem Schemawechsel des Payloads). Fuer den
* laufenden Betrieb genuegt der naechtliche Lauf ohne `--force`.
*/
/**
* Execute the console command.
*/
public function handle(BackofficeDashboardService $dashboardService): int
{
$months = $this->monthsToStore();
$force = (bool) $this->option('force');
$trees = $this->option('tree')
? [BackofficeDashboardService::normalizeTree((string) $this->option('tree'))]
: BackofficeDashboardService::trees();
if ($months === []) {
$this->info('Keine abgeschlossenen Monate zum Speichern.');
return self::SUCCESS;
}
$userQuery = User::query()
->where('admin', '>=', 1)
->where('admin', '<', 4)
->whereNull('deleted_at');
if ($userId = $this->option('user')) {
$userQuery->where('id', $userId);
}
$users = $userQuery->get();
$this->info('Berechne Backoffice-Snapshots fuer '.$users->count().' User, '.count($months).' Monate und '.count($trees).' Baum-Definition(en)...');
$bar = $this->output->createProgressBar($users->count());
$bar->start();
$stored = 0;
$skipped = 0;
foreach ($users as $user) {
foreach ($months as [$year, $month]) {
foreach ($trees as $tree) {
$exists = BackofficeStatisticsSnapshot::query()
->where('user_id', $user->id)
->where('year', $year)
->where('month', $month)
->where('tree', $tree)
->exists();
if ($exists && ! $force) {
$skipped++;
continue;
}
$dashboardService->storeSnapshot($user, $month, $year, $tree);
$stored++;
}
}
$bar->advance();
gc_collect_cycles();
}
$bar->finish();
$this->newLine();
$this->info("Fertig. Gespeichert: {$stored}, uebersprungen: {$skipped}");
return self::SUCCESS;
}
/**
* @return array<array{int, int}>
*/
private function monthsToStore(): array
{
$monthOption = $this->option('month');
$yearOption = $this->option('year');
if ($monthOption && $yearOption) {
$month = max(1, min(12, (int) $monthOption));
$year = (int) $yearOption;
if (! $this->isClosedMonth($month, $year)) {
return [];
}
return [[$year, $month]];
}
$months = [];
$cursor = Carbon::create(2026, 1, 1)->startOfMonth();
$lastClosedMonth = now()->startOfMonth()->subMonth();
while ($cursor->lte($lastClosedMonth)) {
$months[] = [(int) $cursor->year, (int) $cursor->month];
$cursor->addMonth();
}
return $months;
}
private function isClosedMonth(int $month, int $year): bool
{
return Carbon::create($year, $month, 1)->endOfMonth()->lt(now()->startOfMonth());
}
}