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 */ 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, 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, ',', '.'); } }