mivita/app/Console/Commands/IncentiveCalculate.php
Kevin 867dc21b57 Statistiken: Kundenfeedback 2026-08-19 komplett (N1-N8) + Review-Fixes + VP-/Incentive-Repair gehärtet
Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen):
- N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch
- N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell)
- N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt'
- N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände)
- N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung)
- N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter)
- N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix
- N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav)

Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types

Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:44:44 +00:00

211 lines
8.9 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* Batch-Neuberechnung fuer Incentives.
*
* Abo-Wertung (nicht hier codiert, sondern in {@see IncentiveParticipant::rebuildFromSourceTables}
* und {@see IncentivePointsLogRepairService::syncMissingTrackingAbos}):
*
* - Eigenabo (me): zaehlt auch wenn es vor dem Qualifikationszeitraum abgeschlossen wurde;
* Einmalpunkte wirken dann ab Qualifikationsbeginn (aktiviert_at/Log-Monat auf Start des Zeitraums).
* - Kundenabo (ot): nur wenn im Qualifikationszeitraum neu abgeschlossen (created_at im Zeitraum).
*
* Zu Beginn werden fuer alle Berater (User mit m_level) fehlende Teilnehmerzeilen ohne
* accepted_terms angelegt ({@see IncentiveParticipant::ensureConsultantsForIncentive}), damit
* Punkte ohne Checkbox mitlaufen; die Rangliste blendet Namen erst nach Zustimmung ein.
*/
class IncentiveCalculate extends Command
{
protected $signature = 'incentive:calculate
{incentive_id? : ID des Incentives (leer = alle aktiven)}
{--force : Tracking-Tabellen + Log loeschen und komplett neu aufbauen}
{--skip-repair : Kein Nachziehen von Trackings/FKs/SV-Logs (nur Summen aus bestehendem Log)}
{--verbose-details : Zeigt Details pro Teilnehmer}';
protected $description = 'Incentive-Punkte: fehlende Partner-/Abo-Trackings, FK-Reparatur, fehlende SV-Logs, Summen/Ranking; --force = kompletter Neuaufbau aus Quelldaten';
public function handle(IncentivePointsLogRepairService $repairService): int
{
if ($id = $this->argument('incentive_id')) {
$incentive = Incentive::find($id);
if (! $incentive) {
$this->error("Incentive #{$id} nicht gefunden.");
return self::FAILURE;
}
return $this->processIncentive($incentive, $repairService);
}
$incentives = Incentive::active()->get();
if ($incentives->isEmpty()) {
$this->info('Keine aktiven Incentives gefunden.');
return self::SUCCESS;
}
$exitCode = self::SUCCESS;
foreach ($incentives as $incentive) {
if ($this->processIncentive($incentive, $repairService) !== self::SUCCESS) {
$exitCode = self::FAILURE;
}
}
return $exitCode;
}
private function processIncentive(Incentive $incentive, IncentivePointsLogRepairService $repairService): int
{
$force = $this->option('force');
$skipRepair = $this->option('skip-repair');
$verbose = $this->option('verbose-details');
$this->info("=== {$incentive->name} (ID: {$incentive->id}) ===");
$this->info(" Zeitraum: {$incentive->qualification_start->format('d.m.Y')} - {$incentive->qualification_end->format('d.m.Y')}");
if ($force) {
$this->info(' Modus: FORCE (Tracking + Log aus Quelldaten neu aufbauen)');
} elseif ($skipRepair) {
$this->info(' Modus: Nur Neuberechnung (Summen/Ranking aus bestehendem Log)');
} else {
$this->info(' Modus: Tracking nachziehen + FK-Reparatur + SV-Logs + Neuberechnung');
}
$stubAdded = IncentiveParticipant::ensureConsultantsForIncentive($incentive);
if ($stubAdded > 0) {
$this->info(" Berater-Teilnehmer neu angelegt (ohne Zustimmung): {$stubAdded}");
}
$participants = $incentive->participants()->with('user', 'user.account')->get();
$this->info(" Teilnehmer: {$participants->count()}");
$this->newLine();
$stats = [
'processed' => 0,
'errors' => 0,
'with_points' => 0,
'with_partners' => 0,
'with_abos' => 0,
'tracking_partner_added' => 0,
'tracking_partner_removed' => 0,
'tracking_abo_added' => 0,
'repair_partner_fk' => 0,
'repair_abo_fk' => 0,
'repair_onetime_partner_fk' => 0,
'repair_onetime_abo_fk' => 0,
'sv_logs_added' => 0,
];
$errors = [];
$bar = $this->output->createProgressBar($participants->count());
$bar->start();
foreach ($participants as $participant) {
try {
if (! $participant->user) {
$bar->advance();
continue;
}
if ($force) {
$participant->rebuildFromSourceTables()->save();
} else {
if (! $skipRepair) {
// FK-Reparatur MUSS vor der Stale-Bereinigung laufen: Log-Zeilen mit
// incentive_new_partner_id = NULL wuerden das Loeschen sonst als Waisen
// beim alten Sponsor ueberleben (Doppelzaehlung nach Umhaengen).
$r = $repairService->repairForeignKeys($participant);
$stats['repair_partner_fk'] += $r['partner_fk'];
$stats['repair_abo_fk'] += $r['abo_fk'];
$stats['repair_onetime_partner_fk'] += $r['onetime_partner_fk'];
$stats['repair_onetime_abo_fk'] += $r['onetime_abo_fk'];
$stats['tracking_partner_removed'] += $repairService->removeStaleSponsorTrackings($participant);
$stats['tracking_partner_added'] += $repairService->syncMissingTrackingPartners($participant);
$stats['tracking_abo_added'] += $repairService->syncMissingTrackingAbos($participant);
$stats['sv_logs_added'] += $repairService->syncMissingSalesVolumeLogs($participant);
}
$participant->recalculateFromTrackingTables()->save();
}
$stats['processed']++;
if ($participant->total_points > 0) {
$stats['with_points']++;
}
if ($participant->qualified_partners > 0) {
$stats['with_partners']++;
}
if ($participant->qualified_abos > 0) {
$stats['with_abos']++;
}
if ($verbose && $participant->total_points > 0) {
$name = $participant->user->account
? $participant->user->account->first_name.' '.$participant->user->account->last_name
: ($participant->user->email ?? 'User #'.$participant->user_id);
$bar->clear();
$this->line(" {$name}: {$participant->total_points} Pkt, {$participant->qualified_partners} Partner, {$participant->qualified_abos} Abos");
$bar->display();
}
} catch (\Throwable $e) {
$stats['errors']++;
$errors[] = "Participant #{$participant->id} (User #{$participant->user_id}): {$e->getMessage()}";
Log::error('IncentiveCalculation error for participant '.$participant->id.': '.$e->getMessage());
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
IncentiveTracker::updateRanking($incentive);
$ranked = IncentiveParticipant::where('incentive_id', $incentive->id)
->whereNotNull('rank')
->count();
$tableRows = [
['Verarbeitet', (string) $stats['processed']],
['Fehler', (string) $stats['errors']],
['Mit Punkten', (string) $stats['with_points']],
['Mit Partnern', (string) $stats['with_partners']],
['Mit Abos', (string) $stats['with_abos']],
['Im Ranking', (string) $ranked],
];
if (! $force) {
$tableRows[] = ['Neupartner-Trackings nachgezogen', (string) $stats['tracking_partner_added']];
$tableRows[] = ['Neupartner-Trackings entfernt (Sponsorwechsel)', (string) $stats['tracking_partner_removed']];
$tableRows[] = ['Neuabo-Trackings nachgezogen', (string) $stats['tracking_abo_added']];
$tableRows[] = ['FK Partner (akkum.) repariert', (string) $stats['repair_partner_fk']];
$tableRows[] = ['FK Abo (akkum.) repariert', (string) $stats['repair_abo_fk']];
$tableRows[] = ['FK Partner (Einmal) repariert', (string) $stats['repair_onetime_partner_fk']];
$tableRows[] = ['FK Abo (Einmal) repariert', (string) $stats['repair_onetime_abo_fk']];
$tableRows[] = ['Neue SV-Log-Eintraege', (string) $stats['sv_logs_added']];
}
$this->table(['Metrik', 'Wert'], $tableRows);
if (! empty($errors)) {
$this->error('Fehler:');
foreach ($errors as $err) {
$this->line(" - {$err}");
}
return self::FAILURE;
}
$this->info('Fertig.');
return self::SUCCESS;
}
}