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

282 lines
12 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Http\Controllers\ModalController;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\BusinessPlan\SalesVolumeRunningTotalService;
use App\Services\BusinessPlan\TreeCalcBotOptimized;
use App\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
/**
* Messlauf zu K11 („Team-Statistik und Team-Struktur zeigen andere Zahlen“):
* stellt fuer jeden VIP die beiden Baum-Definitionen der Statistik und die
* Punkte der Team-Struktur (TreeCalcBot) nebeneinander — je Linie und in Summe.
*
* Zusaetzlich prueft er die Laufstaende in `user_sales_volumes`: der TreeCalcBot
* liest je User die kumulativen Spalten `month_TP_points + month_shop_points`
* der letzten Zeile, die Statistik summiert die Einzelzeilen. Laufen zwei
* Abo-Ausfuehrungen fast zeitgleich, ueberholen sich die Laufstaende und die
* letzte Zeile ist zu niedrig — dann fehlen der Struktur (und damit der
* Provision!) Punkte, die in der Statistik richtig stehen. `--repair-running-totals`
* rechnet die Laufstaende dieser User mit der Anwendungsfunktion neu.
*
* Im laufenden Monat rechnet die Team-Struktur live aus diesen Spalten — nach der
* Reparatur stimmt sie sofort (Provisions-Kachel: 6-h-Cache). Fuer einen
* ABGESCHLOSSENEN Monat ist der Provisionslauf (`business:store-optimized`, inkl.
* Gutschriften) bereits gelaufen; ihn zu wiederholen ist keine Reparatur, sondern
* ein neuer Abrechnungslauf (mit `--clear` entstehen doppelte Gutschriften). Die
* Differenz ist dann als Nachzahlung fachlich zu entscheiden.
*/
class StatisticsCompareTrees extends Command
{
protected $signature = 'statistics:compare-trees
{month? : Monat (Standard: aktueller Monat)}
{year? : Jahr (Standard: aktuelles Jahr)}
{--user=* : Nur diese User-IDs (Standard: alle VIPs)}
{--lines : Zusaetzlich jede Linie einzeln ausgeben}
{--live-structure : Team-Struktur live rechnen statt den gespeicherten Nachtlauf zu lesen}
{--repair-running-totals : Laufstaende abweichender User neu berechnen (schreibt in user_sales_volumes)}';
protected $description = 'Vergleicht Team-Statistik (Provisionsbaum / Downline) mit der Team-Struktur je VIP';
public function handle(BackofficeDashboardService $dashboardService): int
{
$month = (int) ($this->argument('month') ?: now()->month);
$year = (int) ($this->argument('year') ?: now()->year);
if ($month < 1 || $month > 12 || $year < 2020) {
$this->error('Ungueltiger Monat/Jahr.');
return self::FAILURE;
}
$users = $this->vipUsers();
if ($users->isEmpty()) {
$this->warn('Keine VIP-User gefunden.');
return self::SUCCESS;
}
$storedStructure = TreeCalcBotOptimized::isFromStored($month, $year);
$this->info(sprintf(
'Vergleich %02d/%d fuer %d VIP(s) — Team-Struktur: %s',
$month,
$year,
$users->count(),
$this->option('live-structure')
? 'live gerechnet'
: ($storedStructure ? 'gespeicherter Nachtlauf vom '.$storedStructure->updated_at?->format('d.m.Y H:i') : 'kein gespeicherter Lauf, live gerechnet')
));
$this->newLine();
$summaryRows = [];
$lineRows = [];
$commissionUserIds = [];
foreach ($users as $user) {
$dashboardService->flushLineBucketsMemo((int) $user->id);
$downline = $dashboardService->liveOverview($user, $month, $year, BackofficeDashboardService::TREE_DOWNLINE);
$commission = $dashboardService->liveOverview($user, $month, $year, BackofficeDashboardService::TREE_COMMISSION);
$structure = $this->structurePoints($user, $month, $year);
$commissionUserIds = array_merge($commissionUserIds, $commission['totals']['user_ids'] ?? []);
$commissionTotal = (float) $commission['totals']['total_points'];
$summaryRows[] = [
$user->id,
$this->userLabel($user),
(int) $downline['totals']['consultants'],
(int) $commission['totals']['consultants'],
count($downline['totals']['user_ids'] ?? []) - count($commission['totals']['user_ids'] ?? []),
$this->fmt((float) $downline['totals']['total_points']),
$this->fmt($commissionTotal),
$structure === null ? 'n/a' : $this->fmt($structure['total']),
$structure === null ? 'n/a' : $this->fmt($commissionTotal - $structure['total']),
$this->fmt((float) ($commission['non_payline_points'] ?? 0)),
$this->fmt((float) ($commission['storno_floor_points'] ?? 0)),
];
if ($this->option('lines')) {
$maxLine = max(
array_keys($downline['lines']) ?: [0],
array_keys($commission['lines']) ?: [0],
$structure === null ? [0] : (array_keys($structure['lines']) ?: [0])
);
for ($line = 1; $line <= $maxLine; $line++) {
$commissionLine = (float) ($commission['lines'][$line]['total_points'] ?? 0);
$structureLine = $structure === null ? null : (float) ($structure['lines'][$line] ?? 0);
$lineRows[] = [
$user->id,
$line,
(int) ($downline['lines'][$line]['consultants'] ?? 0),
(int) ($commission['lines'][$line]['consultants'] ?? 0),
$this->fmt((float) ($downline['lines'][$line]['total_points'] ?? 0)),
$this->fmt($commissionLine),
$structureLine === null ? 'n/a' : $this->fmt($structureLine),
$structureLine === null ? 'n/a' : $this->fmt($commissionLine - $structureLine),
];
}
}
}
$this->table(
['ID', 'VIP', 'Berater DL', 'Berater PB', 'nicht im PB', 'Punkte DL', 'Punkte PB', 'Struktur', 'Diff PB-Struktur', 'nur KP (nicht enth.)', 'Storno-Saldo (nicht abgez.)'],
$summaryRows
);
$this->line('DL = gesamte Downline (fruehere Statistik-Sicht) · PB = Provisionsbaum (neue Standard-Sicht, wie Team-Struktur)');
if ($lineRows !== []) {
$this->newLine();
$this->table(['ID', 'Linie', 'Berater DL', 'Berater PB', 'Punkte DL', 'Punkte PB', 'Struktur', 'Diff'], $lineRows);
}
$this->newLine();
$this->reportRunningTotalDrift(array_values(array_unique(array_map('intval', $commissionUserIds))), $month, $year);
return self::SUCCESS;
}
/**
* @return Collection<int, User>
*/
private function vipUsers()
{
$query = User::query()
->with('account')
->whereNull('deleted_at')
->orderBy('id');
$userIds = array_filter(array_map('intval', (array) $this->option('user')));
if ($userIds !== []) {
return $query->whereIn('id', $userIds)->get();
}
return $query->where('admin', '>=', 1)->where('admin', '<', 4)->get();
}
/**
* Linien-Punkte der Team-Struktur, so wie das Berater-Detail (Modal „Linie /
* Punkte / Provision" auf der Struktur-Seite, {@see ModalController})
* sie zeigt: gespeicherter Nachtlauf (`user_businesses`), sofern vorhanden;
* sonst bzw. mit --live-structure live gerechnet.
*
* @return array{lines: array<int, float>, total: float}|null
*/
private function structurePoints(User $user, int $month, int $year): ?array
{
try {
$live = (bool) $this->option('live-structure');
$bot = new TreeCalcBotOptimized($month, $year, 'member', $live);
$bot->initBusinesslUserDetail($user, $live);
$lines = [];
$total = 0.0;
for ($line = 1; $line <= BackofficeDashboardService::COMMISSION_TREE_MAX_LINES; $line++) {
$points = (float) $bot->getKeybyLine($line, 'points');
if ($points === 0.0 && ! $this->structureHasLine($bot, $line)) {
continue;
}
$lines[$line] = $points;
$total += $points;
}
return ['lines' => $lines, 'total' => $total];
} catch (\Throwable $e) {
$this->warn("Team-Struktur fuer User {$user->id} nicht berechenbar: ".$e->getMessage());
return null;
}
}
private function structureHasLine(TreeCalcBotOptimized $bot, int $line): bool
{
$businessUser = $bot->__get('business_user');
$lines = $businessUser?->business_lines;
if ($lines === null) {
return false;
}
return isset($lines[$line]);
}
/**
* Laufstand-Drift der Provisionsbaum-Mitglieder ({@see SalesVolumeRunningTotalService}).
*
* @param int[] $userIds
*/
private function reportRunningTotalDrift(array $userIds, int $month, int $year): void
{
if ($userIds === []) {
return;
}
$service = app(SalesVolumeRunningTotalService::class);
$drift = $service->detectDrift($month, $year, $userIds);
if ($drift === []) {
$this->info('Laufstaende: keine Abweichung zwischen Zeilensumme und kumulierten Spalten.');
return;
}
$users = User::query()->whereIn('id', array_keys($drift))->get()->keyBy('id');
$this->warn(count($drift).' User mit Laufstand-Drift (Struktur/Provision rechnet mit dem zu niedrigen bzw. zu hohen Laufstand):');
$this->table(
['User', 'E-Mail', 'Zeilensumme (Statistik)', 'Laufstand (Struktur)', 'Diff'],
collect($drift)->map(fn (array $values, int $userId) => [
$userId,
$users->get($userId)?->email ?? '-',
$this->fmt($values['rows']),
$this->fmt($values['running']),
$this->fmt($values['running'] - $values['rows']),
])->values()->all()
);
$isClosedMonth = SalesVolumeRunningTotalService::isMonthClosed($month, $year);
if (! $this->option('repair-running-totals')) {
$this->line('Reparatur der Laufstaende: --repair-running-totals (taeglich 02:45 automatisch: sales:repair-running-totals)');
if ($isClosedMonth) {
$this->warn('Abgeschlossener Monat: der Provisionslauf hat mit diesem Laufstand abgerechnet. Die Reparatur aendert nur die Datenbasis, keine Gutschriften — business:store-optimized NICHT erneut ausfuehren (doppelte Gutschriften).');
}
return;
}
$service->repair($month, $year, array_keys($drift));
$this->info('Laufstaende von '.count($drift).' User(n) neu berechnet.');
if ($isClosedMonth) {
$this->warn('Abgeschlossener Monat: gespeicherte Struktur und Gutschriften bleiben unveraendert.');
} else {
$this->line('Laufender Monat: die Team-Struktur rechnet live und zeigt den korrigierten Stand sofort (php artisan cache:clear fuer die Provisions-Kachel).');
}
}
private function userLabel(User $user): string
{
$name = trim(($user->account?->first_name ?? '').' '.($user->account?->last_name ?? ''));
return $name !== '' ? $name : (string) $user->email;
}
private function fmt(float $value): string
{
return number_format($value, 2, ',', '.');
}
}