diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index badbaac..a912347 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,78 +1,45 @@ { - "name": "Mivita Care (Dev Container)", - // 1. DIES IST DER WICHTIGSTE TEIL: - // Wir verwenden Docker Compose für alle Services - "dockerComposeFile": [ - "../docker-compose.yml" - ], - "service": "laravel.test", - // 3. WIR DEFINIEREN DEN ARBEITSBEREICH: - // Das ist der Pfad, in dem Ihr Code *innerhalb* des Containers liegt. - "workspaceFolder": "/var/www/html", - // 4. WIR LEGEN DEN BENUTZER FEST: - // Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden. - "remoteUser": "sail", - // 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES): - // Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden - "features": {}, - // 6. BEFEHLE NACH DEM ERSTELLEN: - // Installiert nur die Tools die ohne Root-Rechte funktionieren - //"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader", - // 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen): - "customizations": { - "vscode": { - "extensions": [ - "bmewburn.vscode-intelephense-client", - "onecentlin.laravel-blade", - "shufo.vscode-blade-formatter", - "bradlc.vscode-tailwindcss" - ] - } - }, - // 8. ZU STARTENDE DIENSTE: - // Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen. - "runServices": [ - "laravel.test", - "mysql", - "redis", - "mailpit" - ], - // 9. ZUSÄTZLICHE KONFIGURATION: - // Umgebungsvariablen für den DevContainer - "containerEnv": { - "WWWUSER": "501", - "WWWGROUP": "20", - "LARAVEL_SAIL": "1" - }, - // 10. MOUNT-KONFIGURATION: - // Stellt sicher, dass der Code korrekt gemountet wird - "mounts": [ - "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached" - ], - // 11. FORWARD PORTS: - // Ports die automatisch weitergeleitet werden sollen - "forwardPorts": [ - 5173, - 33061, - 6380, - 8025 - ], - "portsAttributes": { - "5173": { - "label": "Vite Dev Server", - "onAutoForward": "notify" - }, - "33061": { - "label": "MySQL", - "onAutoForward": "silent" - }, - "6380": { - "label": "Redis", - "onAutoForward": "silent" - }, - "8025": { - "label": "Mailpit Dashboard", - "onAutoForward": "notify" - } - } + "name": "Mivita Care (Dev Container)", + "dockerComposeFile": [ + "../docker-compose.yml" + ], + "service": "laravel.test", + "workspaceFolder": "/var/www/html", + "remoteUser": "sail", + "features": {}, + "customizations": { + "vscode": { + "extensions": [ + "bmewburn.vscode-intelephense-client", + "onecentlin.laravel-blade", + "shufo.vscode-blade-formatter", + "bradlc.vscode-tailwindcss", + "Anthropic.claude-code", + "onecentlin.laravel-extension-pack" + ] + } + }, + // WICHTIG: Hier stehen jetzt nur noch die Dienste, die es im Projekt wirklich gibt! + "runServices": [ + "laravel.test", + "horizon" + ], + "containerEnv": { + "WWWUSER": "501", + "WWWGROUP": "20", + "LARAVEL_SAIL": "1" + }, + "mounts": [ + "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached" + ], + // WICHTIG: Nur noch der Vite-Port muss weitergeleitet werden, den Rest macht das Mutterschiff. + "forwardPorts": [ + 5173 + ], + "portsAttributes": { + "5173": { + "label": "Vite Dev Server", + "onAutoForward": "notify" + } + } } \ No newline at end of file diff --git a/.env b/.env index dfbf460..4e1ce7d 100644 --- a/.env +++ b/.env @@ -42,13 +42,13 @@ APP_PHP_VERSION=8.2 LOG_CHANNEL=stack + DB_CONNECTION=mysql -DB_HOST=mysql +DB_HOST=global-mysql DB_PORT=3306 DB_DATABASE=mivita -DB_USERNAME=sail +DB_USERNAME=root DB_PASSWORD=password -FORWARD_DB_PORT=33061 MYSQL_EXTRA_OPTIONS= #DB_HOST=192.168.1.8 diff --git a/app/Console/Commands/AboStoreChartSnapshots.php b/app/Console/Commands/AboStoreChartSnapshots.php new file mode 100644 index 0000000..bc52da3 --- /dev/null +++ b/app/Console/Commands/AboStoreChartSnapshots.php @@ -0,0 +1,169 @@ +option('force'); + + // Monate die eingefroren werden sollen: von START_YEAR/01 bis letzten Monat + $months = $this->getPastMonths($now); + if (empty($months)) { + $this->info('Keine vergangenen Monate zum Speichern.'); + + return self::SUCCESS; + } + + // User ermitteln + $userQuery = User::whereNotNull('m_level') + ->whereNotNull('payment_account') + ->where('admin', '<', 4) + ->whereNull('deleted_at'); + + if ($userId = $this->option('user')) { + $userQuery->where('id', $userId); + } + + $users = $userQuery->select('id')->get(); + $total = $users->count(); + $this->info("Berechne Snapshots für {$total} User, ".count($months).' Monate, '.count(self::SCOPES).' Scopes...'); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $inserted = 0; + $skipped = 0; + + foreach ($users as $user) { + // Bereits vorhandene Snapshots für diesen User laden (zum Überspringen) + $existing = AboChartSnapshot::where('user_id', $user->id) + ->get() + ->keyBy(fn ($s) => "{$s->scope}_{$s->year}_{$s->month}"); + + $teamUserIds = AboHelper::getTeamUserIds($user->id); + $rows = []; + + foreach ($months as [$year, $month]) { + $startOfMonth = Carbon::create($year, $month, 1)->startOfMonth(); + $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); + + foreach (self::SCOPES as $scope) { + $key = "{$scope}_{$year}_{$month}"; + + if (! $force && $existing->has($key)) { + $skipped++; + + continue; + } + + $count = $this->calculateCount($scope, $user->id, $teamUserIds, $startOfMonth, $endOfMonth); + $rows[] = [ + 'user_id' => $user->id, + 'scope' => $scope, + 'year' => $year, + 'month' => $month, + 'count' => $count, + 'calculated_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + $inserted++; + } + } + + if (! empty($rows)) { + if ($force) { + foreach ($rows as $row) { + AboChartSnapshot::updateOrInsert( + ['user_id' => $row['user_id'], 'scope' => $row['scope'], 'year' => $row['year'], 'month' => $row['month']], + $row + ); + } + } else { + AboChartSnapshot::insertOrIgnore($rows); + } + } + + $bar->advance(); + gc_collect_cycles(); + } + + $bar->finish(); + $this->newLine(); + $this->info("Fertig. Gespeichert: {$inserted}, Übersprungen (bereits vorhanden): {$skipped}"); + + return self::SUCCESS; + } + + /** + * Berechnet die Abo-Anzahl für einen Scope/User/Monat anhand der tatsächlichen Daten zum Zeitpunkt der Berechnung. + * + * @param int[] $teamUserIds + */ + private function calculateCount(string $scope, int $userId, array $teamUserIds, Carbon $startOfMonth, Carbon $endOfMonth): int + { + $terminalStatuses = [4, 5]; + + $query = match ($scope) { + 'ot' => UserAbo::where('member_id', $userId) + ->where('is_for', 'ot') + ->where('status', '>', 1), + 'team_abos' => UserAbo::whereIn('user_id', $teamUserIds) + ->where('is_for', 'me') + ->where('status', '>', 1), + 'team_cust_abos' => UserAbo::whereIn('member_id', $teamUserIds) + ->where('is_for', 'ot') + ->where('status', '>', 1), + }; + + return $query + ->whereDate('start_date', '<=', $endOfMonth) + ->where(function ($q) use ($startOfMonth, $terminalStatuses) { + $q->whereDate('cancel_date', '>=', $startOfMonth) + ->orWhere(function ($q2) use ($terminalStatuses) { + $q2->whereNull('cancel_date') + ->whereNotIn('status', $terminalStatuses); + }); + }) + ->count(); + } + + /** + * Alle abgeschlossenen Monate von START_YEAR/01 bis letzten Monat. + * + * @return array + */ + private function getPastMonths(Carbon $now): array + { + $months = []; + $cursor = Carbon::create(self::START_YEAR, 1, 1); + $lastMonth = $now->copy()->subMonth()->endOfMonth(); + + while ($cursor->lte($lastMonth)) { + $months[] = [(int) $cursor->year, (int) $cursor->month]; + $cursor->addMonth(); + } + + return $months; + } +} diff --git a/app/Console/Commands/BusinessStoreOptimized.php b/app/Console/Commands/BusinessStoreOptimized.php index fc37c26..6ab8aca 100644 --- a/app/Console/Commands/BusinessStoreOptimized.php +++ b/app/Console/Commands/BusinessStoreOptimized.php @@ -8,6 +8,9 @@ use App\Cron\UserPaymentCredits; use App\Models\Setting; use App\Models\UserBusiness; use App\Models\UserBusinessStructure; +use App\Models\UserSalesVolume; +use App\Services\BusinessPlan\SalesPointsVolume; +use App\User; use Illuminate\Console\Command; class BusinessStoreOptimized extends Command @@ -163,6 +166,10 @@ class BusinessStoreOptimized extends Command $this->userLevelUpdate(); }); + $this->executeWithErrorHandling('Monthly Qual-KP Bonus Points', function () { + \Log::channel('cron')->info('RUN Command BusinessStoreOptimized Monthly Qual-KP Bonus Points'); + $this->assignMonthlyQualKpBonusPoints(); + }); // Auskommentierte Prozesse bleiben inaktiv // $this->userCreatePaymentCreditsPDF(); // $this->storeBusinessStructureUsersDetailPeriod(1, 6); @@ -377,6 +384,58 @@ class BusinessStoreOptimized extends Command } } + /** + * Schreibt ausgewählten Usern einmal pro Monat ihre Level-qual_kp als KP-Bonus gut. + * Idempotent: Ein bereits vorhandener Eintrag für diesen Monat/Jahr wird übersprungen. + * + * @var array User-IDs die den monatlichen Qual-KP-Bonus erhalten sollen + */ + private function assignMonthlyQualKpBonusPoints(): void + { + $bonusUserIds = [486]; + + $month = date('m'); + $year = date('Y'); + + $users = User::query() + ->whereIn('id', $bonusUserIds) + ->whereNotNull('m_level') + ->with('user_level') + ->get() + ->filter(fn (User $user) => $user->user_level && $user->user_level->qual_kp > 0); + + $assigned = 0; + $skipped = 0; + + foreach ($users as $user) { + $alreadyExists = UserSalesVolume::where('user_id', $user->id) + ->where('month', $month) + ->where('year', $year) + ->where('info', 'qual_kp_bonus') + ->exists(); + + if ($alreadyExists) { + $skipped++; + + continue; + } + + SalesPointsVolume::addSalesPointsVolume([ + 'user_id' => $user->id, + 'points' => $user->user_level->qual_kp, + 'status_points' => 2, + 'total_net' => 0, + 'status_turnover' => 1, + 'info' => 'qual_kp_bonus', + ]); + + $assigned++; + } + + $this->info("Qual-KP Bonus: {$assigned} zugewiesen, {$skipped} übersprungen (bereits vorhanden)"); + \Log::channel('cron')->info("Qual-KP Bonus Points: assigned={$assigned}, skipped={$skipped}"); + } + private function logExecutionTime($message) { $diff = microtime(true) - $this->timeStart; diff --git a/app/Console/Commands/IncentiveCalculate.php b/app/Console/Commands/IncentiveCalculate.php new file mode 100644 index 0000000..4c8cd0d --- /dev/null +++ b/app/Console/Commands/IncentiveCalculate.php @@ -0,0 +1,205 @@ +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_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) { + $stats['tracking_partner_added'] += $repairService->syncMissingTrackingPartners($participant); + $stats['tracking_abo_added'] += $repairService->syncMissingTrackingAbos($participant); + $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['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[] = ['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; + } +} diff --git a/app/Console/Commands/IncentiveDebugTrackPartner.php b/app/Console/Commands/IncentiveDebugTrackPartner.php new file mode 100644 index 0000000..3f2f74a --- /dev/null +++ b/app/Console/Commands/IncentiveDebugTrackPartner.php @@ -0,0 +1,196 @@ +argument('order_id'); + $shopping_order = ShoppingOrder::find($order_id); + + if (! $shopping_order) { + $this->error("Shopping Order #{$order_id} nicht gefunden."); + + return self::FAILURE; + } + + $this->info("=== Debug trackNewPartner fuer Order #{$order_id} ==="); + $this->newLine(); + + // 1. Bestelldaten + $this->info('[1] Bestelldaten:'); + $this->table(['Feld', 'Wert'], [ + ['id', $shopping_order->id], + ['auth_user_id', $shopping_order->auth_user_id ?? 'NULL'], + ['member_id', $shopping_order->member_id ?? 'NULL'], + ['payment_for', $shopping_order->payment_for], + ['paid', $shopping_order->paid], + ['txaction', $shopping_order->txaction], + ['mode', $shopping_order->mode], + ['created_at', $shopping_order->created_at], + ]); + + // 2. Prüfe payment_for == 1 (Voraussetzung im Payment.php) + if ($shopping_order->payment_for != 1) { + $this->warn("[!] payment_for = {$shopping_order->payment_for} (nicht 1/registration). trackNewPartner wird nur bei payment_for=1 aufgerufen!"); + } + + // 3. Neuer User + $this->newLine(); + $this->info('[2] Neuer User (auth_user_id):'); + + if (! $shopping_order->auth_user_id) { + $this->error(' auth_user_id ist NULL -> ABBRUCH (return)'); + + return self::SUCCESS; + } + + $new_user = User::find($shopping_order->auth_user_id); + if (! $new_user) { + $this->error(" User #{$shopping_order->auth_user_id} nicht gefunden -> ABBRUCH (return)"); + + return self::SUCCESS; + } + + $this->table(['Feld', 'Wert'], [ + ['id', $new_user->id], + ['email', $new_user->email], + ['m_sponsor', $new_user->m_sponsor ?? 'NULL'], + ['active', $new_user->active], + ['created_at', $new_user->created_at], + ]); + + if (! $new_user->m_sponsor) { + $this->error(' m_sponsor ist NULL -> ABBRUCH (return)'); + + return self::SUCCESS; + } + + $sponsor_id = $new_user->m_sponsor; + $this->info(" Sponsor ID: {$sponsor_id}"); + + // 4. Registration Date + $registration_date = $shopping_order->created_at; + $this->newLine(); + $this->info("[3] Registration Date: {$registration_date}"); + + // 5. Aktive Incentives + $this->newLine(); + $this->info('[4] Aktive Incentives pruefen:'); + + $all_incentives = Incentive::query()->get(); + $this->info(" Incentives gesamt: {$all_incentives->count()}"); + + foreach ($all_incentives as $incentive) { + $is_active = $incentive->status == 1; + $in_range = $registration_date >= $incentive->qualification_start + && $registration_date <= $incentive->qualification_end; + + $status_icon = $is_active ? 'AKTIV' : 'INAKTIV'; + $range_icon = $in_range ? 'IM ZEITRAUM' : 'AUSSERHALB'; + + $this->table(['Feld', 'Wert'], [ + ['Incentive', "#{$incentive->id}: {$incentive->name}"], + ['Status', "{$incentive->status} ({$status_icon})"], + ['qualification_start', $incentive->qualification_start], + ['qualification_end', $incentive->qualification_end], + ['Registration Date', "{$registration_date} ({$range_icon})"], + ]); + + if (! $is_active) { + $this->warn(' -> Uebersprungen: Incentive nicht aktiv'); + + continue; + } + + if (! $in_range) { + $this->warn(' -> Uebersprungen: Registration Date ausserhalb Qualifikationszeitraum'); + + continue; + } + + $this->info(" -> MATCH! Incentive #{$incentive->id} ist aktiv und Registration Date liegt im Zeitraum."); + + // 6. Participant prüfen + $this->newLine(); + $this->info("[5] Participant-Check: Sponsor #{$sponsor_id} in Incentive #{$incentive->id}"); + + $participant = IncentiveParticipant::where('incentive_id', $incentive->id) + ->where('user_id', $sponsor_id) + ->first(); + + if (! $participant) { + $this->error(" Sponsor #{$sponsor_id} ist KEIN Teilnehmer in Incentive #{$incentive->id} -> SKIP"); + + // Zeige alle Teilnehmer-User-IDs + $participant_ids = IncentiveParticipant::where('incentive_id', $incentive->id) + ->pluck('user_id') + ->toArray(); + $this->info(' Teilnehmer User-IDs: '.implode(', ', array_slice($participant_ids, 0, 20)) + .(count($participant_ids) > 20 ? '... (+'.count($participant_ids) - 20 .')' : '')); + + continue; + } + + $this->info(" Participant gefunden: #{$participant->id} (User #{$participant->user_id})"); + $this->table(['Feld', 'Wert'], [ + ['participant.id', $participant->id], + ['user_id', $participant->user_id], + ['total_points', $participant->total_points], + ['qualified_partners', $participant->qualified_partners], + ['accepted_terms_at', $participant->accepted_terms_at ?? 'NULL'], + ]); + + // 7. Tracking-Eintrag prüfen + $this->newLine(); + $this->info('[6] Tracking-Eintrag (incentive_new_partners):'); + + $existing = IncentiveNewPartner::where('participant_id', $participant->id) + ->where('user_id', $new_user->id) + ->first(); + + if ($existing) { + $this->warn(" Eintrag existiert bereits: #{$existing->id} (erstellt: {$existing->created_at})"); + } else { + $this->info(' Kein Eintrag vorhanden -> wuerde neu erstellt werden.'); + } + + // 8. Zusammenfassung + $this->newLine(); + $this->info('=== ERGEBNIS ==='); + $this->info('trackNewPartner WUERDE erfolgreich laufen fuer:'); + $this->info(" Neuer Partner: User #{$new_user->id} ({$new_user->email})"); + $this->info(" Sponsor/Teilnehmer: User #{$sponsor_id} (Participant #{$participant->id})"); + $this->info(" Incentive: #{$incentive->id} ({$incentive->name})"); + $this->info(" Einmalpunkte: {$incentive->points_partner_onetime}"); + } + + // Prüfe den Query wie er im Code steht + $this->newLine(); + $this->info('[7] Exakter Query wie im Code:'); + $matched_incentives = Incentive::query() + ->active() + ->where('qualification_start', '<=', $registration_date) + ->where('qualification_end', '>=', $registration_date) + ->get(); + $this->info(" Incentive::active()->where(start <= {$registration_date})->where(end >= {$registration_date})"); + $this->info(" Ergebnis: {$matched_incentives->count()} Incentive(s)"); + foreach ($matched_incentives as $mi) { + $this->info(" -> #{$mi->id}: {$mi->name}"); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/IncentiveDebugTrackSalesVolume.php b/app/Console/Commands/IncentiveDebugTrackSalesVolume.php new file mode 100644 index 0000000..c4d3bfb --- /dev/null +++ b/app/Console/Commands/IncentiveDebugTrackSalesVolume.php @@ -0,0 +1,241 @@ +argument('sv_id'); + $usv = UserSalesVolume::find($sv_id); + + if (! $usv) { + $this->error("UserSalesVolume #{$sv_id} nicht gefunden."); + + return self::FAILURE; + } + + $this->info("=== Debug trackSalesVolume fuer USV #{$sv_id} ==="); + $this->newLine(); + + // 1. SalesVolume-Daten + $this->info('[1] SalesVolume-Daten:'); + $this->table(['Feld', 'Wert'], [ + ['id', $usv->id], + ['user_id', $usv->user_id ?? 'NULL'], + ['shopping_order_id', $usv->shopping_order_id ?? 'NULL'], + ['user_invoice_id', $usv->user_invoice_id ?? 'NULL'], + ['month', $usv->month ?? 'NULL'], + ['year', $usv->year ?? 'NULL'], + ['points', $usv->getRawOriginal('points') ?? 'NULL'], + ['status', $usv->status.' ('.($usv->getStatusType() ?: '-').')'], + ['status_points', $usv->status_points ?? 'NULL'], + ['status_turnover', $usv->status_turnover ?? 'NULL'], + ['message', $usv->message ?? 'NULL'], + ]); + + // 2. Fruehe Abbruch-Checks + $month = $usv->month; + $year = $usv->year; + + if (! $month || ! $year) { + $this->error('[ABBRUCH] month oder year ist NULL -> return'); + + return self::SUCCESS; + } + + $points = (int) abs($usv->getRawOriginal('points') ?? 0); + if ($points <= 0) { + $this->error("[ABBRUCH] points = {$points} (<= 0) -> return"); + + return self::SUCCESS; + } + + $this->info(" Effektive Punkte: {$points}"); + + // 3. Aktive Incentives + $this->newLine(); + $this->info('[2] Aktive Incentives:'); + $active_incentives = Incentive::query()->active()->get(); + $this->info(" Anzahl aktive: {$active_incentives->count()}"); + + foreach ($active_incentives as $incentive) { + $in_scope = $incentive->isDateInScope($month, $year); + $scope_label = $in_scope ? 'IM SCOPE' : 'AUSSERHALB'; + $this->info(" #{$incentive->id} {$incentive->name}: {$month}/{$year} -> {$scope_label}"); + $this->info(" Qualification: {$incentive->qualification_start} - {$incentive->qualification_end}, Calc End: {$incentive->calculation_end}"); + } + + // ===== TEIL A: Neupartner-Check ===== + $this->newLine(); + $this->info('========================================'); + $this->info('[A] NEUPARTNER-CHECK: Ist User #'.$usv->user_id.' ein gettrackter Neupartner?'); + $this->info('========================================'); + + $partner_trackings = IncentiveNewPartner::where('user_id', $usv->user_id) + ->with('participant.incentive') + ->get(); + + $this->info(" IncentiveNewPartner-Eintraege fuer user_id={$usv->user_id}: {$partner_trackings->count()}"); + + if ($partner_trackings->isEmpty()) { + $this->warn(' -> User ist KEIN gettrackter Neupartner in irgendeinem Incentive.'); + } + + foreach ($partner_trackings as $tracking) { + $participant = $tracking->participant; + $incentive = $participant->incentive ?? null; + + $this->newLine(); + $this->table(['Feld', 'Wert'], [ + ['NewPartner #', $tracking->id], + ['participant_id', $tracking->participant_id], + ['Participant User', $participant->user_id], + ['Incentive', $incentive ? "#{$incentive->id}: {$incentive->name}" : 'NULL'], + ['Incentive Status', $incentive ? $incentive->status : 'NULL'], + ['Incentive aktiv?', $incentive && $incentive->status == 1 ? 'JA' : 'NEIN'], + ]); + + if (! $incentive || $incentive->status != 1) { + $this->warn(' -> Incentive nicht aktiv -> SKIP'); + + continue; + } + + $in_scope = $incentive->isDateInScope($month, $year); + $this->info(" isDateInScope({$month}, {$year}): ".($in_scope ? 'JA' : 'NEIN')); + + if (! $in_scope) { + $this->warn(' -> Monat/Jahr ausserhalb Scope -> SKIP'); + + continue; + } + + // Duplikat-Check + $exists = IncentivePointsLog::where('participant_id', $participant->id) + ->where('user_sales_volume_id', $usv->id) + ->where('is_storno', false) + ->exists(); + + $this->info(' Log-Eintrag existiert bereits: '.($exists ? 'JA (Duplikat -> kein neuer Eintrag)' : 'NEIN -> wuerde erstellt')); + + $this->info(' ==> MATCH! Punkte wuerden Participant #'.$participant->id." (User #{$participant->user_id}) gutgeschrieben"); + $this->info(" Typ: partner, Punkte: {$points} (accumulated)"); + } + + // ===== TEIL B: Neuabo-Check ===== + $this->newLine(); + $this->info('========================================'); + $this->info('[B] NEUABO-CHECK: Stammt die Bestellung von einem gettrackten Abo-Kunden?'); + $this->info('========================================'); + + if (! $usv->shopping_order_id) { + $this->warn(' shopping_order_id ist NULL -> Abo-Check uebersprungen.'); + } else { + $order = ShoppingOrder::find($usv->shopping_order_id); + + if (! $order) { + $this->error(" ShoppingOrder #{$usv->shopping_order_id} nicht gefunden."); + } else { + $this->table(['Feld', 'Wert'], [ + ['Order ID', $order->id], + ['shopping_user_id', $order->shopping_user_id ?? 'NULL'], + ['auth_user_id', $order->auth_user_id ?? 'NULL'], + ['member_id', $order->member_id ?? 'NULL'], + ['payment_for', $order->payment_for], + ['is_abo', $order->is_abo ? 'JA' : 'NEIN'], + ]); + + if (! $order->shopping_user_id) { + $this->warn(' shopping_user_id ist NULL -> kein Abo-Matching moeglich.'); + } else { + $abo_trackings = IncentiveNewAbo::whereHas( + 'userAbo', + fn ($q) => $q->where('shopping_user_id', $order->shopping_user_id) + ) + ->with('participant.incentive', 'userAbo') + ->get(); + + $this->info(" IncentiveNewAbo mit shopping_user_id={$order->shopping_user_id}: {$abo_trackings->count()}"); + + if ($abo_trackings->isEmpty()) { + $this->warn(' -> Keine gettrackten Abos fuer diesen Kunden.'); + } + + foreach ($abo_trackings as $tracking) { + $participant = $tracking->participant; + $incentive = $participant->incentive ?? null; + $abo = $tracking->userAbo; + + $this->newLine(); + $this->table(['Feld', 'Wert'], [ + ['NewAbo #', $tracking->id], + ['user_abo_id', $tracking->user_abo_id], + ['Abo shopping_user_id', $abo ? $abo->shopping_user_id : 'NULL'], + ['participant_id', $tracking->participant_id], + ['Participant User', $participant->user_id], + ['Incentive', $incentive ? "#{$incentive->id}: {$incentive->name}" : 'NULL'], + ['Incentive aktiv?', $incentive && $incentive->status == 1 ? 'JA' : 'NEIN'], + ]); + + if (! $incentive || $incentive->status != 1) { + $this->warn(' -> Incentive nicht aktiv -> SKIP'); + + continue; + } + + $in_scope = $incentive->isDateInScope($month, $year); + $this->info(" isDateInScope({$month}, {$year}): ".($in_scope ? 'JA' : 'NEIN')); + + if (! $in_scope) { + $this->warn(' -> Monat/Jahr ausserhalb Scope -> SKIP'); + + continue; + } + + $exists = IncentivePointsLog::where('participant_id', $participant->id) + ->where('user_sales_volume_id', $usv->id) + ->where('is_storno', false) + ->exists(); + + $this->info(' Log-Eintrag existiert bereits: '.($exists ? 'JA (Duplikat)' : 'NEIN -> wuerde erstellt')); + + $this->info(' ==> MATCH! Punkte wuerden Participant #'.$participant->id." (User #{$participant->user_id}) gutgeschrieben"); + $this->info(" Typ: abo, Punkte: {$points} (accumulated)"); + } + } + } + } + + // ===== Zusammenfassung ===== + $this->newLine(); + $this->info('=== ZUSAMMENFASSUNG ==='); + + $total_partner = $partner_trackings->filter(function ($t) use ($month, $year) { + return $t->participant->incentive + && $t->participant->incentive->status == 1 + && $t->participant->incentive->isDateInScope($month, $year); + })->count(); + + $this->info(" Neupartner-Matches: {$total_partner}"); + $this->info(' Neuabo-Matches: siehe oben'); + + if ($total_partner === 0) { + $this->warn(' -> Keine Punkte wuerden vergeben (kein Match).'); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PayoneFailedPaypalReport.php b/app/Console/Commands/PayoneFailedPaypalReport.php new file mode 100644 index 0000000..ee91a92 --- /dev/null +++ b/app/Console/Commands/PayoneFailedPaypalReport.php @@ -0,0 +1,419 @@ +option('from'); + $to = $this->option('to') ?: now()->format('Y-m-d'); + $outputPath = $this->option('output'); + + $this->info("Schadenbericht PayPal-Ausfälle: {$from} bis {$to}"); + $this->newLine(); + + $orders = $this->getAffectedOrders($from, $to); + + if ($orders->isEmpty()) { + $this->warn('Keine fehlgeschlagenen PayPal-Zahlungen im angegebenen Zeitraum gefunden.'); + + return self::SUCCESS; + } + + $this->displaySummary($orders, $from, $to); + + $fullPath = base_path($outputPath); + $dir = dirname($fullPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $this->writeCsvReport($fullPath, $orders, $from, $to); + $this->writeTxtReport(str_replace('.csv', '.txt', $fullPath), $orders, $from, $to); + $this->writeEmailLists($dir, $orders); + + $this->newLine(); + $this->info("CSV-Bericht: {$fullPath}"); + $this->info('TXT-Bericht: ' . str_replace('.csv', '.txt', $fullPath)); + $this->info("E-Mail Berater: {$dir}/emails-berater.csv"); + $this->info("E-Mail Shop-Kunden: {$dir}/emails-shop-kunden.csv"); + + return self::SUCCESS; + } + + private function getAffectedOrders(string $from, string $to): \Illuminate\Support\Collection + { + return DB::table('shopping_orders') + ->join('shopping_payments', function ($join) { + $join->on('shopping_payments.shopping_order_id', '=', 'shopping_orders.id') + ->where('shopping_payments.clearingtype', '=', 'wlt') + ->where('shopping_payments.wallettype', '=', 'PPE'); + }) + ->join('payment_transactions', function ($join) { + $join->on('payment_transactions.shopping_payment_id', '=', 'shopping_payments.id') + ->where('payment_transactions.errorcode', '=', 923); + }) + ->join('shopping_users', 'shopping_users.id', '=', 'shopping_orders.shopping_user_id') + ->whereBetween('payment_transactions.created_at', ["{$from} 00:00:00", "{$to} 23:59:59"]) + ->select( + 'shopping_orders.id as order_id', + 'shopping_orders.total_shipping', + 'shopping_orders.paid', + 'shopping_orders.txaction', + 'shopping_orders.mode', + 'shopping_orders.payment_for', + 'shopping_orders.auth_user_id', + 'shopping_orders.created_at as order_date', + 'shopping_users.billing_email', + 'shopping_users.billing_firstname', + 'shopping_users.billing_lastname', + 'shopping_payments.id as payment_id', + 'shopping_payments.reference', + 'shopping_payments.amount as amount_cents', + 'shopping_payments.currency', + 'payment_transactions.id as tx_id', + 'payment_transactions.errorcode', + 'payment_transactions.errormessage', + 'payment_transactions.created_at as error_date', + ) + ->orderBy('payment_transactions.created_at') + ->get(); + } + + private function displaySummary(\Illuminate\Support\Collection $rows, string $from, string $to): void + { + $uniqueOrders = $rows->unique('order_id'); + $paidOrders = $uniqueOrders->where('paid', 1); + $unpaidOrders = $uniqueOrders->where('paid', 0); + + $this->table( + ['Kennzahl', 'Wert'], + [ + ['Zeitraum', "{$from} bis {$to}"], + ['Fehlgeschlagene Transaktionen (Error 923)', $rows->count()], + ['Betroffene Bestellungen (eindeutig)', $uniqueOrders->count()], + ['Davon nachträglich bezahlt (andere Zahlungsart)', $paidOrders->count()], + ['Nicht bezahlt (offen/verloren)', $unpaidOrders->count()], + ['Summe nicht bezahlter Bestellungen', number_format($unpaidOrders->sum('total_shipping'), 2, ',', '.') . ' EUR'], + ['Summe aller betroffenen Bestellungen', number_format($uniqueOrders->sum('total_shipping'), 2, ',', '.') . ' EUR'], + ] + ); + } + + private function writeCsvReport(string $path, \Illuminate\Support\Collection $rows, string $from, string $to): void + { + $fp = fopen($path, 'w'); + + fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF)); + + fputcsv($fp, [ + 'Fehler-Datum', + 'Bestell-Nr', + 'Bestell-Datum', + 'Transaktions-ID', + 'Payment-Referenz', + 'Betrag (EUR)', + 'Fehlercode', + 'Fehlermeldung', + 'Modus', + 'Nachträglich bezahlt', + 'Aktueller Status', + ], ';'); + + $uniqueOrders = $rows->unique('order_id'); + $unpaidOrders = $uniqueOrders->where('paid', 0); + + foreach ($rows as $row) { + fputcsv($fp, [ + $row->error_date, + $row->order_id, + $row->order_date, + $row->tx_id, + $row->reference, + number_format($row->total_shipping, 2, ',', ''), + $row->errorcode, + $row->errormessage, + $row->mode, + $row->paid ? 'Ja' : 'Nein', + $row->txaction, + ], ';'); + } + + fputcsv($fp, [], ';'); + fputcsv($fp, ['ZUSAMMENFASSUNG'], ';'); + fputcsv($fp, ['Zeitraum', "{$from} bis {$to}"], ';'); + fputcsv($fp, ['Fehlgeschlagene Transaktionen', $rows->count()], ';'); + fputcsv($fp, ['Betroffene Bestellungen', $uniqueOrders->count()], ';'); + fputcsv($fp, ['Nicht bezahlt (offen/verloren)', $unpaidOrders->count()], ';'); + fputcsv($fp, ['Summe nicht bezahlter Bestellungen', number_format($unpaidOrders->sum('total_shipping'), 2, ',', '') . ' EUR'], ';'); + fputcsv($fp, ['Summe aller betroffenen Bestellungen', number_format($uniqueOrders->sum('total_shipping'), 2, ',', '') . ' EUR'], ';'); + + fclose($fp); + } + + private function writeTxtReport(string $path, \Illuminate\Support\Collection $rows, string $from, string $to): void + { + $uniqueOrders = $rows->unique('order_id'); + $paidOrders = $uniqueOrders->where('paid', 1); + $unpaidOrders = $uniqueOrders->where('paid', 0); + + $lines = []; + $lines[] = '================================================================================'; + $lines[] = ' SCHADENBERICHT: Fehlgeschlagene PayPal-Zahlungen (PAYONE Error 923)'; + $lines[] = '================================================================================'; + $lines[] = ''; + $lines[] = "Zeitraum: {$from} bis {$to}"; + $lines[] = 'Erstellt am: ' . now()->format('d.m.Y H:i:s'); + $lines[] = 'Ursache: PayPal-Kontoverknüpfung bei PAYONE nicht migriert (Vertragsübernahme GmbH)'; + $lines[] = 'Portal-ID: 2030693'; + $lines[] = 'Merchant-ID: 42504'; + $lines[] = 'Sub-Account-ID: 43065'; + $lines[] = ''; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ' ZUSAMMENFASSUNG'; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ''; + $lines[] = sprintf(' Fehlgeschlagene Transaktionen (Error 923): %d', $rows->count()); + $lines[] = sprintf(' Betroffene Bestellungen (eindeutig): %d', $uniqueOrders->count()); + $lines[] = sprintf(' Davon nachträglich bezahlt (andere Methode): %d', $paidOrders->count()); + $lines[] = sprintf(' Nicht bezahlt (offen/verloren): %d', $unpaidOrders->count()); + $lines[] = ''; + $lines[] = sprintf(' Summe nicht bezahlter Bestellungen: %s EUR', number_format($unpaidOrders->sum('total_shipping'), 2, ',', '.')); + $lines[] = sprintf(' Summe nachträglich bezahlter Bestellungen: %s EUR', number_format($paidOrders->sum('total_shipping'), 2, ',', '.')); + $lines[] = sprintf(' Summe ALLER betroffenen Bestellungen: %s EUR', number_format($uniqueOrders->sum('total_shipping'), 2, ',', '.')); + $lines[] = ''; + + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ' AUFSCHLÜSSELUNG NACH TAG'; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ''; + + $byDate = $rows->groupBy(fn($r) => substr($r->error_date, 0, 10)); + foreach ($byDate as $date => $dayRows) { + $dayOrders = $dayRows->unique('order_id'); + $dayUnpaid = $dayOrders->where('paid', 0); + $lines[] = sprintf( + ' %s: %3d Fehler | %3d Bestellungen | %3d nicht bezahlt | %s EUR offen', + $date, + $dayRows->count(), + $dayOrders->count(), + $dayUnpaid->count(), + number_format($dayUnpaid->sum('total_shipping'), 2, ',', '.') + ); + } + + $lines[] = ''; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ' NICHT BEZAHLTE BESTELLUNGEN (DETAIL)'; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ''; + $lines[] = sprintf( + ' %-12s %-20s %-18s %-14s %s', + 'Bestell-Nr', + 'Datum', + 'Referenz', + 'Betrag (EUR)', + 'Status' + ); + $lines[] = ' ' . str_repeat('-', 80); + + foreach ($unpaidOrders->sortBy('order_date') as $order) { + $lines[] = sprintf( + ' %-12s %-20s %-18s %14s %s', + $order->order_id, + $order->order_date, + $order->reference, + number_format($order->total_shipping, 2, ',', '.'), + $order->txaction + ); + } + + $lines[] = ' ' . str_repeat('-', 80); + $lines[] = sprintf( + ' %-12s %-20s %-18s %14s', + 'GESAMT', + '', + $unpaidOrders->count() . ' Bestellungen', + number_format($unpaidOrders->sum('total_shipping'), 2, ',', '.') + ); + + $lines[] = ''; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ' NACHTRÄGLICH BEZAHLTE BESTELLUNGEN (andere Zahlungsart)'; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ''; + + if ($paidOrders->isEmpty()) { + $lines[] = ' Keine.'; + } else { + $lines[] = sprintf( + ' %-12s %-20s %-18s %-14s %s', + 'Bestell-Nr', + 'Datum', + 'Referenz', + 'Betrag (EUR)', + 'Status' + ); + $lines[] = ' ' . str_repeat('-', 80); + + foreach ($paidOrders->sortBy('order_date') as $order) { + $lines[] = sprintf( + ' %-12s %-20s %-18s %14s %s', + $order->order_id, + $order->order_date, + $order->reference, + number_format($order->total_shipping, 2, ',', '.'), + $order->txaction + ); + } + + $lines[] = ' ' . str_repeat('-', 80); + $lines[] = sprintf( + ' %-12s %-20s %-18s %14s', + 'GESAMT', + '', + $paidOrders->count() . ' Bestellungen', + number_format($paidOrders->sum('total_shipping'), 2, ',', '.') + ); + } + + $lines[] = ''; + $this->appendEmailSectionToTxt($lines, $unpaidOrders); + $lines[] = ''; + $lines[] = '================================================================================'; + $lines[] = ' Ende des Berichts'; + $lines[] = '================================================================================'; + $lines[] = ''; + + file_put_contents($path, implode("\n", $lines)); + } + + private function appendEmailSectionToTxt(array &$lines, \Illuminate\Support\Collection $unpaidOrders): void + { + $berater = $unpaidOrders->filter(fn($o) => ! empty($o->auth_user_id))->sortBy('billing_email'); + $shopKunden = $unpaidOrders->filter(fn($o) => empty($o->auth_user_id))->sortBy('billing_email'); + + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ' BETROFFENE BERATER (mit Auth-User-ID) - nicht bezahlt'; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ''; + $lines[] = sprintf(' %-12s %-8s %-30s %-30s %14s', 'Bestell-Nr', 'User-ID', 'Name', 'E-Mail', 'Betrag (EUR)'); + $lines[] = ' ' . str_repeat('-', 100); + + $beraterSum = 0; + foreach ($berater as $order) { + $name = trim(($order->billing_firstname ?? '') . ' ' . ($order->billing_lastname ?? '')); + $lines[] = sprintf( + ' %-12s %-8s %-30s %-30s %14s', + $order->order_id, + $order->auth_user_id, + mb_substr($name, 0, 28), + mb_substr($order->billing_email ?? '-', 0, 28), + number_format($order->total_shipping, 2, ',', '.') + ); + $beraterSum += $order->total_shipping; + } + + $lines[] = ' ' . str_repeat('-', 100); + $lines[] = sprintf(' %-12s %-8s %-30s %-30s %14s', 'GESAMT', '', $berater->count() . ' Bestellungen', '', number_format($beraterSum, 2, ',', '.')); + + $lines[] = ''; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ' BETROFFENE SHOP-KUNDEN (ohne Auth-User-ID) - nicht bezahlt'; + $lines[] = '--------------------------------------------------------------------------------'; + $lines[] = ''; + $lines[] = sprintf(' %-12s %-30s %-30s %14s', 'Bestell-Nr', 'Name', 'E-Mail', 'Betrag (EUR)'); + $lines[] = ' ' . str_repeat('-', 90); + + $shopSum = 0; + foreach ($shopKunden as $order) { + $name = trim(($order->billing_firstname ?? '') . ' ' . ($order->billing_lastname ?? '')); + $lines[] = sprintf( + ' %-12s %-30s %-30s %14s', + $order->order_id, + mb_substr($name, 0, 28), + mb_substr($order->billing_email ?? '-', 0, 28), + number_format($order->total_shipping, 2, ',', '.') + ); + $shopSum += $order->total_shipping; + } + + $lines[] = ' ' . str_repeat('-', 90); + $lines[] = sprintf(' %-12s %-30s %-30s %14s', 'GESAMT', $shopKunden->count() . ' Bestellungen', '', number_format($shopSum, 2, ',', '.')); + } + + private function writeEmailLists(string $dir, \Illuminate\Support\Collection $rows): void + { + $unpaidOrders = $rows->unique('order_id')->where('paid', 0); + + $berater = $unpaidOrders->filter(fn($o) => ! empty($o->auth_user_id))->sortBy('order_date'); + $shopKunden = $unpaidOrders->filter(fn($o) => empty($o->auth_user_id))->sortBy('order_date'); + + $this->writeEmailCsv("{$dir}/emails-berater.csv", $berater, true); + $this->writeEmailCsv("{$dir}/emails-shop-kunden.csv", $shopKunden, false); + + $this->newLine(); + $this->table( + ['Kategorie', 'Bestellungen', 'Eindeutige E-Mails', 'Summe (EUR)'], + [ + [ + 'Berater (mit Auth-User-ID)', + $berater->count(), + $berater->pluck('billing_email')->filter()->unique()->count(), + number_format($berater->sum('total_shipping'), 2, ',', '.') . ' EUR', + ], + [ + 'Shop-Kunden (ohne Auth-User-ID)', + $shopKunden->count(), + $shopKunden->pluck('billing_email')->filter()->unique()->count(), + number_format($shopKunden->sum('total_shipping'), 2, ',', '.') . ' EUR', + ], + ] + ); + } + + private function writeEmailCsv(string $path, \Illuminate\Support\Collection $orders, bool $includeUserId): void + { + $fp = fopen($path, 'w'); + fprintf($fp, chr(0xEF) . chr(0xBB) . chr(0xBF)); + + $headers = ['Bestell-Nr', 'Bestell-Datum', 'Vorname', 'Nachname', 'E-Mail', 'Betrag (EUR)', 'Status']; + if ($includeUserId) { + array_splice($headers, 1, 0, 'Auth-User-ID'); + } + fputcsv($fp, $headers, ';'); + + foreach ($orders as $order) { + $row = [ + $order->order_id, + $order->order_date, + $order->billing_firstname ?? '', + $order->billing_lastname ?? '', + $order->billing_email ?? '', + number_format($order->total_shipping, 2, ',', ''), + $order->txaction, + ]; + if ($includeUserId) { + array_splice($row, 1, 0, $order->auth_user_id); + } + fputcsv($fp, $row, ';'); + } + + fputcsv($fp, [], ';'); + fputcsv($fp, ['GESAMT', '', '', '', $orders->count() . ' Bestellungen', number_format($orders->sum('total_shipping'), 2, ',', '') . ' EUR'], ';'); + fputcsv($fp, ['Eindeutige E-Mail-Adressen', $orders->pluck('billing_email')->filter()->unique()->count()], ';'); + + fclose($fp); + } +} diff --git a/app/Console/Commands/RepairMissingAboFromOrders.php b/app/Console/Commands/RepairMissingAboFromOrders.php new file mode 100644 index 0000000..2308569 --- /dev/null +++ b/app/Console/Commands/RepairMissingAboFromOrders.php @@ -0,0 +1,220 @@ += (Y-m-d)} + {--until= : Nur Bestellungen mit created_at <= Ende dieses Tages (Y-m-d)} + {--order= : Komma-getrennte shopping_order IDs (Filter)} + {--mode=live : Modus: live, test, dev oder all} + {--stats : Zusaetzliche Statistik: bezahlte Abo-Bestellungen vs. mit/ohne UserAboOrder}'; + + protected $description = 'Abgleich und Reparatur: bezahlte Abo-Bestellungen (Checkout) ohne Verknuepfung user_abo_orders — z. B. nach Payone-Callback vor Erfolgs-Redirect'; + + public function handle(): int + { + $missing = $this->queryMissingOrders()->orderBy('id')->get(); + + $this->info('Abgleich: Bestellungen mit is_abo, abo_interval>0, als bezahlt markiert, ohne user_abo_orders-Eintrag.'); + $this->newLine(); + + if ($this->option('stats')) { + $this->printStats(); + $this->newLine(); + } + + $this->info('Treffer (fehlende Verknuepfung): '.$missing->count()); + + if ($missing->isEmpty()) { + $this->info('Keine Diskrepanz — nichts zu tun.'); + + return self::SUCCESS; + } + + $this->table( + ['ID', 'shopping_user_id', 'mode', 'txaction', 'paid', 'created_at'], + $missing->take(200)->map(fn (ShoppingOrder $o) => [ + $o->id, + $o->shopping_user_id, + $o->mode, + $o->txaction, + $o->paid ? '1' : '0', + $o->created_at?->format('Y-m-d H:i'), + ]) + ); + + if ($missing->count() > 200) { + $this->warn('… und weitere '.($missing->count() - 200).' Eintraege (Ausgabe gekuerzt).'); + } + + if (! $this->option('fix')) { + $this->newLine(); + $this->warn('Trockenlauf. Nutze --fix zur Reparatur (mit Bestaetigung).'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm('Wirklich '.$missing->count().' Bestellung(en) reparieren?')) { + return self::SUCCESS; + } + + $ok = 0; + $fail = 0; + $bar = $this->output->createProgressBar($missing->count()); + $bar->start(); + + foreach ($missing as $order) { + try { + DB::transaction(function () use ($order) { + $this->repairSingleOrder($order); + }); + $ok++; + } catch (\Throwable $e) { + $fail++; + $this->newLine(); + $this->error("Order #{$order->id}: {$e->getMessage()}"); + } + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info("Fertig: {$ok} repariert, {$fail} Fehler."); + + return $fail > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @return \Illuminate\Database\Eloquent\Builder + */ + private function queryMissingOrders(): \Illuminate\Database\Eloquent\Builder + { + $q = ShoppingOrder::query() + ->where('is_abo', true) + ->where('abo_interval', '>', 0) + ->where(function ($sub) { + $sub->where('paid', true) + ->orWhere('paid', 1); + }) + ->whereIn('txaction', ['paid', 'invoice_paid', 'extern_paid']) + ->whereNotNull('shopping_user_id') + ->whereHas('shopping_payments') + ->whereNotExists(function ($sub) { + $sub->select(DB::raw('1')) + ->from('user_abo_orders') + ->whereColumn('user_abo_orders.shopping_order_id', 'shopping_orders.id'); + }); + + if ($ids = $this->parseOrderIds()) { + $q->whereIn('id', $ids); + } + + if ($since = $this->option('since')) { + $q->where('created_at', '>=', $since.' 00:00:00'); + } + + if ($until = $this->option('until')) { + $q->where('created_at', '<=', $until.' 23:59:59'); + } + + $mode = (string) $this->option('mode'); + if ($mode !== 'all') { + $q->where('mode', $mode); + } + + return $q; + } + + /** + * @return list + */ + private function parseOrderIds(): array + { + $raw = $this->option('order'); + if ($raw === null || $raw === '') { + return []; + } + + return array_values(array_filter(array_map('intval', explode(',', (string) $raw)))); + } + + private function printStats(): void + { + $mode = (string) $this->option('mode'); + $base = ShoppingOrder::query() + ->where('is_abo', true) + ->where('abo_interval', '>', 0) + ->where(function ($sub) { + $sub->where('paid', true)->orWhere('paid', 1); + }) + ->whereIn('txaction', ['paid', 'invoice_paid', 'extern_paid']); + + if ($since = $this->option('since')) { + $base->where('created_at', '>=', $since.' 00:00:00'); + } + if ($until = $this->option('until')) { + $base->where('created_at', '<=', $until.' 23:59:59'); + } + if ($mode !== 'all') { + $base->where('mode', $mode); + } + if ($ids = $this->parseOrderIds()) { + $base->whereIn('id', $ids); + } + + $totalPaidAbo = (clone $base)->count(); + + $withLink = (clone $base)->whereExists(function ($sub) { + $sub->select(DB::raw('1')) + ->from('user_abo_orders') + ->whereColumn('user_abo_orders.shopping_order_id', 'shopping_orders.id'); + })->count(); + + $this->table( + ['Kennzahl', 'Anzahl'], + [ + ['Bezahlte Abo-Bestellungen (Filter)', $totalPaidAbo], + ['Davon mit user_abo_orders', $withLink], + ['Davon ohne user_abo_orders', max(0, $totalPaidAbo - $withLink)], + ] + ); + } + + private function repairSingleOrder(ShoppingOrder $order): void + { + $payment = ShoppingPayment::query() + ->where('shopping_order_id', $order->id) + ->orderByDesc('id') + ->first(); + + if (! $payment) { + throw new \RuntimeException('Kein ShoppingPayment zur Bestellung.'); + } + + $order->loadMissing(['shopping_user', 'shopping_order_items']); + $payment->loadMissing(['payment_transactions']); + $payment->setRelation('shopping_order', $order); + + AboHelper::createNewAbo($payment); + + $order->refresh(); + + if (! $order->getUserAbo()) { + throw new \RuntimeException('createNewAbo hat kein UserAbo erzeugt (pruefen: abo_interval, Bestellpositionen, ShoppingPayment.abo_interval).'); + } + + AboHelper::setAboActive($order, 2, true); + IncentiveTracker::trackAboActivated($order); + } +} diff --git a/app/Console/Commands/RepairMissingInvoices.php b/app/Console/Commands/RepairMissingInvoices.php new file mode 100644 index 0000000..903c833 --- /dev/null +++ b/app/Console/Commands/RepairMissingInvoices.php @@ -0,0 +1,129 @@ +option('since') ?? '2026-03-16'; + $fix = $this->option('fix') ?? false; + + $orders = ShoppingOrder::query() + ->where('mode', 'live') + ->where('paid', 0) + ->where('txaction', 'paid') + ->where('created_at', '>=', $since) + ->whereNull('deleted_at') + ->whereDoesntHave('user_invoice') + ->whereDoesntHave('user_sales_volume') + // ->whereDoesntHave('shopping_payments', fn($q) => $q->where('clearingtype', 'vor')) + ->orderBy('created_at') + ->get(); + + $this->info("Betroffene Bestellungen seit {$since}: {$orders->count()}"); + + if ($orders->isEmpty()) { + $this->info('Keine betroffenen Bestellungen gefunden.'); + + return self::SUCCESS; + } + + // Zusammenfassung + $total = $orders->sum('total'); + $byPaymentFor = $orders->groupBy('payment_for')->map->count(); + $this->table( + ['payment_for', 'Anzahl'], + $byPaymentFor->map(fn ($count, $type) => [$type, $count])->values() + ); + $this->info("Gesamtwert: {$total} EUR"); + + if (! $fix) { + $this->warn('Trockenlauf! Nutze --fix um die Reparatur durchzufuehren.'); + $this->newLine(); + + // Erste 10 anzeigen + $this->table( + ['ID', 'payment_for', 'total', 'txaction', 'created_at'], + $orders->take(100)->map(fn ($o) => [ + $o->id, + $o->payment_for, + $o->total, + $o->txaction, + $o->created_at->format('Y-m-d H:i'), + ]) + ); + + if ($orders->count() > 100) { + $this->info('... und '.($orders->count() - 100).' weitere'); + } + + return self::SUCCESS; + } + + $send_mail = ! $this->option('no-mail'); + + if ($send_mail) { + $this->info('Rechnungs-Mails werden versendet. Nutze --no-mail um dies zu unterdruecken.'); + } else { + $this->warn('Rechnungs-Mails werden NICHT versendet.'); + } + + if (! $this->confirm("Wirklich {$orders->count()} Bestellungen reparieren?")) { + return self::SUCCESS; + } + + $success = 0; + $errors = 0; + $bar = $this->output->createProgressBar($orders->count()); + $bar->start(); + + foreach ($orders as $order) { + try { + // 1. SalesVolume erstellen + $user_sales_volume = SalesPointsVolume::User($order); + + // 2. Rechnung erstellen (mit Mail-Versand) + $invoice_repo = new InvoiceRepository($order); + $user_invoice = $invoice_repo->create([ + 'invoice_send_mail' => $send_mail, + ]); + + // 3. SalesVolume mit Rechnung verknuepfen + $user_sales_volume->user_invoice_id = $user_invoice->id; + $user_sales_volume->save(); + + // 4. Incentive tracking (falls relevant) + IncentiveTracker::trackSalesVolume($user_sales_volume); + + $success++; + $this->info("Order #{$order->id}: Reparatur erfolgreich"); + } catch (\Throwable $e) { + $errors++; + $this->newLine(); + $this->error("Order #{$order->id}: {$e->getMessage()}"); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info("Fertig: {$success} repariert, {$errors} Fehler."); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Console/Commands/RetryFailedPaypalAbos.php b/app/Console/Commands/RetryFailedPaypalAbos.php new file mode 100644 index 0000000..ab35768 --- /dev/null +++ b/app/Console/Commands/RetryFailedPaypalAbos.php @@ -0,0 +1,297 @@ +timeStart = microtime(true); + $dryRun = $this->option('dry-run'); + $singleAboId = $this->option('abo-id'); + + \Log::channel('abo_order')->info('RetryFailedPaypalAbos: Gestartet', [ + 'dry_run' => $dryRun, + 'abo_id' => $singleAboId, + ]); + + $this->info($dryRun ? '=== DRY-RUN Modus (keine Bestellungen) ===' : '=== LIVE Modus ==='); + $this->newLine(); + + $abos = $this->getAffectedAbos($singleAboId); + + if ($abos->isEmpty()) { + $this->warn('Keine betroffenen PayPal-Abos gefunden.'); + + return self::SUCCESS; + } + + $this->displayAboList($abos); + + if (! $dryRun && ! $singleAboId) { + if (! $this->confirm("Sollen alle {$abos->count()} Abos jetzt erneut ausgeführt werden?")) { + $this->info('Abgebrochen.'); + + return self::SUCCESS; + } + } + + $results = ['success' => 0, 'error' => 0, 'skipped' => 0]; + + foreach ($abos as $userAbo) { + if ($dryRun) { + $this->info(" [DRY-RUN] Abo #{$userAbo->id} würde ausgeführt werden"); + $results['skipped']++; + + continue; + } + + try { + $result = $this->retryAboOrder($userAbo); + if ($result) { + $results['success']++; + } else { + $results['error']++; + } + } catch (\Throwable $e) { + $results['error']++; + \Log::channel('abo_order')->error('RetryFailedPaypalAbos: Exception', [ + 'abo_id' => $userAbo->id, + 'error' => $e->getMessage(), + ]); + $this->error(" Abo #{$userAbo->id}: Exception - {$e->getMessage()}"); + } + } + + $this->newLine(); + $this->table( + ['Ergebnis', 'Anzahl'], + [ + ['Erfolgreich', $results['success']], + ['Fehlgeschlagen', $results['error']], + ['Übersprungen (Dry-Run)', $results['skipped']], + ] + ); + + $executionTime = $this->getExecutionTime(); + $this->info("Abgeschlossen in {$executionTime}"); + \Log::channel('abo_order')->info("RetryFailedPaypalAbos: Abgeschlossen in {$executionTime}", $results); + + return self::SUCCESS; + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ + private function getAffectedAbos(?string $singleAboId): \Illuminate\Database\Eloquent\Collection + { + $query = UserAbo::query() + ->where('status', 3) + ->where('active', true) + ->where('clearingtype', 'wlt') + ->where('wallettype', 'PPE') + ->whereRaw("DATE(next_date) = '2026-04-05'") + ->with(['shopping_user', 'user_abo_items']); + + if ($singleAboId) { + $query->where('id', $singleAboId); + } + + return $query->orderBy('id')->get(); + } + + private function displayAboList(\Illuminate\Database\Eloquent\Collection $abos): void + { + $rows = $abos->map(fn (UserAbo $abo) => [ + $abo->id, + $abo->user_id ?? '-', + $abo->is_for, + $abo->email, + $abo->abo_interval, + $abo->getRawOriginal('next_date'), + $abo->user_abo_items->count().' Artikel', + ]); + + $this->table( + ['Abo-ID', 'User-ID', 'Typ', 'E-Mail', 'Intervall', 'Next-Date', 'Artikel'], + $rows->toArray() + ); + + $this->info("Betroffene Abos: {$abos->count()}"); + $this->newLine(); + } + + private function retryAboOrder(UserAbo $userAbo): bool + { + $this->info(" Verarbeite Abo #{$userAbo->id} ({$userAbo->email})..."); + + \Log::channel('abo_order')->info('RetryFailedPaypalAbos: Verarbeite Abo', [ + 'abo_id' => $userAbo->id, + 'email' => $userAbo->email, + 'payone_userid' => $userAbo->payone_userid, + ]); + + $alreadyPaidToday = UserAboOrder::where('user_abo_id', $userAbo->id) + ->whereDate('created_at', now()->toDateString()) + ->where('paid', true) + ->exists(); + + if ($alreadyPaidToday) { + $this->warn(" Abo #{$userAbo->id}: Bereits heute bezahlt - übersprungen"); + + return true; + } + + AboHelper::ensureUserAboItemsFromLatestOrder($userAbo); + + $shoppingOrder = null; + $userOrder = new UserMakeOrder($userAbo); + + try { + if (! $userOrder->createShoppingUser()) { + $this->error(" Abo #{$userAbo->id}: Shopping-User konnte nicht erstellt werden"); + + return false; + } + + $shoppingOrder = $userOrder->makeShoppingOrder(); + if (! $shoppingOrder) { + $this->error(" Abo #{$userAbo->id}: Bestellung konnte nicht erstellt werden"); + + return false; + } + + $this->info(" Bestellung #{$shoppingOrder->id} erstellt (Betrag: {$shoppingOrder->total_shipping} EUR)"); + + $response = $userOrder->makePayment(); + if (is_object($response)) { + $response = (array) $response; + } + + if (! isset($response['status'])) { + $this->error(" Abo #{$userAbo->id}: Ungültige Zahlungsantwort"); + $this->markAboError($userAbo, $shoppingOrder); + + return false; + } + + if ($response['status'] === 'APPROVED') { + $this->info(" Zahlung ERFOLGREICH für Abo #{$userAbo->id}"); + $this->markAboSuccess($userAbo, $shoppingOrder); + + return true; + } + + $errorCode = $response['errorcode'] ?? '-'; + $errorMsg = $response['errormessage'] ?? '-'; + $this->error(" Zahlung FEHLGESCHLAGEN für Abo #{$userAbo->id}: [{$errorCode}] {$errorMsg}"); + + MyLog::writeLog( + 'userabo', + 'error', + 'Error:RetryPaypal RetryFailedPaypalAbos / makePayment Error', + $response + ); + + $this->markAboError($userAbo, $shoppingOrder); + + $shoppingPayment = $userOrder->getShoppingPayment(); + if ($shoppingPayment) { + Payment::paymentStatusSendMail($shoppingOrder, $shoppingPayment, [ + 'mode' => $shoppingPayment->mode, + 'txaction' => 'error', + 'send_link' => false, + 'payment_error' => $response, + ]); + } + + return false; + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('RetryFailedPaypalAbos: Exception bei Abo-Verarbeitung', [ + 'abo_id' => $userAbo->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $this->error(" Exception: {$e->getMessage()}"); + + if ($shoppingOrder) { + $this->markAboError($userAbo, $shoppingOrder); + } + + return false; + } + } + + private function markAboSuccess(UserAbo $userAbo, $shoppingOrder): void + { + DB::transaction(function () use ($userAbo, $shoppingOrder) { + $nextDate = AboHelper::setNextDate(now(), $userAbo->abo_interval); + + $userAbo->update([ + 'status' => 2, + 'next_date' => $nextDate, + 'last_date' => now(), + ]); + + UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 1, + 'paid' => true, + ]); + }); + + IncentiveTracker::trackAboActivated($shoppingOrder); + + $nextDateFormatted = Carbon::parse($userAbo->getRawOriginal('next_date'))->format('d.m.Y'); + $this->info(" Status → 2 (abo_okay), nächstes Datum → {$nextDateFormatted}"); + + \Log::channel('abo_order')->info('RetryFailedPaypalAbos: Abo erfolgreich reaktiviert', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder->id, + 'next_date' => $userAbo->getRawOriginal('next_date'), + ]); + } + + private function markAboError(UserAbo $userAbo, $shoppingOrder): void + { + DB::transaction(function () use ($userAbo, $shoppingOrder) { + $userAbo->update(['last_date' => now()]); + + UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 3, + 'paid' => false, + ]); + }); + } + + private function getExecutionTime(): string + { + $diff = microtime(true) - $this->timeStart; + $sec = intval($diff); + $micro = $diff - $sec; + + return $sec.' Sekunden und '.round($micro * 1000, 2).' ms'; + } +} diff --git a/app/Console/Commands/TestUserMakeAboOrder.php b/app/Console/Commands/TestUserMakeAboOrder.php index df80968..6ad2ee6 100644 --- a/app/Console/Commands/TestUserMakeAboOrder.php +++ b/app/Console/Commands/TestUserMakeAboOrder.php @@ -2,13 +2,12 @@ namespace App\Console\Commands; -use Carbon\Carbon; -use App\Models\UserAbo; use App\Cron\UserMakeOrder; -use App\Services\AboHelper; +use App\Models\UserAbo; use App\Models\UserAboOrder; +use App\Services\AboHelper; +use Carbon\Carbon; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\DB; class TestUserMakeAboOrder extends Command @@ -63,8 +62,9 @@ class TestUserMakeAboOrder extends Command if ($aboId) { // Test für spezifisches Abo $userAbo = UserAbo::find($aboId); - if (!$userAbo) { + if (! $userAbo) { $this->error("Abo mit ID {$aboId} nicht gefunden!"); + return 1; } @@ -80,8 +80,9 @@ class TestUserMakeAboOrder extends Command return 0; } catch (\Exception $e) { - $this->error('Fehler beim Testen: ' . $e->getMessage()); + $this->error('Fehler beim Testen: '.$e->getMessage()); $this->error($e->getTraceAsString()); + return 1; } } @@ -89,10 +90,10 @@ class TestUserMakeAboOrder extends Command /** * Testet ein einzelnes Abo * - * @param UserAbo $userAbo - * @param string $testDate - * @param bool $dryRun - * @param bool $force + * @param UserAbo $userAbo + * @param string $testDate + * @param bool $dryRun + * @param bool $force * @return void */ private function testSingleAbo($userAbo, $testDate, $dryRun, $force) @@ -101,15 +102,15 @@ class TestUserMakeAboOrder extends Command $this->displayAboInfo($userAbo); // Prüfe ob Abo für Test-Datum fällig ist - if ($userAbo->next_date != $testDate && !$force) { + if ($userAbo->next_date != $testDate && ! $force) { $this->warn("Abo ist nicht für {$testDate} fällig (next_date: {$userAbo->next_date})"); - if (!$this->confirm('Trotzdem fortfahren?', false)) { + if (! $this->confirm('Trotzdem fortfahren?', false)) { return; } } // Prüfe auf Duplikate - if (!$force) { + if (! $force) { $existingOrder = UserAboOrder::where('user_abo_id', $userAbo->id) ->whereDate('created_at', $testDate) ->first(); @@ -117,7 +118,7 @@ class TestUserMakeAboOrder extends Command if ($existingOrder) { $this->warn("Es existiert bereits eine Bestellung für dieses Abo am {$testDate}"); $this->info("Bestell-ID: {$existingOrder->shopping_order_id}"); - if (!$this->confirm('Trotzdem fortfahren?', false)) { + if (! $this->confirm('Trotzdem fortfahren?', false)) { return; } } @@ -144,7 +145,7 @@ class TestUserMakeAboOrder extends Command if ($shoppingOrder) { $this->info("✓ Bestellung erfolgreich erstellt: ID {$shoppingOrder->id}"); } else { - $this->error("✗ Bestellung konnte nicht erstellt werden"); + $this->error('✗ Bestellung konnte nicht erstellt werden'); } } finally { // next_date zurücksetzen falls geändert @@ -160,9 +161,9 @@ class TestUserMakeAboOrder extends Command /** * Testet alle fälligen Abos * - * @param string $testDate - * @param bool $dryRun - * @param bool $force + * @param string $testDate + * @param bool $dryRun + * @param bool $force * @return void */ private function testAllAbos($testDate, $dryRun, $force) @@ -170,7 +171,7 @@ class TestUserMakeAboOrder extends Command $query = UserAbo::where('next_date', '=', $testDate) ->where('active', true); - if (!$force) { + if (! $force) { $query->whereDoesntHave('user_abo_orders', function ($q) use ($testDate) { $q->whereDate('created_at', $testDate); }); @@ -184,10 +185,11 @@ class TestUserMakeAboOrder extends Command if ($count === 0) { $this->warn('Keine fälligen Abos gefunden!'); + return; } - if (!$this->confirm("Möchten Sie {$count} Abo(s) testen?", true)) { + if (! $this->confirm("Möchten Sie {$count} Abo(s) testen?", true)) { return; } @@ -203,7 +205,7 @@ class TestUserMakeAboOrder extends Command /** * Zeigt Informationen über ein Abo an * - * @param UserAbo $userAbo + * @param UserAbo $userAbo * @return void */ private function displayAboInfo($userAbo) @@ -215,11 +217,11 @@ class TestUserMakeAboOrder extends Command ['User ID', $userAbo->user_id], ['Payone UserID', $userAbo->payone_userid], ['Aktiv', $userAbo->active ? 'Ja' : 'Nein'], - ['Status', $userAbo->status . ' (' . ($userAbo->getStatusType() ?? 'unbekannt') . ')'], + ['Status', $userAbo->status.' ('.($userAbo->getStatusType() ?? 'unbekannt').')'], ['Intervall', $userAbo->abo_interval], ['Next Date', $userAbo->next_date], ['Last Date', $userAbo->last_date ?? 'Nie'], - ['Amount', number_format($userAbo->amount / 100, 2, ',', '.') . ' €'], + ['Amount', number_format($userAbo->amount / 100, 2, ',', '.').' €'], ['is_for', $userAbo->is_for], ['Clearing Type', $userAbo->clearingtype], ['Items', $userAbo->user_abo_items->count()], @@ -235,7 +237,7 @@ class TestUserMakeAboOrder extends Command 'Product ID' => $item->product_id, 'Qty' => $item->qty, 'Comp' => $item->comp ?? '-', - 'Price' => number_format($item->price / 100, 2, ',', '.') . ' €', + 'Price' => number_format($item->price / 100, 2, ',', '.').' €', ]; } $this->table(['Product ID', 'Qty', 'Comp', 'Price'], $items); @@ -245,7 +247,7 @@ class TestUserMakeAboOrder extends Command /** * Zeigt eine Vorschau der Bestellung an * - * @param UserAbo $userAbo + * @param UserAbo $userAbo * @return void */ private function displayOrderPreview($userAbo) @@ -265,8 +267,8 @@ class TestUserMakeAboOrder extends Command /** * Erstellt eine Bestellung für ein Abo (vereinfachte Version für Test) * - * @param UserAbo $userAbo - * @param bool $dryRun + * @param UserAbo $userAbo + * @param bool $dryRun * @return mixed */ private function makeOrder($userAbo, $dryRun = false) @@ -274,18 +276,20 @@ class TestUserMakeAboOrder extends Command $this->info('Erstelle Shopping-User...'); $userOrder = new UserMakeOrder($userAbo); - if (!$userOrder->createShoppingUser()) { + if (! $userOrder->createShoppingUser()) { $this->error('Konnte Shopping-User nicht erstellen'); + return null; } $this->info('✓ Shopping-User erstellt'); $this->info('Erstelle Bestellung...'); $shoppingOrder = $userOrder->makeShoppingOrder(); - $shoppingOrder->mode = 'test'; //immer im test mode testen + $shoppingOrder->mode = 'test'; // immer im test mode testen $shoppingOrder->save(); - if (!$shoppingOrder) { + if (! $shoppingOrder) { $this->error('Konnte Bestellung nicht erstellen'); + return null; } $this->info("✓ Bestellung erstellt: ID {$shoppingOrder->id}"); @@ -293,6 +297,7 @@ class TestUserMakeAboOrder extends Command if ($dryRun) { $this->info('[DRY-RUN] Zahlung würde durchgeführt'); $this->info('[DRY-RUN] Abo würde aktualisiert'); + return $shoppingOrder; } @@ -304,10 +309,11 @@ class TestUserMakeAboOrder extends Command $response = (array) $response; } - $this->info('Zahlungsantwort: ' . json_encode($response, JSON_PRETTY_PRINT)); + $this->info('Zahlungsantwort: '.json_encode($response, JSON_PRETTY_PRINT)); - if (!isset($response['status'])) { + if (! isset($response['status'])) { $this->warn('⚠ Kein Status in Zahlungsantwort'); + return $shoppingOrder; } @@ -323,7 +329,7 @@ class TestUserMakeAboOrder extends Command $this->warn("⚠ Zahlungsstatus: {$response['status']}"); } } catch (\Exception $e) { - $this->error('Fehler bei Zahlung: ' . $e->getMessage()); + $this->error('Fehler bei Zahlung: '.$e->getMessage()); } return $shoppingOrder; @@ -332,9 +338,9 @@ class TestUserMakeAboOrder extends Command /** * Aktualisiert das Abo nach erfolgreicher Bestellung (vereinfachte Version) * - * @param UserAbo $userAbo - * @param mixed $shoppingOrder - * @param int $status + * @param UserAbo $userAbo + * @param mixed $shoppingOrder + * @param int $status * @return void */ private function updateAbo($userAbo, $shoppingOrder, $status = 1) @@ -356,10 +362,11 @@ class TestUserMakeAboOrder extends Command 'user_abo_id' => $userAbo->id, 'shopping_order_id' => $shoppingOrder->id, 'status' => $status, + 'paid' => true, ]); }); } catch (\Exception $e) { - $this->error('Fehler beim Aktualisieren des Abos: ' . $e->getMessage()); + $this->error('Fehler beim Aktualisieren des Abos: '.$e->getMessage()); throw $e; } } @@ -375,6 +382,6 @@ class TestUserMakeAboOrder extends Command $sec = intval($diff); $micro = $diff - $sec; - return $sec . ' Sekunden und ' . round($micro * 1000, 2) . ' ms'; + return $sec.' Sekunden und '.round($micro * 1000, 2).' ms'; } } diff --git a/app/Console/Commands/UserMakeAboOrder.php b/app/Console/Commands/UserMakeAboOrder.php index 9c7855b..0f9805f 100644 --- a/app/Console/Commands/UserMakeAboOrder.php +++ b/app/Console/Commands/UserMakeAboOrder.php @@ -2,16 +2,15 @@ namespace App\Console\Commands; -use Carbon\Carbon; -use App\Models\Setting; +use App\Cron\UserMakeOrder; use App\Models\UserAbo; +use App\Models\UserAboOrder; +use App\Services\AboHelper; +use App\Services\Incentive\IncentiveTracker; use App\Services\MyLog; use App\Services\Payment; -use App\Cron\UserMakeOrder; -use App\Services\AboHelper; -use App\Models\UserAboOrder; +use Carbon\Carbon; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\DB; class UserMakeAboOrder extends Command @@ -33,10 +32,13 @@ class UserMakeAboOrder extends Command protected $description = 'Make Orders from Abos'; private $timeStart; + private $month; + private $year; private $sendCreditMail = false; + private $sendUpdateMail = false; /** @@ -70,9 +72,10 @@ class UserMakeAboOrder extends Command } catch (\Exception $e) { \Log::channel('cron')->error('UserMakeAboOrder: Fehler beim Ausführen des Befehls', [ 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + 'trace' => $e->getTraceAsString(), ]); $this->error('Fehler beim Ausführen des Befehls: ' . $e->getMessage()); + return 1; } } @@ -91,7 +94,7 @@ class UserMakeAboOrder extends Command // Prüfe auf bereits verarbeitete Abos am heutigen Tag (Duplikatsprüfung) $userAbos = UserAbo::where('next_date', '=', $dateNow) ->where('active', true) - ->where('status', '=', 2) //abo_okay + ->where('status', '=', 2) // abo_okay ->whereDoesntHave('user_abo_orders', function ($query) use ($dateNow) { $query->whereDate('created_at', $dateNow); }) @@ -104,7 +107,7 @@ class UserMakeAboOrder extends Command foreach ($userAbos as $userAbo) { \Log::channel('abo_order')->info('UserMakeAboOrder: Verarbeite Abo', [ 'abo_id' => $userAbo->id, - 'payone_userid' => $userAbo->payone_userid + 'payone_userid' => $userAbo->payone_userid, ]); $this->info("Verarbeite Abo: {$userAbo->id} (PayoneUserid: {$userAbo->payone_userid})"); @@ -116,14 +119,15 @@ class UserMakeAboOrder extends Command $lockedAbo = UserAbo::where('id', $userAbo->id) ->where('next_date', '=', $dateNow) ->where('active', true) - ->where('status', '=', 2) //abo_okay + ->where('status', '=', 2) // abo_okay ->lockForUpdate() ->first(); - if (!$lockedAbo) { + if (! $lockedAbo) { \Log::channel('abo_order')->warning('UserMakeAboOrder: Abo wurde bereits verarbeitet oder ist nicht mehr aktiv', [ - 'abo_id' => $userAbo->id + 'abo_id' => $userAbo->id, ]); + return null; } @@ -135,8 +139,9 @@ class UserMakeAboOrder extends Command if ($existingOrder) { \Log::channel('abo_order')->info('UserMakeAboOrder: Abo wurde bereits heute verarbeitet', [ 'abo_id' => $lockedAbo->id, - 'existing_order_id' => $existingOrder->shopping_order_id + 'existing_order_id' => $existingOrder->shopping_order_id, ]); + return null; } @@ -146,18 +151,18 @@ class UserMakeAboOrder extends Command if ($shoppingOrder) { \Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt', [ 'abo_id' => $userAbo->id, - 'order_id' => $shoppingOrder->id + 'order_id' => $shoppingOrder->id, ]); $this->info("Bestellung erstellt: {$shoppingOrder->id}"); } else { \Log::channel('abo_order')->warning('UserMakeAboOrder: Keine Bestellung erstellt für Abo', ['abo_id' => $userAbo->id]); $this->warn("Keine Bestellung erstellt für Abo: {$userAbo->id}"); } - } catch (\Exception $e) { + } catch (\Throwable $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Fehler bei der Verarbeitung des Abos', [ 'abo_id' => $userAbo->id, 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + 'trace' => $e->getTraceAsString(), ]); $this->error("Fehler bei Abo {$userAbo->id}: " . $e->getMessage()); } @@ -167,7 +172,7 @@ class UserMakeAboOrder extends Command /** * Erstellt eine Bestellung für ein Abo * - * @param UserAbo $userAbo + * @param UserAbo $userAbo * @return mixed */ private function makeOrder($userAbo) @@ -179,22 +184,24 @@ class UserMakeAboOrder extends Command $userOrder = new UserMakeOrder($userAbo); try { - if (!$userOrder->createShoppingUser()) { + if (! $userOrder->createShoppingUser()) { \Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Shopping-User nicht erstellen', ['abo_id' => $userAbo->id]); $this->error("Konnte Shopping-User für Abo {$userAbo->id} nicht erstellen"); + return null; } $shoppingOrder = $userOrder->makeShoppingOrder(); - if (!$shoppingOrder) { + if (! $shoppingOrder) { \Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Bestellung nicht erstellen', ['abo_id' => $userAbo->id]); $this->error("Konnte Bestellung für Abo {$userAbo->id} nicht erstellen"); + return null; } \Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt, starte Zahlungsvorgang', [ 'abo_id' => $userAbo->id, - 'order_id' => $shoppingOrder->id + 'order_id' => $shoppingOrder->id, ]); $response = $userOrder->makePayment(); @@ -205,17 +212,18 @@ class UserMakeAboOrder extends Command $response = (array) $response; } - if (!isset($response['status'])) { + if (! isset($response['status'])) { \Log::channel('abo_order')->error('UserMakeAboOrder: Ungültige Zahlungsantwort', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, - 'response' => $response + 'response' => $response, ]); $this->error("Ungültige Zahlungsantwort für Abo {$userAbo->id}"); // Bei fehlender Status-Information: Abo nicht aktualisieren, damit es beim nächsten Lauf erneut versucht wird // Aber Bestellung speichern für Nachverfolgung $this->updateAboOnError($userAbo, $shoppingOrder, 'Ungültige Zahlungsantwort - kein Status'); + return $shoppingOrder; } @@ -223,7 +231,7 @@ class UserMakeAboOrder extends Command \Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung erfolgreich', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, - 'response' => $response + 'response' => $response, ]); $this->info("Zahlung erfolgreich für Abo {$userAbo->id}"); // Nur bei erfolgreicher Zahlung: next_date aktualisieren @@ -232,7 +240,7 @@ class UserMakeAboOrder extends Command \Log::channel('abo_order')->error('UserMakeAboOrder: Zahlungsfehler', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, - 'error' => $response + 'error' => $response, ]); $this->error("Zahlungsfehler für Abo {$userAbo->id}"); @@ -263,7 +271,7 @@ class UserMakeAboOrder extends Command \Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung ausstehend/weiterleitung', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, - 'status' => $response['status'] + 'status' => $response['status'], ]); $this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}"); $this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: ' . $response['status']); @@ -272,23 +280,29 @@ class UserMakeAboOrder extends Command \Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, - 'status' => $response['status'] + 'status' => $response['status'], ]); $this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}"); $this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: ' . $response['status']); } - } catch (\Exception $e) { + } catch (\Throwable $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [ 'abo_id' => $userAbo->id, 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + 'trace' => $e->getTraceAsString(), ]); $this->error("Ausnahme bei Abo {$userAbo->id}: " . $e->getMessage()); - // Bei Exception: Bestellung speichern falls vorhanden, aber Abo nicht aktualisieren + // Bestellung existiert (z. B. Fehler bei Payone): Abo-Fehlerstatus, Bestellung bleibt nachvollziehbar if ($shoppingOrder) { $this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: ' . $e->getMessage()); + + return $shoppingOrder; } + + // Noch keine ShoppingOrder (createShoppingUser / makeShoppingOrder): Exception durchreichen, + // sonst ruft der Aufrufer nur "null" ohne Ursache (z. B. Testbench, fehlende country_id im Yard). + throw $e; } return $shoppingOrder; @@ -298,9 +312,9 @@ class UserMakeAboOrder extends Command * Aktualisiert das Abo nach einer erfolgreichen Bestellung * Aktualisiert next_date für den nächsten Abo-Zyklus * - * @param UserAbo $userAbo - * @param mixed $shoppingOrder - * @param int $status + * @param UserAbo $userAbo + * @param mixed $shoppingOrder + * @param int $status * @return void */ private function updateAbo($userAbo, $shoppingOrder, $status = 1) @@ -308,7 +322,7 @@ class UserMakeAboOrder extends Command \Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo nach erfolgreicher Zahlung', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, - 'status' => $status + 'status' => $status, ]); $this->info("Aktualisiere Abo: {$userAbo->id} mit Status {$status}"); @@ -330,18 +344,22 @@ class UserMakeAboOrder extends Command 'user_abo_id' => $userAbo->id, 'shopping_order_id' => $shoppingOrder->id, 'status' => $status, - 'paid' => false, + 'paid' => true, ]); \Log::channel('abo_order')->info('UserMakeAboOrder: Abo erfolgreich aktualisiert', [ 'abo_id' => $userAbo->id, - 'next_date' => $updateData['next_date'] + 'next_date' => $updateData['next_date'], ]); }); + + // Wie bei Payment::paymentStatusPaidAction: Incentive nur wenn Callback nicht lief + // (firstOrCreate verhindert Doppelungen wenn Payone später noch trackt) + IncentiveTracker::trackAboActivated($shoppingOrder); } catch (\Exception $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [ 'abo_id' => $userAbo->id, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); $this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage()); throw $e; // Re-throw für besseres Error-Handling @@ -352,10 +370,10 @@ class UserMakeAboOrder extends Command * Aktualisiert das Abo bei Fehlern - OHNE next_date zu aktualisieren * Damit wird das Abo beim nächsten Cron-Lauf erneut versucht * - * @param UserAbo $userAbo - * @param mixed $shoppingOrder - * @param int|string $status Status-Code oder Fehlermeldung - * @param array|null $errorResponse Optionale Fehlerantwort von Payment + * @param UserAbo $userAbo + * @param mixed $shoppingOrder + * @param int|string $status Status-Code oder Fehlermeldung + * @param array|null $errorResponse Optionale Fehlerantwort von Payment * @return void */ private function updateAboOnError($userAbo, $shoppingOrder, $status, $errorResponse = null) @@ -363,7 +381,7 @@ class UserMakeAboOrder extends Command \Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo bei Fehler (ohne next_date)', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, - 'status' => $status + 'status' => $status, ]); $this->info("Aktualisiere Abo bei Fehler: {$userAbo->id} (Status: {$status})"); @@ -395,13 +413,13 @@ class UserMakeAboOrder extends Command \Log::channel('abo_order')->info('UserMakeAboOrder: Abo bei Fehler aktualisiert (next_date unverändert)', [ 'abo_id' => $userAbo->id, 'next_date' => $userAbo->next_date, - 'status' => $status + 'status' => $status, ]); }); } catch (\Exception $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos bei Fehler', [ 'abo_id' => $userAbo->id, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); $this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage()); // Bei Fehler hier nicht re-throw, damit der Hauptprozess fortgesetzt werden kann diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 69e79de..ceca551 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -44,6 +44,12 @@ class Kernel extends ConsoleKernel $schedule->command('user:cleanup')->dailyAt('03:30'); $schedule->command('user:make_abo_order')->dailyAt('04:00'); + // Abo-Chart-Snapshots: vergangene Monate einfrieren (nach allen Abo-Jobs) + $schedule->command('abo:store-chart-snapshots')->dailyAt('04:30'); + + // Incentive: Punkteberechnung täglich nach business:store-optimized + $schedule->command('incentive:calculate')->dailyAt('05:00'); + // Cleanup old log files weekly (keeps logs for 30 days) $schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00'); diff --git a/app/Cron/UserMakeOrder.php b/app/Cron/UserMakeOrder.php index d4ab3b0..819a9f5 100644 --- a/app/Cron/UserMakeOrder.php +++ b/app/Cron/UserMakeOrder.php @@ -2,41 +2,45 @@ namespace App\Cron; -use Yard; -use App\Models\UserAbo; +use App\Http\Controllers\Pay\PayoneController; use App\Models\ShoppingOrder; use App\Models\ShoppingOrderItem; -use App\Http\Controllers\Pay\PayoneController; +use App\Models\UserAbo; use App\Services\AboOrderCart; use Illuminate\Support\Facades\Log; +use Yard; class UserMakeOrder { private $userAbo; - private $shopping_user; - private $shopping_order; - private $is_for; - private $user; - private $pay; + private $shopping_user; + + private $shopping_order; + + private $is_for; + + private $user; + + private $pay; public function __construct(UserAbo $userAbo) { $this->userAbo = $userAbo; - Log::info('UserMakeOrder initialisiert für UserAbo ID: ' . $userAbo->id); + Log::info('UserMakeOrder initialisiert für UserAbo ID: '.$userAbo->id); } - public function checkProducts() { - Log::info('Überprüfe Produkte für UserAbo ID: ' . $this->userAbo->id); + Log::info('Überprüfe Produkte für UserAbo ID: '.$this->userAbo->id); $ret = []; - if (!$this->userAbo->items || $this->userAbo->items->isEmpty()) { - Log::warning('Keine Artikel für UserAbo ID: ' . $this->userAbo->id . ' gefunden'); + if (! $this->userAbo->items || $this->userAbo->items->isEmpty()) { + Log::warning('Keine Artikel für UserAbo ID: '.$this->userAbo->id.' gefunden'); + return $ret; } - //preise prüfen, ob sie sich geändert haben? + // preise prüfen, ob sie sich geändert haben? foreach ($this->userAbo->items as $item) { $ret[] = [ 'product_id' => $item->product_id, @@ -52,75 +56,79 @@ class UserMakeOrder ]; } - Log::info('Produkte überprüft: ' . count($ret) . ' Artikel gefunden'); + Log::info('Produkte überprüft: '.count($ret).' Artikel gefunden'); + return $ret; } public function makePayment($testmode = false) { - Log::info('Starte Zahlungsvorgang für UserAbo ID: ' . $this->userAbo->id); + Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id); try { - $this->pay = new PayoneController(); + $this->pay = new PayoneController; $this->pay->init($this->shopping_user, $this->shopping_order); - $amount = $this->shopping_order->subtotal_ws * 100; + $amount = $this->shopping_order->total_shipping * 100; + // $amount = Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100; + $this->pay->setAboPayment($this->userAbo, $amount, 'EUR'); $this->pay->setPersonalData(); $response = $this->pay->onlyPaymentResponse(); - \Log::info('Response: ' . json_encode($response)); - //$response = $this->pay->ResponseData(true); + \Log::info('Response: '.json_encode($response)); + // $response = $this->pay->ResponseData(true); + + Log::info('Zahlungsvorgang abgeschlossen für UserAbo ID: '.$this->userAbo->id.', Status: '.($response->status ?? 'unbekannt')); - Log::info('Zahlungsvorgang abgeschlossen für UserAbo ID: ' . $this->userAbo->id . ', Status: ' . ($response->status ?? 'unbekannt')); return $response; } catch (\Exception $e) { - Log::error('Fehler bei Zahlungsvorgang für UserAbo ID: ' . $this->userAbo->id . ': ' . $e->getMessage()); + Log::error('Fehler bei Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id.': '.$e->getMessage()); throw $e; } } public function getShoppingPayment() { - Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: ' . $this->userAbo->id); + Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id); if ($this->pay) { $payment = $this->pay->getShoppingPayment(); - Log::info('Zahlungsinformationen abgerufen: ' . ($payment ? 'erfolgreich' : 'nicht verfügbar')); + Log::info('Zahlungsinformationen abgerufen: '.($payment ? 'erfolgreich' : 'nicht verfügbar')); + return $payment; } - Log::warning('Keine Zahlungsinformationen verfügbar für UserAbo ID: ' . $this->userAbo->id); + Log::warning('Keine Zahlungsinformationen verfügbar für UserAbo ID: '.$this->userAbo->id); + return false; } public function createShoppingUser() { - Log::info('Erstelle Shopping-User für UserAbo ID: ' . $this->userAbo->id); - //hier muss der letzte shopping_user verwendet werden + Log::info('Erstelle Shopping-User für UserAbo ID: '.$this->userAbo->id); + // hier muss der letzte shopping_user verwendet werden try { $this->shopping_user = AboOrderCart::makeCustomerDetail($this->userAbo); $this->shopping_user->created_at = now(); $this->shopping_user->updated_at = now(); $this->shopping_user->save(); - Log::info('Shopping-User erstellt für UserAbo ID: ' . $this->userAbo->id . ', Neue User-ID: ' . $this->shopping_user->id); + Log::info('Shopping-User erstellt für UserAbo ID: '.$this->userAbo->id.', Neue User-ID: '.$this->shopping_user->id); + return $this->shopping_user; - } catch (\Exception $e) { - Log::error('Fehler beim Erstellen des Shopping-Users für UserAbo ID: ' . $this->userAbo->id . ': ' . $e->getMessage()); + } catch (\Throwable $e) { + Log::error('Fehler beim Erstellen des Shopping-Users für UserAbo ID: '.$this->userAbo->id.': '.$e->getMessage()); throw $e; } - - - Log::warning('Kein Shopping-User verfügbar für UserAbo ID: ' . $this->userAbo->id); - return false; } public function makeShoppingOrder() { - Log::info('Erstelle Bestellung für UserAbo ID: ' . $this->userAbo->id); + Log::info('Erstelle Bestellung für UserAbo ID: '.$this->userAbo->id); try { - if (!$this->shopping_user) { - Log::error('Kein Shopping-User verfügbar für Bestellerstellung, UserAbo ID: ' . $this->userAbo->id); + if (! $this->shopping_user) { + Log::error('Kein Shopping-User verfügbar für Bestellerstellung, UserAbo ID: '.$this->userAbo->id); + return false; } @@ -135,18 +143,18 @@ class UserMakeOrder $yardBefore = Yard::instance('shopping'); $itemsBefore = $yardBefore->content(); if ($itemsBefore->count() > 0) { - Log::warning('UserMakeOrder: Yard war nicht leer nach initYard für Abo ID: ' . $this->userAbo->id . ', Items: ' . $itemsBefore->count()); + Log::warning('UserMakeOrder: Yard war nicht leer nach initYard für Abo ID: '.$this->userAbo->id.', Items: '.$itemsBefore->count()); $yardBefore->destroy(); // Erzwinge Leerung } - //hier wird die Bestellung erstellt inkl aktueller Preise + // hier wird die Bestellung erstellt inkl aktueller Preise AboOrderCart::makeOrderYard($this->userAbo); $yard = Yard::instance('shopping'); // Debug: Logge welche Produkte im Cart sind $items = $yard->content(); - Log::info('UserMakeOrder: Produkte im Cart nach makeOrderYard für Abo ID: ' . $this->userAbo->id, [ + Log::info('UserMakeOrder: Produkte im Cart nach makeOrderYard für Abo ID: '.$this->userAbo->id, [ 'abo_id' => $this->userAbo->id, 'item_count' => $items->count(), 'items' => $items->map(function ($item) { @@ -154,26 +162,57 @@ class UserMakeOrder 'product_id' => $item->id, 'name' => $item->name, 'qty' => $item->qty, - 'rowId' => $item->rowId + 'rowId' => $item->rowId, ]; - })->toArray() + })->toArray(), ]); - if (!$this->userAbo->shopping_user || !$this->userAbo->shopping_user->shopping_order || !$this->userAbo->shopping_user->shopping_order->user_shop) { - Log::error('Fehlende Beziehungsdaten für Bestellerstellung, UserAbo ID: ' . $this->userAbo->id); + $shoppingUserStamm = $this->userAbo->shopping_user; + if (! $shoppingUserStamm) { + Log::error('UserAbo ohne shopping_user (Stammdaten-Kunde), UserAbo ID: '.$this->userAbo->id); + + return false; + } + + // Referenz fuer Shop/Mode: neueste Bestellung (hasOne shopping_order kann null sein z. B. wenn die + // aelteste Order soft-deleted ist oder mehrere Orders existieren). + $referenceOrder = $shoppingUserStamm->shopping_orders() + ->orderByDesc('id') + ->first(); + + if (! $referenceOrder || ! $referenceOrder->user_shop_id) { + Log::error('Fehlende Beziehungsdaten fuer Bestellerstellung (Referenz-Bestellung ohne user_shop_id), UserAbo ID: '.$this->userAbo->id, [ + 'shopping_user_id' => $shoppingUserStamm->id, + 'reference_order_id' => $referenceOrder?->id, + ]); + + return false; + } + + $countryId = $yard->getShippingCountryId() ?? $referenceOrder->country_id; + if (! $countryId) { + Log::error('Kein country_id (Yard shipping_country_id und Referenz-Bestellung leer), UserAbo ID: '.$this->userAbo->id, [ + 'yard_shipping_country_id' => $yard->getShippingCountryId(), + 'reference_order_id' => $referenceOrder->id, + 'reference_country_id' => $referenceOrder->country_id, + ]); + return false; } $this->shopping_order = ShoppingOrder::create([ 'shopping_user_id' => $this->shopping_user->id, - 'auth_user_id' => $this->shopping_user->auth_user_id, - 'country_id' => $yard->getShippingCountryId(), + 'member_id' => $this->userAbo->member_id ?? $referenceOrder->member_id, + 'auth_user_id' => $this->userAbo->is_for === 'me' + ? ($this->userAbo->user_id ?? $referenceOrder->auth_user_id ?? $this->shopping_user->auth_user_id) + : ($this->shopping_user->auth_user_id ?? $referenceOrder->auth_user_id), + 'country_id' => $countryId, 'language' => \App::getLocale(), - 'user_shop_id' => $this->userAbo->shopping_user->shopping_order->user_shop->id, + 'user_shop_id' => (int) $referenceOrder->user_shop_id, 'payment_for' => $this->shopping_user->getOrderPaymentFor(), 'total' => $yard->total(2, '.', ''), 'subtotal' => $yard->subtotal(2, '.', ''), - 'shipping' => $yard->shipping(2, '.', ','), + 'shipping' => $yard->shipping(2, '.', ''), 'shipping_net' => $yard->shippingNet(2, '.', ''), 'subtotal_ws' => $yard->subtotalWithShipping(2, '.', ''), 'tax' => $yard->taxWithShipping(2, '.', ''), @@ -183,21 +222,21 @@ class UserMakeOrder 'is_abo' => 1, 'abo_interval' => $this->userAbo->abo_interval ?? 0, 'txaction' => 'prev', - 'mode' => $this->userAbo->shopping_user->shopping_order->mode, + 'mode' => $referenceOrder->mode, ]); - Log::info('Bestellung erstellt für UserAbo ID: ' . $this->userAbo->id . ', Bestellnummer: ' . $this->shopping_order->id); + Log::info('Bestellung erstellt für UserAbo ID: '.$this->userAbo->id.', Bestellnummer: '.$this->shopping_order->id); $items = $yard->getContentByOrder(); $itemCount = 0; foreach ($items as $item) { - if (!ShoppingOrderItem::where('shopping_order_id', $this->shopping_order->id)->where('row_id', $item->rowId)->count()) { + if (! ShoppingOrderItem::where('shopping_order_id', $this->shopping_order->id)->where('row_id', $item->rowId)->count()) { $price_net = $yard->rowPriceNet($item, 2, '.', ''); $tax = $item->price - $price_net; $data = [ 'shopping_order_id' => $this->shopping_order->id, - 'row_id' => $item->rowId, + 'row_id' => $item->rowId, 'product_id' => $item->id, 'comp' => $item->options->comp, 'qty' => $item->qty, @@ -208,21 +247,21 @@ class UserMakeOrder 'price_vk_net' => $this->shopping_order->getPriceVkNetBy($item->id), 'discount' => $item->options->no_commission ? 0 : $this->shopping_order->getUserDiscount(), 'points' => $item->options->points, - 'slug' => $item->options->slug + 'slug' => $item->options->slug, ]; ShoppingOrderItem::create($data); $itemCount++; } } - Log::info('Bestellpositionen hinzugefügt für UserAbo ID: ' . $this->userAbo->id . ', Anzahl: ' . $itemCount); + Log::info('Bestellpositionen hinzugefügt für UserAbo ID: '.$this->userAbo->id.', Anzahl: '.$itemCount); $this->shopping_order->makeTaxSplit(); - Log::info('Steueraufteilung für Bestellung abgeschlossen, UserAbo ID: ' . $this->userAbo->id); + Log::info('Steueraufteilung für Bestellung abgeschlossen, UserAbo ID: '.$this->userAbo->id); return $this->shopping_order; - } catch (\Exception $e) { - Log::error('Fehler bei Bestellerstellung für UserAbo ID: ' . $this->userAbo->id . ': ' . $e->getMessage()); + } catch (\Throwable $e) { + Log::error('Fehler bei Bestellerstellung für UserAbo ID: '.$this->userAbo->id.': '.$e->getMessage()); throw $e; } } diff --git a/app/Http/Controllers/Admin/IncentiveController.php b/app/Http/Controllers/Admin/IncentiveController.php new file mode 100644 index 0000000..8104322 --- /dev/null +++ b/app/Http/Controllers/Admin/IncentiveController.php @@ -0,0 +1,411 @@ +middleware('admin'); + } + + public function index() + { + return view('admin.incentive.index'); + } + + public function create() + { + return view('admin.incentive.create', [ + 'languages' => config('localization.supportedLocales'), + ]); + } + + public function store() + { + $data = Request::validate([ + 'name' => 'required|string|max:255', + 'subtitle' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'nullable|string|max:255', + 'terms' => 'nullable|string', + 'qualification_start' => 'required|date', + 'qualification_end' => 'required|date|after_or_equal:qualification_start', + 'calculation_end' => 'required|date|after_or_equal:qualification_end', + 'points_partner_onetime' => 'required|integer|min:0', + 'points_abo_onetime' => 'required|integer|min:0', + 'min_direct_partners' => 'required|integer|min:0', + 'min_customer_abos' => 'required|integer|min:0', + 'max_winners' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1,2', + ]); + + $data = array_merge($data, $this->extractTranslations()); + + Incentive::create($data); + + \Session()->flash('alert-success', __('incentive.created')); + + return redirect(route('admin_incentives')); + } + + public function show($id) + { + $incentive = Incentive::findOrFail($id); + $participants = IncentiveParticipant::where('incentive_id', $incentive->id) + ->with('user', 'user.account') + ->orderByIncentiveLeaderboard() + ->get(); + + return view('admin.incentive.show', [ + 'incentive' => $incentive, + 'participants' => $participants, + ]); + } + + public function edit($id) + { + $incentive = Incentive::findOrFail($id); + + return view('admin.incentive.edit', [ + 'incentive' => $incentive, + 'languages' => config('localization.supportedLocales'), + ]); + } + + public function update($id) + { + $data = Request::validate([ + 'name' => 'required|string|max:255', + 'subtitle' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'nullable|string|max:255', + 'terms' => 'nullable|string', + 'qualification_start' => 'required|date', + 'qualification_end' => 'required|date|after_or_equal:qualification_start', + 'calculation_end' => 'required|date|after_or_equal:qualification_end', + 'points_partner_onetime' => 'required|integer|min:0', + 'points_abo_onetime' => 'required|integer|min:0', + 'min_direct_partners' => 'required|integer|min:0', + 'min_customer_abos' => 'required|integer|min:0', + 'max_winners' => 'required|integer|min:1', + 'status' => 'required|integer|in:0,1,2', + ]); + + $data = array_merge($data, $this->extractTranslations()); + + $incentive = Incentive::findOrFail($id); + $incentive->update($data); + + \Session()->flash('alert-success', __('incentive.updated')); + + return redirect(route('admin_incentive_show', [$id])); + } + + /** + * @return array{trans_name: array, trans_description: array, trans_terms: array} + */ + private function extractTranslations(): array + { + $transName = []; + $transDescription = []; + $transTerms = []; + + $transSubtitle = []; + + foreach (config('localization.supportedLocales') as $locale => $localeData) { + if ($locale !== 'de') { + $transName[$locale] = Request::get('trans_name_'.$locale, ''); + $transSubtitle[$locale] = Request::get('trans_subtitle_'.$locale, ''); + $transDescription[$locale] = Request::get('trans_description_'.$locale, ''); + $transTerms[$locale] = Request::get('trans_terms_'.$locale, ''); + } + } + + return [ + 'trans_name' => $transName, + 'trans_subtitle' => $transSubtitle, + 'trans_description' => $transDescription, + 'trans_terms' => $transTerms, + ]; + } + + public function recalculate($id) + { + $incentive = Incentive::findOrFail($id); + $service = new IncentiveCalculationService; + $stats = $service->recalculate($incentive, Request::has('force')); + + \Session()->flash('alert-success', __('incentive.recalculated', [ + 'participants' => $stats['participants'], + 'errors' => $stats['errors'], + ])); + + return redirect(route('admin_incentive_show', [$id])); + } + + public function participantDetails($participant_id) + { + $participant = IncentiveParticipant::with('incentive', 'user', 'user.account') + ->findOrFail($participant_id); + + $data = self::buildParticipantDetailData($participant); + + return view('admin.incentive._participant_details', $data); + } + + /** + * Baut die Detail-Daten fuer einen Teilnehmer auf. + * Wird von Admin und User Controller genutzt. + */ + public static function buildParticipantDetailData(IncentiveParticipant $participant): array + { + $incentive = $participant->incentive; + $calculation_months = $incentive->getCalculationMonths(); + + // Alle Logs dieses Teilnehmers (ohne Stornos) + $all_logs = IncentivePointsLog::where('participant_id', $participant->id) + ->where('is_storno', false) + ->with('salesVolume') + ->orderBy('created_at') + ->get(); + + // UserSalesVolume-IDs -> User-ID Mapping aufbauen (fuer akkumulierte Partner-Punkte) + $sv_ids = $all_logs->whereNotNull('user_sales_volume_id')->pluck('user_sales_volume_id')->unique()->toArray(); + $sv_user_map = ! empty($sv_ids) + ? UserSalesVolume::whereIn('id', $sv_ids)->pluck('user_id', 'id')->toArray() + : []; + + // Partner aus Tracking-Tabelle + $new_partners = IncentiveNewPartner::where('participant_id', $participant->id) + ->with('user', 'user.account') + ->orderBy('registered_at') + ->get(); + + $partner_logs = $all_logs->where('type', 'partner'); + + $partner_sources = $new_partners->map(function ($np) use ($partner_logs, $sv_user_map, $calculation_months, $incentive) { + $monthly = []; + $transactions = []; + + foreach ($calculation_months as $period) { + // Akkumulierte Logs: source_type=UserSalesVolume, deren USV.user_id == partner user_id + $month_logs = $partner_logs + ->where('month', $period['month']) + ->where('year', $period['year']) + ->filter(function ($log) use ($np, $sv_user_map) { + if ($log->source_type === User::class) { + return false; // Einmal-Punkte nicht in Monatsspalte + } + + if ($log->incentive_new_partner_id) { + return (int) $log->incentive_new_partner_id === (int) $np->id; + } + + // Legacy: USV.user_id muss zum Partner gehoeren + return isset($sv_user_map[$log->user_sales_volume_id]) + && $sv_user_map[$log->user_sales_volume_id] === $np->user_id; + }); + + $month_points = (int) $month_logs->sum('points_accumulated'); + $monthly[] = $month_points; + + foreach ($month_logs as $log) { + $transactions[] = [ + 'date' => $log->created_at->format('d.m.Y'), + 'month' => $period['month'], + 'year' => $period['year'], + 'label' => $log->source_label ?: 'KP #'.($log->user_sales_volume_id ?? $log->source_id), + 'points' => $log->points_accumulated, + 'type' => 'accumulated', + ]; + } + } + + // Einmal-Punkte als Transaktion hinzufuegen + $onetime_log = $partner_logs + ->where('source_type', User::class) + ->first(function ($log) use ($np) { + if ($log->incentive_new_partner_id) { + return (int) $log->incentive_new_partner_id === (int) $np->id; + } + + return (int) $log->source_id === (int) $np->user_id; + }); + + if ($onetime_log) { + array_unshift($transactions, [ + 'date' => $onetime_log->created_at->format('d.m.Y'), + 'month' => $onetime_log->month, + 'year' => $onetime_log->year, + 'label' => __('incentive.onetime_registration'), + 'points' => $onetime_log->points_onetime, + 'type' => 'onetime', + ]); + } + + return [ + 'id' => $np->id, + 'label' => $np->user ? ($np->user->getFullName() ?: $np->user->email ?: 'User #'.$np->user_id) : 'User #'.$np->user_id, + 'month' => $np->registered_at->month, + 'year' => $np->registered_at->year, + 'onetime' => $incentive->points_partner_onetime, + 'monthly' => $monthly, + 'total' => $incentive->points_partner_onetime + array_sum($monthly), + 'transactions' => $transactions, + ]; + }); + + // Abos aus Tracking-Tabelle + $new_abos = IncentiveNewAbo::where('participant_id', $participant->id) + ->with('userAbo') + ->orderBy('activated_at') + ->get(); + + $abo_logs = $all_logs->where('type', 'abo'); + + // Legacy-Fallback: USV -> user_abo_id (Logs ohne incentive_new_abo_id) + $sv_user_abo_map = []; + $needs_legacy_abo_map = $abo_logs->whereNull('incentive_new_abo_id')->whereNotNull('user_sales_volume_id')->isNotEmpty(); + if ($needs_legacy_abo_map && ! empty($sv_ids)) { + $sv_rows = UserSalesVolume::query() + ->whereIn('id', $sv_ids) + ->whereNotNull('shopping_order_id') + ->get(['id', 'shopping_order_id']); + $order_ids = $sv_rows->pluck('shopping_order_id')->unique()->filter()->values(); + if ($order_ids->isNotEmpty()) { + $user_abo_id_by_order_id = UserAboOrder::query() + ->whereIn('shopping_order_id', $order_ids) + ->get(['shopping_order_id', 'user_abo_id']) + ->keyBy('shopping_order_id'); + foreach ($sv_rows as $sv) { + $link = $user_abo_id_by_order_id->get($sv->shopping_order_id); + if ($link) { + $sv_user_abo_map[$sv->id] = (int) $link->user_abo_id; + } + } + } + } + + $abo_sources = $new_abos->map(function ($na) use ($abo_logs, $sv_user_abo_map, $calculation_months, $incentive) { + $monthly = []; + $transactions = []; + $tracked_user_abo_id = (int) $na->user_abo_id; + + foreach ($calculation_months as $period) { + $month_logs = $abo_logs + ->where('month', $period['month']) + ->where('year', $period['year']) + ->filter(function ($log) use ($na, $tracked_user_abo_id, $sv_user_abo_map) { + if ($log->source_type !== UserSalesVolume::class) { + return false; + } + + if ($log->incentive_new_abo_id) { + return (int) $log->incentive_new_abo_id === (int) $na->id; + } + + $usv_id = $log->user_sales_volume_id; + if (! $usv_id || ! isset($sv_user_abo_map[$usv_id])) { + return false; + } + + return $sv_user_abo_map[$usv_id] === $tracked_user_abo_id; + }); + + $month_points = (int) $month_logs->sum('points_accumulated'); + $monthly[] = $month_points; + + foreach ($month_logs as $log) { + $transactions[] = [ + 'date' => $log->created_at->format('d.m.Y'), + 'month' => $period['month'], + 'year' => $period['year'], + 'label' => $log->source_label ?: 'SV #'.($log->user_sales_volume_id ?? $log->source_id), + 'points' => $log->points_accumulated, + 'type' => 'accumulated', + ]; + } + } + + // Einmal-Punkte als Transaktion + $onetime_log = $abo_logs + ->where('source_type', '!=', UserSalesVolume::class) + ->first(function ($log) use ($na) { + if ($log->incentive_new_abo_id) { + return (int) $log->incentive_new_abo_id === (int) $na->id; + } + + return (int) $log->source_id === (int) $na->user_abo_id; + }); + + if ($onetime_log) { + array_unshift($transactions, [ + 'date' => $onetime_log->created_at->format('d.m.Y'), + 'month' => $onetime_log->month, + 'year' => $onetime_log->year, + 'label' => __('incentive.onetime_abo_activation'), + 'points' => $onetime_log->points_onetime, + 'type' => 'onetime', + ]); + } + + $label = $na->userAbo?->email ?: ('Abo #'.$na->user_abo_id); + + return [ + 'id' => $na->id, + 'label' => $label, + 'month' => $na->activated_at->month, + 'year' => $na->activated_at->year, + 'onetime' => $incentive->points_abo_onetime, + 'monthly' => $monthly, + 'total' => $incentive->points_abo_onetime + array_sum($monthly), + 'transactions' => $transactions, + ]; + }); + + return [ + 'incentive' => $incentive, + 'participant' => $participant, + 'calculation_months' => $calculation_months, + 'partner_sources' => $partner_sources, + 'abo_sources' => $abo_sources, + ]; + } + + public function datatable() + { + $query = Incentive::query()->select('incentives.*'); + + return \DataTables::eloquent($query) + ->addColumn('action', function (Incentive $incentive) { + return ' + '; + }) + ->addColumn('status_label', function (Incentive $incentive) { + return ''.$incentive->getStatusType().''; + }) + ->addColumn('period', function (Incentive $incentive) { + return $incentive->qualification_start->format('d.m.Y').' - '.$incentive->qualification_end->format('d.m.Y'); + }) + ->addColumn('participants_count', function (Incentive $incentive) { + return $incentive->participants()->count(); + }) + ->orderColumn('name', 'name $1') + ->orderColumn('status_label', 'status $1') + ->rawColumns(['action', 'status_label']) + ->make(true); + } +} diff --git a/app/Http/Controllers/Api/PayoneController.php b/app/Http/Controllers/Api/PayoneController.php index 58e0120..bfa5c5a 100644 --- a/app/Http/Controllers/Api/PayoneController.php +++ b/app/Http/Controllers/Api/PayoneController.php @@ -105,29 +105,39 @@ class PayoneController extends Controller echo 'TSOK'; exit; } - - /* TODO -- need this? */ + /* + * Payone sendet dieselbe txaction oft mehrfach (v. a. "appointed"). War der Status + * bereits auf ShoppingPayment gespeichert, ist das ein Duplikat: TSOK, keine Doppel-Verarbeitung. + * Ausnahme: erneutes "paid", obwohl die Bestellung noch nicht als bezahlt gefuehrt wird (Recovery). + */ if ($shopping_payment->txaction == $data['txaction']) { - if ($data['txaction'] === 'paid' && $shopping_order->txaction === 'paid') { MyLog::writeLog( 'payone', - 'error', - 'Error:2007 App\Http\Controllers\Api\PayoneController::paymentStatus same txaction - was already paid', + 'notice', + 'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (already paid)', $data, false ); - // was already paid echo 'TSOK'; exit; - } else { + } + + if (in_array($data['txaction'], ['appointed', 'failed', 'pending'], true)) { MyLog::writeLog( 'payone', - 'error', - 'Error:2007 App\Http\Controllers\Api\PayoneController::paymentStatus same txaction - show', - $data, + 'info', + 'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (same txaction)', + [ + 'reference' => $data['reference'] ?? null, + 'param' => $data['param'] ?? null, + 'txaction' => $data['txaction'], + 'txid' => $data['txid'] ?? null, + ], false ); + echo 'TSOK'; + exit; } } @@ -191,7 +201,6 @@ class PayoneController extends Controller $locked_order = ShoppingOrder::where('id', $shopping_order->id) ->lockForUpdate() ->first(); - // Double-check if payment was already processed if (! $locked_order->paid) { $send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment); @@ -211,6 +220,7 @@ class PayoneController extends Controller throw $e; } } + $data['send_link'] = $send_link; if ($send_mail) { Payment::paymentStatusSendMail($shopping_order, $shopping_payment, $data); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 13ad310..6f35000 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,6 +2,9 @@ namespace App\Http\Controllers; +use App\Models\DashboardNews; +use App\Models\Incentive; +use App\Models\IncentiveParticipant; use App\Models\ShoppingPayment; use App\User; use Carbon\Carbon; @@ -20,6 +23,7 @@ class HomeController extends Controller public function index() { + if (! Auth::check()) { return redirect('login'); } @@ -43,10 +47,26 @@ class HomeController extends Controller return redirect('login'); } + $user = Auth::user(); + + $activeIncentive = null; + $incentiveParticipant = null; + + if ($user->isActiveAccount()) { + $activeIncentive = Incentive::active()->first(); + if ($activeIncentive) { + $incentiveParticipant = IncentiveParticipant::where('incentive_id', $activeIncentive->id) + ->where('user_id', $user->id) + ->first(); + } + } + $data = [ - 'user' => Auth::user(), + 'user' => $user, 'now' => Carbon::now(), - 'dashboardNews' => \App\Models\DashboardNews::getActiveNews(), + 'dashboardNews' => DashboardNews::getActiveNews(), + 'activeIncentive' => $activeIncentive, + 'incentiveParticipant' => $incentiveParticipant, ]; return view('home', $data); diff --git a/app/Http/Controllers/PaymentCreditController.php b/app/Http/Controllers/PaymentCreditController.php index 39c9fd7..ef36314 100644 --- a/app/Http/Controllers/PaymentCreditController.php +++ b/app/Http/Controllers/PaymentCreditController.php @@ -11,6 +11,7 @@ use App\Services\Payment; use App\Services\Util; use App\User; use Carbon; +use Illuminate\Http\JsonResponse; use Request; class PaymentCreditController extends Controller @@ -183,6 +184,36 @@ class PaymentCreditController extends Controller return $query; } + public function stats(): JsonResponse + { + $this->setFilterVars(); + + $month = Request::get('credit_filter_month', session('credit_filter_month')); + $year = Request::get('credit_filter_year', session('credit_filter_year')); + $name = Request::get('credit_filter_name', ''); + + $dateStart = Carbon::parse('01.'.$month.'.'.$year)->format('Y-m-d'); + $dateEnd = Carbon::parse('01.'.$month.'.'.$year)->endOfMonth()->format('Y-m-d'); + + $baseQuery = UserCredit::query() + ->whereBetween('date', [$dateStart, $dateEnd]); + + if ($name) { + $baseQuery->whereHas('user.account', function ($query) use ($name) { + $query->where('first_name', 'LIKE', '%'.$name.'%') + ->orWhere('last_name', 'LIKE', '%'.$name.'%'); + }); + } + + $count = (clone $baseQuery)->count(); + $total = (clone $baseQuery)->sum('total'); + + return response()->json([ + 'count' => $count, + 'total' => number_format((float) $total, 2, ',', '.'), + ]); + } + public function datatable() { diff --git a/app/Http/Controllers/PaymentInvoiceController.php b/app/Http/Controllers/PaymentInvoiceController.php index f4fffe8..9b8e91e 100644 --- a/app/Http/Controllers/PaymentInvoiceController.php +++ b/app/Http/Controllers/PaymentInvoiceController.php @@ -1,18 +1,15 @@ middleware('admin'); @@ -26,16 +23,17 @@ class PaymentInvoiceController extends Controller 'filter_months' => HTMLHelper::getTransMonths(), 'filter_years' => HTMLHelper::getYearRange(), ]; + return view('admin.payment.invoice', $data); } private function setFilterVars() { - if (!session('invoice_filter_month')) { + if (! session('invoice_filter_month')) { session(['invoice_filter_month' => intval(date('m'))]); } - if (!session('invoice_filter_year')) { + if (! session('invoice_filter_year')) { session(['invoice_filter_year' => intval(date('Y'))]); } if (Request::get('invoice_filter_name')) { @@ -61,12 +59,44 @@ class PaymentInvoiceController extends Controller if (Request::get('invoice_filter_name')) { $query->whereHas('shopping_order.shopping_user', function ($query) { - return $query->where('billing_firstname', 'LIKE', '%' . Request::get('invoice_filter_name') . '%')->orWhere('billing_lastname', 'LIKE', '%' . Request::get('invoice_filter_name') . '%')->orWhere('billing_email', 'LIKE', '%' . Request::get('invoice_filter_name') . '%'); + return $query->where('billing_firstname', 'LIKE', '%'.Request::get('invoice_filter_name').'%')->orWhere('billing_lastname', 'LIKE', '%'.Request::get('invoice_filter_name').'%')->orWhere('billing_email', 'LIKE', '%'.Request::get('invoice_filter_name').'%'); })->get(); } + return $query; } + public function stats(): JsonResponse + { + $this->setFilterVars(); + + $month = Request::get('invoice_filter_month', session('invoice_filter_month')); + $year = Request::get('invoice_filter_year', session('invoice_filter_year')); + $name = Request::get('invoice_filter_name', ''); + + $baseQuery = UserInvoice::query() + ->where('user_invoices.month', $month) + ->where('user_invoices.year', $year); + + if ($name) { + $baseQuery->whereHas('shopping_order.shopping_user', function ($query) use ($name) { + $query->where('billing_firstname', 'LIKE', '%'.$name.'%') + ->orWhere('billing_lastname', 'LIKE', '%'.$name.'%') + ->orWhere('billing_email', 'LIKE', '%'.$name.'%'); + }); + } + + $count = (clone $baseQuery)->count(); + $total = (clone $baseQuery) + ->join('shopping_orders', 'shopping_orders.id', '=', 'user_invoices.shopping_order_id') + ->sum('shopping_orders.total_shipping'); + + return response()->json([ + 'count' => $count, + 'total' => number_format((float) $total, 2, ',', '.'), + ]); + } + public function datatable() { @@ -75,32 +105,35 @@ class PaymentInvoiceController extends Controller return \DataTables::eloquent($query) ->addColumn('id', function (UserInvoice $UserInvoice) { if ($UserInvoice->shopping_order->auth_user_id) { - return ''; + return ''; } - return ''; + + return ''; }) ->addColumn('total_shipping', function (UserInvoice $UserInvoice) { - return '' . $UserInvoice->shopping_order->getFormattedTotalShipping() . " €"; + return ''.$UserInvoice->shopping_order->getFormattedTotalShipping().' €'; }) ->addColumn('created_at', function (UserInvoice $UserInvoice) { - return $UserInvoice->created_at->format("d.m.Y"); + return $UserInvoice->created_at->format('d.m.Y'); }) ->addColumn('txaction', function (UserInvoice $UserInvoice) { if ($UserInvoice->shopping_order) { return Payment::getShoppingOrderBadge($UserInvoice->shopping_order); } - return "-"; + + return '-'; }) ->addColumn('status', function (UserInvoice $UserInvoice) { return ' - ' . $UserInvoice->getStatusType() . ' + data-id="'.$UserInvoice->id.'" data-route="'.route('modal_load').'" data-action="user-credit-status" data-view=""> + '.$UserInvoice->getStatusType().' '; }) ->addColumn('invoice', function (UserInvoice $UserInvoice) { - $ret = ""; - $ret .= ' '; - $ret .= ''; + $ret = ''; + $ret .= ' '; + $ret .= ''; + return $ret; }) ->orderColumn('id', 'id $1') diff --git a/app/Http/Controllers/Portal/AboController.php b/app/Http/Controllers/Portal/AboController.php index 6056951..bf00b00 100644 --- a/app/Http/Controllers/Portal/AboController.php +++ b/app/Http/Controllers/Portal/AboController.php @@ -403,16 +403,20 @@ class AboController extends Controller $data['step'] = 4; break; case 5: - // chekout verarbeiten UserService::setInstance($this->instance); UserService::initCustomerYard($shopping_user, 'abo-ot-customer'); if (Request::get('action') == 'checkout') { - // checkout verarbeiten - if (! $this->preCheckCheckout()) { + if (! Request::boolean('abo_order_info_checkbox')) { + $data['error'] = __('abo.abo_order_info_checkbox_required'); + $data['step'] = 4; + } elseif (! in_array((int) Request::input('abo_interval'), UserAbo::$aboDeliveryDays, true)) { + $data['error'] = __('abo.error_abo_interval'); + $data['step'] = 4; + } elseif (! $this->preCheckCheckout()) { $data['error'] = __('abo.abo_error_basis_product'); $data['step'] = 4; } else { - $data['checkout_url'] = $this->processCheckout(); + $data['checkout_url'] = $this->processCheckout($shopping_user); } } $data['step'] = 4; @@ -439,18 +443,9 @@ class AboController extends Controller Shop::initUserShopLang($delivery_country, $this->instance); } - private function preCheckCheckout() + private function preCheckCheckout(): bool { - $result = false; - // alle inhlate des warenkorb - $cartItems = $this->yard->content(); - foreach ($cartItems as $item) { - if (in_array(12, $item->options->show_on)) { - $result = true; - } - } - - return $result; + return AboHelper::aboHasBaseProduct($this->yard->getContentByOrder()); } private function checkBasisProduct() @@ -550,7 +545,7 @@ class AboController extends Controller $this->yard->reCalculateShippingPrice(); } - private function processCheckout() + private function processCheckout(ShoppingUser $shoppingUser): string { $user_shop = Util::getUserShop(); if (! $user_shop) { @@ -560,24 +555,38 @@ class AboController extends Controller $identifier = Util::getToken(); } while (ShoppingInstance::where('identifier', $identifier)->count()); - $data = []; - $data['is_from'] = 'shopping'; - $data['user_price_infos'] = $this->yard->getUserPriceInfos(); + $aboInterval = (int) Request::input('abo_interval', 0); + + $fillable = (new ShoppingUser)->getFillable(); + $shoppingData = array_merge( + array_intersect_key($shoppingUser->getAttributes(), array_flip($fillable)), + [ + 'shopping_user_id' => $shoppingUser->id, + 'is_from' => 'shopping', + 'is_for' => 'abo-ot-customer', + 'is_abo' => true, + 'abo_interval' => $aboInterval, + 'shipping_is_for' => 'abo-ot-customer', + 'user_price_infos' => $this->yard->getUserPriceInfos(), + 'mode' => config('app.mode') === 'test' ? 'test' : 'live', + ] + ); ShoppingInstance::create([ 'identifier' => $identifier, 'user_shop_id' => $user_shop->id, - 'payment' => 1, // Customer Shop Payment + 'payment' => 1, 'subdomain' => url('/'), 'country_id' => $this->yard->getShippingCountryId(), - 'language' => \App::getLocale(), - 'shopping_data' => $data, + 'language' => $shoppingUser->getLocale(), + 'amount' => (float) $this->yard->totalWithShipping(2, '.', ''), + 'shopping_user_id' => $shoppingUser->id, + 'shopping_data' => $shoppingData, 'back' => url()->previous(), - ]); $this->yard->store($identifier); - // add to DB + $path = route('checkout.checkout_card', ['identifier' => $identifier]); if (strpos($path, 'https') === false) { $path = str_replace('http', 'https', $path); diff --git a/app/Http/Controllers/Portal/OrderController.php b/app/Http/Controllers/Portal/OrderController.php index 4145c81..ea7e042 100644 --- a/app/Http/Controllers/Portal/OrderController.php +++ b/app/Http/Controllers/Portal/OrderController.php @@ -8,9 +8,11 @@ use App\Models\ShoppingOrder; use App\Models\ShoppingPayment; use App\Models\ShoppingUser; use App\Services\Payment; +use App\Services\ProductOrderContext; use App\Services\Shop; use App\Services\Util; use Auth; +use Illuminate\Support\Facades\Session; use Yard; class OrderController extends Controller @@ -192,7 +194,7 @@ class OrderController extends Controller public function myOrderCreate(int $id) { $user = Auth::guard('customers')->user(); - $shopping_order = ShoppingOrder::findOrFail($id); + $shopping_order = ShoppingOrder::with('member.shop')->findOrFail($id); if ($shopping_order->shopping_user_id != $user->shopping_user_id) { $shopping_user = ShoppingUser::findOrFail($user->shopping_user_id); @@ -202,6 +204,13 @@ class OrderController extends Controller } $shopping_user = ShoppingUser::findOrFail($user->shopping_user_id); + + if ($shopping_order->is_abo) { + Session::flash('alert-error', __('order.reorder_abo_not_allowed')); + + return redirect()->route('portal.my_orders.show', $shopping_order->id); + } + $delivery_country = $shopping_user->getDeliveryCountry(true); \Session::put('user_init_country', strtolower($delivery_country->code)); @@ -211,18 +220,18 @@ class OrderController extends Controller Shop::initUserShopLang($delivery_country, $this->instance); foreach ($shopping_order->shopping_order_items as $item) { - if ($item->product) { + if ($item->product && ProductOrderContext::isProductAllowedInCustomerWebshop($item->product)) { $this->addToCart($item->product_id, $item->qty); } } - return redirect(Util::getMyMivitaShopUrl('/user/card/show')); + return redirect(Util::getCustomerReorderCartUrl($shopping_order)); } private function addToCart(int $productId, int $quantity = 1): void { $product = Product::find($productId); - if (! $product) { + if (! $product || ! ProductOrderContext::isProductAllowedInCustomerWebshop($product)) { return; } diff --git a/app/Http/Controllers/SAdmin/SAdminController.php b/app/Http/Controllers/SAdmin/SAdminController.php new file mode 100644 index 0000000..b40fa5c --- /dev/null +++ b/app/Http/Controllers/SAdmin/SAdminController.php @@ -0,0 +1,42 @@ +middleware('superadmin'); + } + + public function index() + { + return view('sys.index'); + } + + public function tool($serve) + { + switch ($serve) { + + case 'abo_orders_overview': + return AboOrdersOverview::show(); + break; + } + abort(403, 'not found tool'); + } + + public function store($serve) + { + switch ($serve) { + case 'abo_orders_overview': + // return AboOrdersOverview::store(); + break; + } + abort(403, 'not found tool'); + } +} diff --git a/app/Http/Controllers/SyS/SysController.php b/app/Http/Controllers/SyS/SysController.php index 3a549bd..c52e6c1 100644 --- a/app/Http/Controllers/SyS/SysController.php +++ b/app/Http/Controllers/SyS/SysController.php @@ -2,23 +2,23 @@ namespace App\Http\Controllers\SyS; -use Carbon; -use Request; -use App\Services\SyS\Sales; -use App\Services\SyS\Import; +use App\Http\Controllers\Controller; +use App\Services\SyS\AboOrdersOverview; +use App\Services\SyS\BusinessStructur; +use App\Services\SyS\BuyingsProducts; +use App\Services\SyS\ChangeUserBusinesses; +use App\Services\SyS\CleanHTMLProductDescription; +use App\Services\SyS\Correction; use App\Services\SyS\Cronjobs; use App\Services\SyS\Customers; use App\Services\SyS\DomainSSL; -use App\Services\SyS\Correction; -use App\Http\Controllers\Controller; -use App\Services\SyS\ShoppingOrders; -use App\Services\SyS\BuyingsProducts; -use App\Services\SyS\BusinessStructur; +use App\Services\SyS\Import; use App\Services\SyS\ImportDbipCountry; -use App\Services\SyS\ChangeUserBusinesses; -use App\Services\SyS\UserCreditItemsAddFrom; +use App\Services\SyS\PayoneCallbackTestbench; use App\Services\SyS\RepairSalesVolumeInvoice; -use App\Services\SyS\CleanHTMLProductDescription; +use App\Services\SyS\Sales; +use App\Services\SyS\ShoppingOrders; +use App\Services\SyS\UserCreditItemsAddFrom; use App\Services\SyS\UserCreditItemsChangeMessage; class SysController extends Controller @@ -28,18 +28,17 @@ class SysController extends Controller public function __construct() { $this->middleware('sysadmin'); - } public function index() - { + { return view('sys.index'); } public function tool($serve) - { + { switch ($serve) { - + case 'user_credit_items_add_from': return UserCreditItemsAddFrom::show(); break; @@ -54,19 +53,19 @@ class SysController extends Controller break; case 'customers': return Customers::show(); - break; + break; case 'cronjobs': return Cronjobs::show(); - break; + break; case 'domainssl': return DomainSSL::show(); break; case 'shopping_orders': return ShoppingOrders::show(); - break; + break; case 'import': return Import::show(); - break; + break; case 'corrections': return Correction::show(); break; @@ -75,27 +74,28 @@ class SysController extends Controller break; case 'repair_sales_volume_invoice': return RepairSalesVolumeInvoice::show(); - break; + break; case 'user_credit_items_change_message': return UserCreditItemsChangeMessage::show(); - break; - case 'clean_html_product_description': + break; + case 'clean_html_product_description': return CleanHTMLProductDescription::show(); - break; + break; case 'import_dbip_country_lite': return ImportDbipCountry::show(); - break; - - - - - + break; + case 'abo_orders_overview': + return AboOrdersOverview::show(); + break; + case 'payone_callback_testbench': + return PayoneCallbackTestbench::show(); + break; } - abort(403, 'not found tool'); + abort(403, 'not found tool'); } - + public function store($serve) - { + { switch ($serve) { case 'user_credit_items_add_from': return UserCreditItemsAddFrom::show(); @@ -111,38 +111,41 @@ class SysController extends Controller break; case 'customers': return Customers::store(); - break; + break; case 'cronjobs': return Cronjobs::store(); - break; + break; case 'domainssl': return DomainSSL::store(); break; case 'shopping_orders': return ShoppingOrders::store(); - break; + break; case 'import': return Import::store(); - break; + break; case 'corrections': return Correction::store(); break; case 'change_user_businesses': return ChangeUserBusinesses::store(); - break; + break; case 'repair_sales_volume_invoice': return RepairSalesVolumeInvoice::store(); - break; + break; case 'user_credit_items_change_message': return UserCreditItemsChangeMessage::store(); - break; + break; case 'clean_html_product_description': return CleanHTMLProductDescription::store(); - break; + break; case 'import_dbip_country_lite': return ImportDbipCountry::store(); - break; + break; + case 'payone_callback_testbench': + return PayoneCallbackTestbench::store(); + break; } - abort(403, 'not found tool'); + abort(403, 'not found tool'); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index e0a8af7..4e45edd 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -45,9 +45,13 @@ class AboController extends Controller } if ($view === 'ot') { - $user_abos = UserAbo::where('member_id', \Auth::user()->id) + $selectedYear = (int) \Request::get('year', now()->year); + $baseQuery = UserAbo::where('member_id', \Auth::user()->id) ->where('status', '>', 1) - ->where('is_for', 'ot') + ->where('is_for', 'ot'); + + $user_abos = (clone $baseQuery) + ->with(['user_abo_items', 'user_abo_items.product', 'shopping_user']) ->orderBy('id', 'desc') ->get(); @@ -55,6 +59,10 @@ class AboController extends Controller 'user_abos' => $user_abos, 'view' => 'ot', 'isAdmin' => false, + 'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id), + 'chartYear' => $selectedYear, + 'chartYears' => \App\Services\HTMLHelper::getYearRange(2026), + 'chartMonths' => \App\Services\HTMLHelper::getTransMonths(), ]); } diff --git a/app/Http/Controllers/User/IncentiveController.php b/app/Http/Controllers/User/IncentiveController.php new file mode 100644 index 0000000..3a3e530 --- /dev/null +++ b/app/Http/Controllers/User/IncentiveController.php @@ -0,0 +1,200 @@ +middleware('active.account'); + } + + public function teaser($slug) + { + $incentive = Incentive::where('slug', $slug) + ->where('status', '!=', 0) + ->firstOrFail(); + + $user = Auth::user(); + $participant = IncentiveParticipant::where('incentive_id', $incentive->id) + ->where('user_id', $user->id) + ->first(); + + $galleryImages = $this->collectGalleryImages($incentive); + + return view('user.incentive.teaser', [ + 'incentive' => $incentive, + 'participant' => $participant, + 'galleryImages' => $galleryImages, + ]); + } + + public function show($slug) + { + $incentive = Incentive::where('slug', $slug) + ->where('status', '!=', 0) // not draft + ->firstOrFail(); + + $user = Auth::user(); + $participant = IncentiveParticipant::where('incentive_id', $incentive->id) + ->where('user_id', $user->id) + ->first(); + + $ranking = IncentiveParticipant::where('incentive_id', $incentive->id) + ->withRankingActivity() + ->with('user', 'user.account') + ->orderByIncentiveLeaderboard() + ->limit(self::USER_RANKING_DISPLAY_LIMIT) + ->get(); + + $participateHasTrackableAbos = false; + if (! $participant?->accepted_terms_at) { + $participateHasTrackableAbos = $this->userHasTrackableAbosForIncentive($user, $incentive); + } + + return view('user.incentive.show', [ + 'incentive' => $incentive, + 'participant' => $participant, + 'hasConfirmedParticipation' => $participant && $participant->accepted_terms_at !== null, + 'ranking' => $ranking, + 'rankingDisplayLimit' => self::USER_RANKING_DISPLAY_LIMIT, + 'participateHasTrackableAbos' => $participateHasTrackableAbos, + ]); + } + + public function participate($slug) + { + $incentive = Incentive::where('slug', $slug) + ->active() + ->firstOrFail(); + + if (! Request::has('accept_terms')) { + \Session()->flash('alert-error', __('incentive.terms_required')); + + return redirect(route('user_incentive_show', [$slug])); + } + + $user = Auth::user(); + + $participant = IncentiveParticipant::firstOrCreate( + [ + 'incentive_id' => $incentive->id, + 'user_id' => $user->id, + ], + [ + 'accepted_terms_at' => null, + ] + ); + + if ($participant->accepted_terms_at !== null) { + \Session()->flash('alert-info', __('incentive.already_participating')); + + return redirect(route('user_incentive_show', [$slug])); + } + + $participant->accepted_terms_at = Carbon::now(); + $participant->save(); + + $repair = app(IncentivePointsLogRepairService::class); + $repair->syncMissingTrackingAbos($participant); + $repair->syncMissingSalesVolumeLogs($participant); + $participant->refresh()->recalculateFromTrackingTables()->save(); + IncentiveTracker::updateRanking($incentive); + + \Session()->flash('alert-success', __('incentive.participation_confirmed')); + + return redirect(route('user_incentive_show', [$slug])); + } + + public function details($slug) + { + $incentive = Incentive::where('slug', $slug) + ->where('status', '!=', 0) + ->firstOrFail(); + + $user = Auth::user(); + $participant = IncentiveParticipant::with('incentive', 'user', 'user.account') + ->where('incentive_id', $incentive->id) + ->where('user_id', $user->id) + ->firstOrFail(); + + if ($participant->accepted_terms_at === null) { + \Session()->flash('alert-info', __('incentive.details_requires_confirmation')); + + return redirect(route('user_incentive_show', [$slug])); + } + + $data = AdminIncentiveController::buildParticipantDetailData($participant); + + return view('user.incentive.details', $data); + } + + /** + * Sammelt alle verfuegbaren Galerie-Bilder aus public/img/incentive/ + * (ohne das Hauptbild, das bereits als Hero verwendet wird). + * + * @return list + */ + private function collectGalleryImages(Incentive $incentive): array + { + $dir = public_path('img/incentive'); + + if (! is_dir($dir)) { + return []; + } + + $files = glob($dir . '/*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: []; + + $images = []; + foreach ($files as $file) { + $basename = basename($file); + $images[] = 'img/incentive/' . $basename; + } + sort($images); + + return $images; + } + + /** + * Hinweis auf der Teilnehmen-Karte: Es gibt bereits ein Eigenabo oder ein Kundenabo im Qualifikationszeitraum. + */ + private function userHasTrackableAbosForIncentive(User $user, Incentive $incentive): bool + { + $qualEnd = $incentive->qualification_end->copy()->endOfDay(); + + $hasOwnActiveAbo = UserAbo::query() + ->where('user_id', $user->id) + ->where('is_for', 'me') + ->where('status', 2) + ->exists(); + + $hasCustomerAboInQualification = UserAbo::query() + ->where('member_id', $user->id) + ->where('is_for', 'ot') + ->where('status', 2) + ->whereBetween('created_at', [ + $incentive->qualification_start, + $qualEnd, + ]) + ->exists(); + + return $hasOwnActiveAbo || $hasCustomerAboInQualification; + } +} diff --git a/app/Http/Controllers/User/MembershipController.php b/app/Http/Controllers/User/MembershipController.php index ef2f772..8f84230 100644 --- a/app/Http/Controllers/User/MembershipController.php +++ b/app/Http/Controllers/User/MembershipController.php @@ -63,7 +63,6 @@ class MembershipController extends Controller if ($user->isActiveAccount() && ! $user->isActiveShop()) { $payment_greaterThan = Carbon::parse($user->payment_account)->modify('-'.(config('mivita.renewal_days') + 1).' days'); $userHistoryUpgradeOrder = UserHistory::whereUserId($user->id)->whereAction('upgrade_order')->where('created_at', '>=', $payment_greaterThan)->get()->last(); - } $userHistoryDeleteMembership = UserHistory::whereUserId($user->id)->whereAction('delete_membership')->whereStatus(50)->get()->last(); @@ -87,7 +86,6 @@ class MembershipController extends Controller ]; return view('user.membership.index', $data); - } private function checkShoppingCountry($user) @@ -158,8 +156,11 @@ class MembershipController extends Controller if ($product->images->count()) { $image = $product->images->first()->slug; } - $qty = Request::get('qty') ? Request::get('qty') : 1; + $qty = $product->is_membership_only ? 1 : (Request::get('qty') ? Request::get('qty') : 1); $cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), $qty, $product->getPriceWith(\App\Services\UserService::getTaxFree(), false, \App\Services\UserService::$user_country), false, false, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on]); + if ($cartItem->qty > 1) { + Yard::instance('shopping')->update($cartItem->rowId, 1); + } if (\App\Services\UserService::getTaxFree()) { Yard::setTax($cartItem->rowId, 0); } else { @@ -214,7 +215,6 @@ class MembershipController extends Controller \Session()->flash('alert-success', __('msg.booked_package_has_been_changed')); return back(); - } } } @@ -236,11 +236,9 @@ class MembershipController extends Controller \Session()->flash('alert-error', __('msg.error_checkbox_not_confirm')); return back(); - } \Session()->flash('alert-error', __('msg.error_checkbox_not_confirm')); return back(); - } } diff --git a/app/Http/Controllers/User/OrderController.php b/app/Http/Controllers/User/OrderController.php index b55bc76..6143167 100644 --- a/app/Http/Controllers/User/OrderController.php +++ b/app/Http/Controllers/User/OrderController.php @@ -14,6 +14,7 @@ use App\Services\AboHelper; use App\Services\MyLog; use App\Services\OrderPaymentService; use App\Services\Payment; +use App\Services\ProductOrderContext; use App\Services\Shop; use App\Services\UserService; use App\Services\Util; @@ -182,6 +183,16 @@ class OrderController extends Controller $delivery_id = $shopping_user->id; } + $isAbo = str_contains($for, 'abo'); + $previousFor = session('user_order_flow_for'); + if ($previousFor !== null && $previousFor !== $for) { + $previousAbo = str_contains($previousFor, 'abo'); + if (ProductOrderContext::allowedShowOnIds($previousAbo, $previousFor) !== ProductOrderContext::allowedShowOnIds($isAbo, $for)) { + Yard::instance('shopping')->destroy(); + } + } + session(['user_order_flow_for' => $for]); + if ($for === 'ot-customer' || $for === 'abo-ot-customer') { UserService::initCustomerYard($shopping_user, $for); } else { @@ -262,7 +273,7 @@ class OrderController extends Controller // Prepare common data $data['is_from'] = 'user_order'; $data['is_for'] = $for; - $data['is_abo'] = $data['is_abo'] ?? 0; + $data['is_abo'] = str_contains($for, 'abo'); $data['abo_interval'] = $data['abo_interval'] ?? 0; $data['shopping_user_id'] = $id; $data['user_price_infos'] = Yard::instance('shopping')->getUserPriceInfos(); @@ -406,6 +417,17 @@ class OrderController extends Controller throw new \Exception(__('msg.shipping_country_was_not_correctly')); } + $isAbo = str_contains($data['shipping_is_for'], 'abo'); + foreach (Yard::instance('shopping')->content() as $row) { + $product = Product::find($row->id); + if (! $product) { + continue; + } + if (! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $data['shipping_is_for'])) { + throw new \Exception(__('msg.cart_product_not_allowed_for_order_type')); + } + } + if ($data['shipping_is_for'] !== 'ot-customer') { if (Yard::instance('shopping')->shipping_free) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); @@ -748,6 +770,15 @@ class OrderController extends Controller return response()->json(['response' => false, 'message' => 'Product not found']); } + $isAbo = str_contains($is_for, 'abo'); + $qty = isset($data['qty']) ? (int) $data['qty'] : 0; + if ($qty > 0 && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) { + return response()->json([ + 'response' => false, + 'message' => __('msg.cart_product_not_allowed_for_order_type'), + ]); + } + $image = ''; if ($product->images->count()) { $image = $product->images->first()->slug; diff --git a/app/Http/Controllers/User/TeamController.php b/app/Http/Controllers/User/TeamController.php index e9e6a17..2156e4d 100644 --- a/app/Http/Controllers/User/TeamController.php +++ b/app/Http/Controllers/User/TeamController.php @@ -713,9 +713,13 @@ class TeamController extends Controller // Hole Team-Mitglieder-IDs effizient via Sponsor-Hierarchie $teamUserIds = AboHelper::getTeamUserIds($user->id); - // Hole Abos der Team-Mitglieder - $abos = \App\Models\UserAbo::whereIn('user_id', $teamUserIds) + $selectedYear = (int) Request::get('year', now()->year); + $baseQuery = \App\Models\UserAbo::whereIn('user_id', $teamUserIds) ->where('is_for', 'me') + ->where('status', '>', 1); + + // Hole Abos der Team-Mitglieder + $abos = (clone $baseQuery) ->with(['user', 'user.account', 'user_abo_items', 'user_abo_items.product']) ->orderBy('next_date', 'asc') ->get(); @@ -724,11 +728,45 @@ class TeamController extends Controller 'filter_months' => HTMLHelper::getTransMonths(), 'filter_years' => HTMLHelper::getYearRange(2022), 'abos' => $abos, + 'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'team_abos', $user->id), + 'chartYear' => $selectedYear, + 'chartYears' => HTMLHelper::getYearRange(2026), + 'chartMonths' => HTMLHelper::getTransMonths(), ]; return view('user.team.abos', $data); } + /** + * Zeigt eine Übersicht der Kunden-Abos aller Team-Mitglieder (anonymisiert) + */ + public function showTeamCustomerAbos(): \Illuminate\View\View + { + $user = User::find(\Auth::user()->id); + $teamUserIds = AboHelper::getTeamUserIds($user->id); + + $selectedYear = (int) Request::get('year', now()->year); + $baseQuery = \App\Models\UserAbo::whereIn('member_id', $teamUserIds) + ->where('is_for', 'ot') + ->where('status', '>', 1); + + $abos = (clone $baseQuery) + ->with(['member', 'member.account', 'user_abo_items', 'user_abo_items.product']) + ->orderBy('member_id') + ->orderBy('next_date', 'asc') + ->get(); + + $groupedByMember = $abos->groupBy('member_id'); + + return view('user.team.customer_abos', [ + 'groupedByMember' => $groupedByMember, + 'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'team_cust_abos', $user->id), + 'chartYear' => $selectedYear, + 'chartYears' => HTMLHelper::getYearRange(2026), + 'chartMonths' => HTMLHelper::getTransMonths(), + ]); + } + /** * Zeigt die Detail-Ansicht eines Team-Abos an */ diff --git a/app/Http/Controllers/Web/CardController.php b/app/Http/Controllers/Web/CardController.php index b87e1c4..6ff5002 100644 --- a/app/Http/Controllers/Web/CardController.php +++ b/app/Http/Controllers/Web/CardController.php @@ -2,46 +2,49 @@ namespace App\Http\Controllers\Web; - -use Yard; -use Request; +use App\Http\Controllers\Controller; +use App\Models\Product; +use App\Models\ShoppingInstance; +use App\Models\ShoppingUser; +use App\Services\ProductOrderContext; use App\Services\Shop; use App\Services\Util; -use App\Models\Product; -use App\Models\ShoppingUser; -use App\Models\ShoppingInstance; -use App\Http\Controllers\Controller; +use Request; +use Yard; class CardController extends Controller { private $instance = 'webshop'; + /** * Create a new controller instance. * * @return void */ - public function __construct() - { - } + public function __construct() {} - - - //Cart::instance('wishlist')->add('sdjk922', 'Product 2', 1, 19.95, ['size' => 'medium']); + // Cart::instance('wishlist')->add('sdjk922', 'Product 2', 1, 19.95, ['size' => 'medium']); public function addToCardGet($id, $quantity = 1, $product_slug = false) { $product = Product::find($id); - if($product){ - $image = ""; - if($product->images->count()){ + if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) { + $image = ''; + if ($product->images->count()) { $image = $product->images->first()->slug; } $cartItem = Yard::instance($this->instance) - ->add($product->id, $product->getLang('name'), $quantity, - $product->getPriceWith(Yard::instance($this->instance)->getUserTaxFree(), false, Yard::instance($this->instance)->getUserCountry()), false, false, - ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on]); - if(Yard::instance($this->instance)->getUserTaxFree()){ + ->add( + $product->id, + $product->getLang('name'), + $quantity, + $product->getPriceWith(Yard::instance($this->instance)->getUserTaxFree(), false, Yard::instance($this->instance)->getUserCountry()), + false, + false, + ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on] + ); + if (Yard::instance($this->instance)->getUserTaxFree()) { Yard::setTax($cartItem->rowId, 0); - }else{ + } else { Yard::setTax($cartItem->rowId, $product->getTaxWith(Yard::instance($this->instance)->getUserCountry())); } Yard::instance($this->instance)->reCalculateShippingPrice(); @@ -50,7 +53,6 @@ class CardController extends Controller } return back(); - } public function addToCardPost($id) @@ -58,39 +60,45 @@ class CardController extends Controller $product = Product::find($id); - if($product){ - $image = ""; - if($product->images->count()){ + if ($product && ProductOrderContext::isProductAllowedInCustomerWebshop($product)) { + $image = ''; + if ($product->images->count()) { $image = $product->images->first()->slug; } $quantity = Request::get('quantity') ? Request::get('quantity') : 1; $cartItem = Yard::instance($this->instance) - ->add($product->id, $product->getLang('name'), $quantity, - $product->getPriceWith(Yard::instance($this->instance)->getUserTaxFree(), false, Yard::instance($this->instance)->getUserCountry()), false, false, - ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on]); - if(Yard::instance($this->instance)->getUserTaxFree()){ + ->add( + $product->id, + $product->getLang('name'), + $quantity, + $product->getPriceWith(Yard::instance($this->instance)->getUserTaxFree(), false, Yard::instance($this->instance)->getUserCountry()), + false, + false, + ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on] + ); + if (Yard::instance($this->instance)->getUserTaxFree()) { Yard::setTax($cartItem->rowId, 0); - }else{ + } else { Yard::setTax($cartItem->rowId, $product->getTaxWith(Yard::instance($this->instance)->getUserCountry())); } Yard::instance($this->instance)->reCalculateShippingPrice(); \Session()->flash('show-card-after-add', true); } + return back(); - - } - public function showCard(){ + public function showCard() + { - if(Request::get('selected_country')){ + if (Request::get('selected_country')) { Yard::instance($this->instance)->setShippingCountryWithPrice(Request::get('selected_country')); - }else{ + } else { Yard::instance($this->instance)->reCalculateShippingPrice(); } - //show konflikt wenn user eingeloggt ist und country nicht gesetzt ist + // show konflikt wenn user eingeloggt ist und country nicht gesetzt ist $shipping_error = $this->checkShippingError(); $data = [ 'user_shop' => Util::getUserShop(), @@ -98,30 +106,49 @@ class CardController extends Controller 'yard_instance' => $this->instance, 'shipping_error' => $shipping_error ?? false, ]; + return view('web.templates.card', $data); } - public function updateCard(){ + public function updateCard() + { $data = Request::all(); - if(isset($data['quantity'])){ - foreach ($data['quantity'] as $rowId => $qty){ + if (isset($data['quantity'])) { + foreach ($data['quantity'] as $rowId => $qty) { + $cartItem = Yard::instance($this->instance)->get($rowId); + if ($cartItem) { + $product = Product::find($cartItem->id); + if ($product && $product->is_membership_only) { + $qty = 1; + } + } Yard::instance($this->instance)->update($rowId, $qty); Yard::instance($this->instance)->reCalculateShippingPrice(); } - }else{ - $this->deleteCard(); + } else { + $this->deleteCard(); } + return back(); } - public function checkoutServer(){ - + public function checkoutServer() + { + foreach (Yard::instance($this->instance)->content() as $row) { + $product = Product::find($row->id); + if (! $product || ! ProductOrderContext::isProductAllowedInCustomerWebshop($product)) { + \Session::flash('alert-error', __('msg.cart_product_not_allowed_for_order_type')); + + return redirect()->back(); + } + } + $user_shop = Util::getUserShop(); - + do { $identifier = Util::getToken(); - } while( ShoppingInstance::where('identifier', $identifier)->count() ); + } while (ShoppingInstance::where('identifier', $identifier)->count()); $data = []; $data['is_from'] = 'shopping'; @@ -130,7 +157,7 @@ class CardController extends Controller ShoppingInstance::create([ 'identifier' => $identifier, 'user_shop_id' => $user_shop->id, - 'payment' => 1, //Customer Shop Payment + 'payment' => 1, // Customer Shop Payment 'subdomain' => url('/'), 'country_id' => Yard::instance($this->instance)->getShippingCountryId(), 'language' => \App::getLocale(), @@ -138,55 +165,63 @@ class CardController extends Controller 'back' => url()->previous(), ]); - + Yard::instance($this->instance)->store($identifier); - //add to DB - $path = route('checkout.checkout_card', ['identifier'=>$identifier]); - if(strpos($path, 'https') === false){ + // add to DB + $path = route('checkout.checkout_card', ['identifier' => $identifier]); + if (strpos($path, 'https') === false) { $path = str_replace('http', 'https', $path); } + return redirect()->secure($path); } - public function backToShop(){ + public function backToShop() + { $this->deleteCard(); - return redirect(url('/')); + return redirect(url('/')); } - public function removeCard($rowId){ + + public function removeCard($rowId) + { Yard::instance($this->instance)->remove($rowId); + return back(); } - public function deleteCard(){ + public function deleteCard() + { $setCode = Shop::getUserShopLang(null, $this->instance); $mylangs = Shop::getLangChange($this->instance); - foreach($mylangs as $code => $country){ - if(strtolower($setCode) === strtolower($code)){ + foreach ($mylangs as $code => $country) { + if (strtolower($setCode) === strtolower($code)) { Shop::initUserShopLang($country, $this->instance); + return back(); } } } - private function checkShippingError(){ + private function checkShippingError() + { $shipping_error = false; - if(\Auth::guard('customers')->check()){ + if (\Auth::guard('customers')->check()) { $user = \Auth::guard('customers')->user(); - if($user->shopping_user_id){ + if ($user->shopping_user_id) { $shopping_user = ShoppingUser::find($user->shopping_user_id); - if($shopping_user->same_as_billing){ - if($shopping_user->billing_country_id != Yard::instance($this->instance)->getUserCountryId()){ + if ($shopping_user->same_as_billing) { + if ($shopping_user->billing_country_id != Yard::instance($this->instance)->getUserCountryId()) { $user_country = Yard::instance($this->instance)->getUserCountry(); $user_country_name = $user_country ? $user_country->getLocated() : ''; $billing_country = $shopping_user->billing_country; $country_name = $billing_country ? $billing_country->getLocated() : ''; $shipping_error = __('website.shipping_error_billing', ['shipping_country' => $user_country_name, 'billing_country' => $country_name]); } - }else{ - if($shopping_user->shipping_country_id != Yard::instance($this->instance)->getUserCountryId()){ + } else { + if ($shopping_user->shipping_country_id != Yard::instance($this->instance)->getUserCountryId()) { $user_country = Yard::instance($this->instance)->getUserCountry(); $user_country_name = $user_country ? $user_country->getLocated() : ''; $shipping_country = $shopping_user->shipping_country; @@ -194,9 +229,9 @@ class CardController extends Controller $shipping_error = __('website.shipping_error_delivery', ['shipping_country' => $user_country_name, 'billing_country' => $country_name]); } } - } } + return $shipping_error; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Web/SiteController.php b/app/Http/Controllers/Web/SiteController.php index d9d794d..72a09a3 100644 --- a/app/Http/Controllers/Web/SiteController.php +++ b/app/Http/Controllers/Web/SiteController.php @@ -2,26 +2,24 @@ namespace App\Http\Controllers\Web; - -use Yard; -use Request; +use App\Http\Controllers\Controller; +use App\Models\Category; use App\Models\IqSite; +use App\Models\Product; +use App\Models\ProductCategory; +use App\Services\LocaleGuard; use App\Services\Shop; use App\Services\Util; -use App\Models\Product; -use App\Models\Category; -use App\Models\ProductCategory; -use App\Http\Controllers\Controller; +use Request; class SiteController extends Controller { - public function index() { $this->setIPInfo(); $products = ['aloe-vera-gel-99', 'aloe-vera-saft-500-ml', 'aloe-vera-lippenbalsam']; // $set_products = ['aloe-vera-cleaner-set', 'aloe-vera-koerper-set', 'aloe-vera-repair-set']; - $set_products = ['aloe-vera-koerper-set', 'baby-set', 'aloe-vera-gel-set']; + $set_products = ['aloe-vera-koerper-set', 'baby-set', 'aloe-vera-gel-set']; $data = [ 'products' => Product::whereIn('slug', $products)->where('active', true)->whereJsonContains('show_on', '1')->get(), 'set_products' => Product::whereIn('slug', $set_products)->where('active', true)->whereJsonContains('show_on', '1')->get(), @@ -34,9 +32,9 @@ class SiteController extends Controller return view('web.index', $data); } - public function domainCheck() + public function domainCheck() { - die("checked"); + exit('checked'); } public function changeLang() @@ -48,8 +46,12 @@ class SiteController extends Controller if (strtolower($data['change_country_id']) === strtolower($code)) { \Session::put('user_init_country', strtolower($code)); \Session::forget('user_init_country_options'); - \Session::put('locale', strtolower($data['change_locale_id'])); + $locale = LocaleGuard::normalize($data['change_locale_id'] ?? null); + if ($locale !== null) { + \Session::put('locale', $locale); + } Shop::initUserShopLang($country, 'webshop'); + return back(); } } @@ -58,40 +60,41 @@ class SiteController extends Controller private function setIPinfo() { - //wurde schon gesetzt //cache + // wurde schon gesetzt //cache $country = strtolower(Shop::getIPDatabaseInfo()); if (\Session::has('user_init_country')) { return; } if (config('app.ipinfo')) { $country = strtolower(Shop::getIPDatabaseInfo()); - if ($country === 'de') { //$locale de - init AT + if ($country === 'de') { // $locale de - init AT \Session::put('user_init_country', $country); + return; } - if ($country === 'error') { //$locale at - init AT + if ($country === 'error') { // $locale at - init AT $country = 'de'; } } else { $country = 'de'; } - //$locale = strtolower(\App::getLocale()); - //ist default + // $locale = strtolower(\App::getLocale()); + // ist default - //sprache + // sprache if (array_key_exists($country, \App\Services\UserService::getTransChange())) { \Session::put('user_init_country', $country); \Session::put('locale', $country); \App::setLocale($country); } else { - //default EN + // default EN \Session::put('user_init_country', 'de'); \Session::put('locale', 'de'); \App::setLocale('de'); } - //bestelland / versandland + // bestelland / versandland if (array_key_exists($country, Shop::getLangChange('webshop'))) { \Session::put('user_init_country_options', $country); } else { @@ -119,6 +122,7 @@ class SiteController extends Controller 'p_count' => Product::where('active', true)->whereJsonContains('show_on', '1')->count(), 'yard_instance' => 'webshop', ]; + return view('web.templates.produkte-show', $data); } } @@ -131,7 +135,7 @@ class SiteController extends Controller $headline_image = $category->iq_image; } - $product_categories = ProductCategory::where('category_id', $category->id)->whereHas('product', function ($query) use ($category) { + $product_categories = ProductCategory::where('category_id', $category->id)->whereHas('product', function ($query) { $query->where('active', true)->whereJsonContains('show_on', '1'); })->orderBy('pos', 'DESC')->get(); @@ -147,7 +151,8 @@ class SiteController extends Controller 'headline_image' => $headline_image, 'yard_instance' => 'webshop', ]; - return view('web.templates.' . $site, $data); + + return view('web.templates.'.$site, $data); } } dd($subsite); @@ -163,7 +168,8 @@ class SiteController extends Controller 'headline_image' => false, 'yard_instance' => 'webshop', ]; - return view('web.templates.' . $site, $data); + + return view('web.templates.'.$site, $data); } $data = [ 'user_shop' => Util::getUserShop(), @@ -171,14 +177,16 @@ class SiteController extends Controller 'yard_instance' => 'webshop', ]; if ($subsite) { - if (!view()->exists('web.templates.' . $subsite)) { + if (! view()->exists('web.templates.'.$subsite)) { abort(404); } - return view('web.templates.' . $subsite, $data); + + return view('web.templates.'.$subsite, $data); } - if (!view()->exists('web.templates.' . $site)) { + if (! view()->exists('web.templates.'.$site)) { abort(404); } - return view('web.templates.' . $site, $data); + + return view('web.templates.'.$site, $data); } } diff --git a/app/Http/Controllers/WizardController.php b/app/Http/Controllers/WizardController.php index 0df4e3c..5cbff3a 100644 --- a/app/Http/Controllers/WizardController.php +++ b/app/Http/Controllers/WizardController.php @@ -605,6 +605,9 @@ class WizardController extends Controller $image = $product->images->first()->slug; } $cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->getPriceWith(\App\Services\UserService::getTaxFree(), false, \App\Services\UserService::$user_country), false, false, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'free_shipping_consultant' => $product->free_shipping_consultant, 'show_on' => $product->show_on]); + if ($cartItem->qty > 1) { + Yard::instance('shopping')->update($cartItem->rowId, 1); + } if (\App\Services\UserService::getTaxFree()) { Yard::setTax($cartItem->rowId, 0); } else { diff --git a/app/Http/Middleware/Localization.php b/app/Http/Middleware/Localization.php index 413e298..5727db9 100644 --- a/app/Http/Middleware/Localization.php +++ b/app/Http/Middleware/Localization.php @@ -2,29 +2,37 @@ namespace App\Http\Middleware; -use Carbon; +use App\Services\LocaleGuard; use Closure; -use Auth; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Session; class Localization { /** - * Handle an incoming request. + * Session locale must be validated: arbitrary strings break Symfony/Carbon + * (e.g. scanner payloads stored as "locale"). * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next */ - - public function handle($request, Closure $next) + public function handle(Request $request, Closure $next): mixed { - if (\Session::has('locale')) { - \App::setLocale(\Session::get('locale')); - // Carbon::setLocale('\Session::get('locale')'); - //Carbon::setLocale('de'); + if (! Session::has('locale')) { + return $next($request); } + $raw = Session::get('locale'); + $normalized = LocaleGuard::normalize(is_string($raw) ? $raw : null); + + if ($normalized !== null) { + App::setLocale($normalized); + + return $next($request); + } + + Session::forget('locale'); + return $next($request); } - } diff --git a/app/Models/AboChartSnapshot.php b/app/Models/AboChartSnapshot.php new file mode 100644 index 0000000..72aaa9c --- /dev/null +++ b/app/Models/AboChartSnapshot.php @@ -0,0 +1,30 @@ + 'int', + 'year' => 'int', + 'month' => 'int', + 'count' => 'int', + 'calculated_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Incentive.php b/app/Models/Incentive.php new file mode 100644 index 0000000..11cbeb3 --- /dev/null +++ b/app/Models/Incentive.php @@ -0,0 +1,240 @@ + $participants + * + * @method static \Illuminate\Database\Eloquent\Builder|Incentive newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Incentive newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|Incentive query() + * + * @mixin \Eloquent + */ +class Incentive extends Model +{ + use HasFactory, Sluggable, SoftDeletes; + + protected $table = 'incentives'; + + protected $casts = [ + 'trans_name' => 'array', + 'trans_subtitle' => 'array', + 'trans_description' => 'array', + 'trans_terms' => 'array', + 'points_partner_onetime' => 'int', + 'points_abo_onetime' => 'int', + 'min_direct_partners' => 'int', + 'min_customer_abos' => 'int', + 'max_winners' => 'int', + 'status' => 'int', + 'qualification_start' => 'date', + 'qualification_end' => 'date', + 'calculation_end' => 'date', + ]; + + protected $fillable = [ + 'name', + 'trans_name', + 'subtitle', + 'trans_subtitle', + 'description', + 'trans_description', + 'image', + 'terms', + 'trans_terms', + 'qualification_start', + 'qualification_end', + 'calculation_end', + 'points_partner_onetime', + 'points_abo_onetime', + 'min_direct_partners', + 'min_customer_abos', + 'max_winners', + 'status', + ]; + + public static $statusTypes = [ + 0 => 'draft', + 1 => 'active', + 2 => 'closed', + ]; + + public static $statusColors = [ + 0 => 'warning', + 1 => 'success', + 2 => 'secondary', + ]; + + public function sluggable(): array + { + return [ + 'slug' => [ + 'source' => 'name', + ], + ]; + } + + // Relationships + + public function participants() + { + return $this->hasMany(IncentiveParticipant::class); + } + + // Scopes + + public function scopeActive($query) + { + return $query->where('status', 1); + } + + public function scopeInQualificationPeriod($query, ?Carbon $date = null) + { + $date = $date ?: Carbon::now(); + + return $query->where('qualification_start', '<=', $date) + ->where('qualification_end', '>=', $date); + } + + public function scopeInCalculationPeriod($query, ?Carbon $date = null) + { + $date = $date ?: Carbon::now(); + + return $query->where('qualification_start', '<=', $date) + ->where('calculation_end', '>=', $date); + } + + // Helpers + + public function isActive(): bool + { + return $this->status === 1; + } + + public function isDraft(): bool + { + return $this->status === 0; + } + + public function isClosed(): bool + { + return $this->status === 2; + } + + public function isInQualificationPeriod(?Carbon $date = null): bool + { + $date = $date ?: Carbon::now(); + + return $date->between($this->qualification_start, $this->qualification_end); + } + + public function isInCalculationPeriod(?Carbon $date = null): bool + { + $date = $date ?: Carbon::now(); + + return $date->between($this->qualification_start, $this->calculation_end); + } + + public function isDateInScope(int $month, int $year): bool + { + $date = Carbon::createFromDate($year, $month, 1); + + return $date->between( + $this->qualification_start->copy()->startOfMonth(), + $this->calculation_end->copy()->endOfMonth() + ); + } + + public function getStatusType(): string + { + return isset(self::$statusTypes[$this->status]) ? __('incentive.status_'.self::$statusTypes[$this->status]) : ''; + } + + public function getStatusColor(): string + { + return self::$statusColors[$this->status] ?? 'default'; + } + + /** + * Get specific translation for a field and locale. + */ + public function getTrans(string $key, string $lang): string + { + $transKey = 'trans_'.$key; + if (! empty($this->{$transKey}[$lang])) { + return $this->{$transKey}[$lang]; + } + + return ''; + } + + /** + * Get translated value for the current locale, falling back to German (default). + */ + public function getLang(string $key): string + { + $lang = \App::getLocale(); + if ($lang === 'de') { + return (string) ($this->{$key} ?? ''); + } + $trans = $this->getTrans($key, $lang); + if ($trans !== '') { + return $trans; + } + + return (string) ($this->{$key} ?? ''); + } + + /** + * @return array + */ + public function getCalculationMonths(): array + { + $months = []; + $current = $this->qualification_start->copy()->startOfMonth(); + $end = $this->calculation_end->copy()->startOfMonth(); + + while ($current->lte($end)) { + $months[] = [ + 'month' => $current->month, + 'year' => $current->year, + ]; + $current->addMonth(); + } + + return $months; + } +} diff --git a/app/Models/IncentiveNewAbo.php b/app/Models/IncentiveNewAbo.php new file mode 100644 index 0000000..af9ee16 --- /dev/null +++ b/app/Models/IncentiveNewAbo.php @@ -0,0 +1,56 @@ + $pointsLogs + * + * @mixin \Eloquent + */ +class IncentiveNewAbo extends Model +{ + use HasFactory; + + protected $table = 'incentive_new_abos'; + + protected $casts = [ + 'participant_id' => 'int', + 'user_abo_id' => 'int', + 'activated_at' => 'datetime', + ]; + + protected $fillable = [ + 'participant_id', + 'user_abo_id', + 'activated_at', + ]; + + public function participant() + { + return $this->belongsTo(IncentiveParticipant::class, 'participant_id'); + } + + public function userAbo() + { + return $this->belongsTo(UserAbo::class, 'user_abo_id'); + } + + public function pointsLogs() + { + return $this->hasMany(IncentivePointsLog::class, 'incentive_new_abo_id'); + } +} diff --git a/app/Models/IncentiveNewPartner.php b/app/Models/IncentiveNewPartner.php new file mode 100644 index 0000000..aeaee77 --- /dev/null +++ b/app/Models/IncentiveNewPartner.php @@ -0,0 +1,57 @@ + $pointsLogs + * + * @mixin \Eloquent + */ +class IncentiveNewPartner extends Model +{ + use HasFactory; + + protected $table = 'incentive_new_partners'; + + protected $casts = [ + 'participant_id' => 'int', + 'user_id' => 'int', + 'registered_at' => 'datetime', + ]; + + protected $fillable = [ + 'participant_id', + 'user_id', + 'registered_at', + ]; + + public function participant() + { + return $this->belongsTo(IncentiveParticipant::class, 'participant_id'); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function pointsLogs() + { + return $this->hasMany(IncentivePointsLog::class, 'incentive_new_partner_id'); + } +} diff --git a/app/Models/IncentiveParticipant.php b/app/Models/IncentiveParticipant.php new file mode 100644 index 0000000..62d7aa2 --- /dev/null +++ b/app/Models/IncentiveParticipant.php @@ -0,0 +1,536 @@ + $pointsLog + * @property-read Collection $newPartners + * @property-read Collection $newAbos + * + * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant orderByIncentiveLeaderboard() + * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant orderByRankNullsLast() + * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant query() + * @method static \Illuminate\Database\Eloquent\Builder|IncentiveParticipant withRankingActivity() + * + * @mixin \Eloquent + */ +class IncentiveParticipant extends Model +{ + use HasFactory; + + protected $table = 'incentive_participants'; + + protected $casts = [ + 'incentive_id' => 'int', + 'user_id' => 'int', + 'total_points' => 'int', + 'qualified_partners' => 'int', + 'qualified_abos' => 'int', + 'is_qualified' => 'bool', + 'rank' => 'int', + 'accepted_terms_at' => 'datetime', + ]; + + protected $fillable = [ + 'incentive_id', + 'user_id', + 'accepted_terms_at', + 'total_points', + 'qualified_partners', + 'qualified_abos', + 'is_qualified', + 'rank', + ]; + + // Relationships + + public function incentive() + { + return $this->belongsTo(Incentive::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function pointsLog() + { + return $this->hasMany(IncentivePointsLog::class, 'participant_id'); + } + + public function newPartners() + { + return $this->hasMany(IncentiveNewPartner::class, 'participant_id'); + } + + public function newAbos() + { + return $this->hasMany(IncentiveNewAbo::class, 'participant_id'); + } + + // Scopes + + public function scopeQualified($query) + { + return $query->where('is_qualified', true); + } + + /** + * Teilnehmer mit nachweisbarer Aktivität: mindestens ein qualifizierter Partner, ein Kunden-Abo oder + * Gesamtpunkte größer null. Reine Nullstände erscheinen in der User-Rangliste nicht. + */ + public function scopeWithRankingActivity(Builder $query): Builder + { + $model = $query->getModel(); + $qualifiedPartners = $model->qualifyColumn('qualified_partners'); + $qualifiedAbos = $model->qualifyColumn('qualified_abos'); + $totalPoints = $model->qualifyColumn('total_points'); + + return $query->where(function (Builder $q) use ($qualifiedPartners, $qualifiedAbos, $totalPoints) { + $q->where($qualifiedPartners, '>', 0) + ->orWhere($qualifiedAbos, '>', 0) + ->orWhere($totalPoints, '>', 0); + }); + } + + /** + * Leaderboard: qualifizierte Teilnehmer (Mindest-Partner/Abos) oben, unabhängig von den Punkten; + * darunter nach Rang (1, 2, …), ohne Rang zuletzt; gleiche Stufe nach Gesamtpunkten absteigend. + * Bei Punktgleichstand: Teilnehmer mit bestätigter Teilnahme (Klarnamen) vor anonymen. + */ + public function scopeOrderByIncentiveLeaderboard(Builder $query): Builder + { + $model = $query->getModel(); + $grammar = $model->getConnection()->getQueryGrammar(); + $qualifiedRank = $grammar->wrap( + $model->qualifyColumn('rank') + ); + $acceptedTerms = $grammar->wrap( + $model->qualifyColumn('accepted_terms_at') + ); + + return $query + ->orderBy($model->qualifyColumn('is_qualified'), 'desc') + ->orderByRaw($qualifiedRank.' IS NULL') + ->orderBy($model->qualifyColumn('rank'), 'asc') + ->orderBy($model->qualifyColumn('total_points'), 'desc') + ->orderByRaw($acceptedTerms.' IS NOT NULL DESC'); + } + + /** + * Sortierung nach Rang (1, 2, …); Teilnehmer ohne gesetzten Rang stehen unten. + */ + public function scopeOrderByRankNullsLast(Builder $query): Builder + { + $model = $query->getModel(); + $qualifiedRank = $model->getConnection()->getQueryGrammar()->wrap( + $model->qualifyColumn('rank') + ); + + return $query + ->orderByRaw($qualifiedRank.' IS NULL') + ->orderBy($model->qualifyColumn('rank'), 'asc'); + } + + public function scopeWinners($query, int $maxWinners) + { + return $query->qualified() + ->whereNotNull('rank') + ->where('rank', '<=', $maxWinners) + ->orderBy('rank'); + } + + // Helpers + + public function hasAcceptedTerms(): bool + { + return $this->accepted_terms_at !== null; + } + + /** + * Teilnehmerzeile fuer einen Berater anlegen, falls noch nicht vorhanden (ohne Teilnahme-Bestaetigung). + */ + public static function ensureForIncentiveUser(Incentive $incentive, int $userId): self + { + return self::firstOrCreate( + [ + 'incentive_id' => $incentive->id, + 'user_id' => $userId, + ], + [ + 'accepted_terms_at' => null, + ] + ); + } + + /** + * Alle Berater (User mit m_level) als Teilnehmer anlegen, damit Punkte im Qualifikationszeitraum ohne Checkbox mitlaufen. + * + * @return int Anzahl neu angelegter Zeilen + */ + public static function ensureConsultantsForIncentive(Incentive $incentive): int + { + $added = 0; + + User::query() + ->where('id', '!=', 1) + ->whereNull('deleted_at') + ->where('admin', '<', 4) + ->whereNotNull('m_level') + ->whereNotExists(function ($q) use ($incentive) { + $q->selectRaw('1') + ->from('incentive_participants') + ->whereColumn('incentive_participants.user_id', 'users.id') + ->where('incentive_participants.incentive_id', $incentive->id); + }) + ->orderBy('id') + ->chunkById(500, function ($users) use ($incentive, &$added) { + foreach ($users as $user) { + self::create([ + 'incentive_id' => $incentive->id, + 'user_id' => $user->id, + 'accepted_terms_at' => null, + ]); + $added++; + } + }); + + return $added; + } + + public function checkQualification(): bool + { + $incentive = $this->incentive; + + $this->is_qualified = $this->qualified_partners >= $incentive->min_direct_partners + && $this->qualified_abos >= $incentive->min_customer_abos; + + return $this->is_qualified; + } + + /** + * Berechnung aus Tracking-Tabellen und Points-Log. + * Zaehlt Partner/Abos aus eigenen Tabellen, Punkte aus Log. + */ + public function recalculateFromTrackingTables(): self + { + $this->qualified_partners = $this->newPartners()->count(); + $this->qualified_abos = $this->newAbos()->count(); + + $this->total_points = (int) $this->pointsLog() + ->selectRaw('COALESCE(SUM(points_onetime + points_accumulated), 0) as total') + ->value('total'); + + $this->checkQualification(); + + return $this; + } + + /** + * Kompletter Neuaufbau aus Quelldaten (Users, UserAbos, UserSalesVolumes). + * Loescht Tracking-Tabellen + Log und baut alles neu auf. + * Nur fuer Batch/Cron/Force-Rebuild. + */ + public function rebuildFromSourceTables(): self + { + $incentive = $this->incentive; + + // Tracking-Tabellen + Log leeren + $this->newPartners()->delete(); + $this->newAbos()->delete(); + $this->pointsLog()->delete(); + + // A. Neupartner: direkt gesponserte User im Qualifikationszeitraum mit bezahltem Starterpaket + $new_partners = User::where('m_sponsor', $this->user_id) + ->whereBetween('created_at', [ + $incentive->qualification_start, + $incentive->qualification_end->copy()->endOfDay(), + ]) + ->whereHas('shopping_orders', function ($q) { + $q->wherePaidRegistrationIncludesStarterKit(); + }) + ->get(); + + foreach ($new_partners as $partner) { + $newPartner = IncentiveNewPartner::create([ + 'participant_id' => $this->id, + 'user_id' => $partner->id, + 'registered_at' => $partner->created_at, + ]); + + IncentivePointsLog::create([ + 'participant_id' => $this->id, + 'type' => 'partner', + 'source_type' => User::class, + 'source_id' => $partner->id, + 'source_label' => $partner->getFullName() ?: $partner->email ?: ('User #'.$partner->id), + 'month' => $partner->created_at->month, + 'year' => $partner->created_at->year, + 'points_onetime' => $incentive->points_partner_onetime, + 'points_accumulated' => 0, + 'incentive_new_partner_id' => $newPartner->id, + ]); + } + + // B. Kundenabos (ot) + Berater-Eigenabos (me): status=2 + $qualStart = $incentive->qualification_start->copy()->startOfDay(); + $qualEnd = $incentive->qualification_end->copy()->endOfDay(); + + // Kundenabos: Berater steht in member_id (nicht user_id) + $customerAbosInPeriod = UserAbo::where('member_id', $this->user_id) + ->where('is_for', 'ot') + ->where('status', 2) + ->whereBetween('created_at', [$qualStart, $qualEnd]) + ->get(); + + // Eigenabo: im Qualifikationszeitraum neu abgeschlossen + $ownAbosInPeriod = UserAbo::where('user_id', $this->user_id) + ->where('is_for', 'me') + ->where('status', 2) + ->whereBetween('created_at', [$qualStart, $qualEnd]) + ->get(); + + // Eigenabo: bereits vor Qualifikationsbeginn aktiv → einmalig mit Wirkung ab Qualifikationsstart + $ownAbosPreExisting = UserAbo::where('user_id', $this->user_id) + ->where('is_for', 'me') + ->where('status', 2) + ->where('created_at', '<', $qualStart) + ->get(); + + foreach ($customerAbosInPeriod->concat($ownAbosInPeriod)->concat($ownAbosPreExisting) as $abo) { + $activatedAt = $abo->created_at; + $logMonth = (int) $abo->created_at->month; + $logYear = (int) $abo->created_at->year; + + if ($abo->is_for === 'me' && $abo->created_at->lt($qualStart)) { + $activatedAt = $qualStart->copy(); + $logMonth = (int) $qualStart->month; + $logYear = (int) $qualStart->year; + } + + $newAbo = IncentiveNewAbo::create([ + 'participant_id' => $this->id, + 'user_abo_id' => $abo->id, + 'activated_at' => $activatedAt, + ]); + + IncentivePointsLog::create([ + 'participant_id' => $this->id, + 'type' => 'abo', + 'source_type' => UserAbo::class, + 'source_id' => $abo->id, + 'source_label' => $abo->email ?: ('Abo #'.$abo->id), + 'month' => $logMonth, + 'year' => $logYear, + 'points_onetime' => $incentive->points_abo_onetime, + 'points_accumulated' => 0, + 'incentive_new_abo_id' => $newAbo->id, + ]); + } + + // C. Akkumulierte Punkte NUR von Neupartnern und Neuabos + $calculation_months = $incentive->getCalculationMonths(); + $new_partner_user_ids = $this->newPartners()->pluck('user_id')->toArray(); + $abo_shopping_user_ids = UserAbo::whereIn('id', $this->newAbos()->pluck('user_abo_id')) + ->whereNotNull('shopping_user_id') + ->pluck('shopping_user_id') + ->toArray(); + + $newPartnerByUserId = $this->newPartners()->get()->keyBy('user_id'); + $newAboByUserAboId = $this->newAbos()->get()->keyBy('user_abo_id'); + + foreach ($calculation_months as $period) { + // C1. Neupartner-Umsaetze: Sales Volumes der Neupartner selbst + if (! empty($new_partner_user_ids)) { + $partner_svs = UserSalesVolume::whereIn('user_id', $new_partner_user_ids) + ->where('month', $period['month']) + ->where('year', $period['year']) + ->where('status', '!=', 6) + ->get(); + + foreach ($partner_svs as $sv) { + $points = (int) abs($sv->points ?? 0); + if ($points <= 0) { + continue; + } + + IncentivePointsLog::create([ + 'participant_id' => $this->id, + 'type' => 'partner', + 'source_type' => UserSalesVolume::class, + 'source_id' => $sv->id, + 'source_label' => $sv->message ?? ('SV '.$period['month'].'/'.$period['year']), + 'month' => $period['month'], + 'year' => $period['year'], + 'points_onetime' => 0, + 'points_accumulated' => $points, + 'user_sales_volume_id' => $sv->id, + 'incentive_new_partner_id' => $newPartnerByUserId->get($sv->user_id)?->id, + ]); + } + + // Stornos von Neupartnern + $partner_stornos = UserSalesVolume::whereIn('user_id', $new_partner_user_ids) + ->where('month', $period['month']) + ->where('year', $period['year']) + ->where('status', 6) + ->get(); + + foreach ($partner_stornos as $storno_sv) { + $points = (int) abs($storno_sv->points ?? 0); + if ($points <= 0) { + continue; + } + + IncentivePointsLog::create([ + 'participant_id' => $this->id, + 'type' => 'partner', + 'source_type' => UserSalesVolume::class, + 'source_id' => $storno_sv->id, + 'source_label' => 'Storno: '.($storno_sv->message ?? 'SV #'.$storno_sv->id), + 'month' => $period['month'], + 'year' => $period['year'], + 'points_onetime' => 0, + 'points_accumulated' => -$points, + 'is_storno' => true, + 'user_sales_volume_id' => $storno_sv->id, + 'incentive_new_partner_id' => $newPartnerByUserId->get($storno_sv->user_id)?->id, + ]); + } + } + + // C2. Neuabo-Umsaetze: Sales Volumes von Bestellungen der Abo-Kunden + if (! empty($abo_shopping_user_ids)) { + $abo_svs = UserSalesVolume::where('user_id', $this->user_id) + ->where('month', $period['month']) + ->where('year', $period['year']) + ->where('status', '!=', 6) + ->whereHas('shopping_order', fn ($q) => $q->whereIn('shopping_user_id', $abo_shopping_user_ids)) + ->get(); + + foreach ($abo_svs as $sv) { + $points = (int) abs($sv->points ?? 0); + if ($points <= 0) { + continue; + } + + $incentiveNewAboId = null; + if ($sv->shopping_order_id) { + $userAboId = UserAboOrder::where('shopping_order_id', $sv->shopping_order_id)->value('user_abo_id'); + if ($userAboId) { + $incentiveNewAboId = $newAboByUserAboId->get($userAboId)?->id; + } + } + + IncentivePointsLog::create([ + 'participant_id' => $this->id, + 'type' => 'abo', + 'source_type' => UserSalesVolume::class, + 'source_id' => $sv->id, + 'source_label' => $sv->message ?? ('SV '.$period['month'].'/'.$period['year']), + 'month' => $period['month'], + 'year' => $period['year'], + 'points_onetime' => 0, + 'points_accumulated' => $points, + 'user_sales_volume_id' => $sv->id, + 'incentive_new_abo_id' => $incentiveNewAboId, + ]); + } + + // Stornos von Abo-Kunden + $abo_stornos = UserSalesVolume::where('user_id', $this->user_id) + ->where('month', $period['month']) + ->where('year', $period['year']) + ->where('status', 6) + ->whereHas('shopping_order', fn ($q) => $q->whereIn('shopping_user_id', $abo_shopping_user_ids)) + ->get(); + + foreach ($abo_stornos as $storno_sv) { + $points = (int) abs($storno_sv->points ?? 0); + if ($points <= 0) { + continue; + } + + $incentiveNewAboId = null; + if ($storno_sv->shopping_order_id) { + $userAboId = UserAboOrder::where('shopping_order_id', $storno_sv->shopping_order_id)->value('user_abo_id'); + if ($userAboId) { + $incentiveNewAboId = $newAboByUserAboId->get($userAboId)?->id; + } + } + + IncentivePointsLog::create([ + 'participant_id' => $this->id, + 'type' => 'abo', + 'source_type' => UserSalesVolume::class, + 'source_id' => $storno_sv->id, + 'source_label' => 'Storno: '.($storno_sv->message ?? 'SV #'.$storno_sv->id), + 'month' => $period['month'], + 'year' => $period['year'], + 'points_onetime' => 0, + 'points_accumulated' => -$points, + 'is_storno' => true, + 'user_sales_volume_id' => $storno_sv->id, + 'incentive_new_abo_id' => $incentiveNewAboId, + ]); + } + } + } + + // Totals aus den neu aufgebauten Tracking-Tabellen berechnen + return $this->recalculateFromTrackingTables(); + } + + /** + * @deprecated Verwende recalculateFromTrackingTables() stattdessen + */ + public function recalculatePoints(): int + { + $this->recalculateFromTrackingTables(); + + return $this->total_points; + } + + public function isWinner(): bool + { + if (! $this->is_qualified || $this->rank === null) { + return false; + } + + return $this->rank <= $this->incentive->max_winners; + } + + private static function determineLogType(UserSalesVolume $usv): string + { + if ($usv->status_turnover == 2 || $usv->status_points == 2) { + return 'abo'; + } + + return 'partner'; + } +} diff --git a/app/Models/IncentivePointsLog.php b/app/Models/IncentivePointsLog.php new file mode 100644 index 0000000..5d89bd6 --- /dev/null +++ b/app/Models/IncentivePointsLog.php @@ -0,0 +1,148 @@ + 'int', + 'source_id' => 'int', + 'month' => 'int', + 'year' => 'int', + 'points_onetime' => 'int', + 'points_accumulated' => 'int', + 'is_storno' => 'bool', + 'storno_of_id' => 'int', + 'user_sales_volume_id' => 'int', + 'incentive_new_partner_id' => 'int', + 'incentive_new_abo_id' => 'int', + ]; + + protected $fillable = [ + 'participant_id', + 'type', + 'source_type', + 'source_id', + 'source_label', + 'month', + 'year', + 'points_onetime', + 'points_accumulated', + 'is_storno', + 'storno_of_id', + 'user_sales_volume_id', + 'incentive_new_partner_id', + 'incentive_new_abo_id', + ]; + + public static $types = [ + 'partner' => 'partner', + 'abo' => 'abo', + ]; + + // Relationships + + public function participant() + { + return $this->belongsTo(IncentiveParticipant::class, 'participant_id'); + } + + public function salesVolume() + { + return $this->belongsTo(UserSalesVolume::class, 'user_sales_volume_id'); + } + + public function incentiveNewPartner() + { + return $this->belongsTo(IncentiveNewPartner::class, 'incentive_new_partner_id'); + } + + public function incentiveNewAbo() + { + return $this->belongsTo(IncentiveNewAbo::class, 'incentive_new_abo_id'); + } + + public function stornoOf() + { + return $this->belongsTo(self::class, 'storno_of_id'); + } + + public function stornoEntries() + { + return $this->hasMany(self::class, 'storno_of_id'); + } + + // Scopes + + public function scopePartner($query) + { + return $query->where('type', 'partner'); + } + + public function scopeAbo($query) + { + return $query->where('type', 'abo'); + } + + public function scopeActive($query) + { + return $query->where('is_storno', false); + } + + public function scopeForMonth($query, int $month, int $year) + { + return $query->where('month', $month)->where('year', $year); + } + + // Helpers + + public function getTotalPoints(): int + { + return $this->points_onetime + $this->points_accumulated; + } + + public function getFormattedMonthYear(): string + { + return str_pad($this->month, 2, '0', STR_PAD_LEFT).'/'.$this->year; + } +} diff --git a/app/Models/ShoppingOrder.php b/app/Models/ShoppingOrder.php index e8d9934..5b6d89b 100644 --- a/app/Models/ShoppingOrder.php +++ b/app/Models/ShoppingOrder.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -339,6 +340,36 @@ class ShoppingOrder extends Model return $this->hasMany('App\Models\ShoppingOrderItem', 'shopping_order_id'); } + /** + * Bezahlte Berater-Registrierung (payment_for = 1) mit mindestens einem Produkt, das keine + * reine Mitgliedschaft ohne Starterpaket ist ({@see Product::$is_membership_only}). + */ + public function scopeWherePaidRegistrationIncludesStarterKit(Builder $query): Builder + { + return $query + ->where('payment_for', 1) + ->where(function (Builder $q) { + $q->where('paid', true) + ->orWhereIn('txaction', ['paid', 'extern_paid']); + }) + ->whereHas('shopping_order_items', function (Builder $q) { + $q->whereHas('product', function (Builder $p) { + $p->where('is_membership_only', false); + }); + }); + } + + /** + * Erfüllt diese Bestellung die Voraussetzung für Incentive-Neupartner-Tracking (Starterpaket)? + */ + public function qualifiesForIncentiveTrackedPartner(): bool + { + return static::query() + ->whereKey($this->getKey()) + ->wherePaidRegistrationIncludesStarterKit() + ->exists(); + } + public function shopping_payments() { return $this->hasMany('App\Models\ShoppingPayment', 'shopping_order_id'); diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index ee8fe61..8f70b48 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -256,11 +256,23 @@ class UserAbo extends Model return $this->attributes['cancel_date'] ? Carbon::parse($this->attributes['cancel_date'])->format(\Util::formatDateDB()) : ''; } - public function getFormattedAmount() + public function getFormattedAmount(): string { return isset($this->attributes['amount']) ? Util::formatNumber($this->attributes['amount'] / 100) : ''; } + public function getTotalPoints(): float + { + return $this->user_abo_items + ->where('comp', 0) + ->sum(fn ($item) => ($item->product?->points ?? 0) * $item->qty); + } + + public function getFormattedTotalPoints(): string + { + return Util::formatNumber($this->getTotalPoints()); + } + public function getIsForFormated() { return $this->attributes['is_for'] === 'me' ? ''.__('tables.adviser').'' : ''.__('tables.customer').''; diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index 6a5d3b7..f716869 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -7,6 +7,10 @@ use App\Services\AboHelper; class AboRepository extends BaseRepository { + private const LOCK_DAYS_CHANGE = 10; + + private const LOCK_DAYS_PAUSE_CANCEL = 3; + public function __construct() { // $this->model = $model; @@ -24,9 +28,12 @@ class AboRepository extends BaseRepository if ($this->validate($data)) { $this->updateStatus($data); $this->model->abo_interval = $data['abo_interval']; - $this->model->next_date = AboHelper::setNextDate(now(), $data['abo_interval']); + $nextDate = $this->calculateNewNextDate($data['abo_interval']); + $this->model->next_date = $nextDate; $this->model->save(); - \Session()->flash('alert-success', 'Einstellungen gespeichert'); + + $daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate); + \Session()->flash('alert-warning', __('abo.warning_next_date_info', ['days' => $daysUntilNext, 'date' => $nextDate->format('d.m.Y')])); return true; } @@ -44,6 +51,16 @@ class AboRepository extends BaseRepository { // Handle cancellation if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') { + // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden + if ($this->model->next_date) { + $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); + if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { + \Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil])); + + return; + } + } + // Status 4 = abo_cancel (storniert/gekündigt) $this->model->status = 4; $this->model->active = false; @@ -54,6 +71,15 @@ class AboRepository extends BaseRepository } $active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false; + // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden + if ($this->model->active && ! $active && $this->model->next_date) { + $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); + if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { + \Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil])); + + return; + } + } // if status is active and active is false, set status to inactive if ($this->model->active && ! $active) { if ($this->model->status == 2) { // okay @@ -63,7 +89,7 @@ class AboRepository extends BaseRepository } } if (! $this->model->active && $active) { - if ($this->model->status = 6) { // inactive + if ($this->model->status == 6) { // inactive $this->model->status = 2; // okay $this->model->active = true; $this->model->save(); @@ -97,23 +123,51 @@ class AboRepository extends BaseRepository } } if (! in_array($data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays)) { - // to check if user is not admin \Session()->flash('alert-error', __('abo.error_abo_interval')); return false; } - // Prüfung: Wenn das Abo diesen Monat noch nicht ausgeführt wurde (oder noch nie), - // darf das Abo-Intervall nicht auf einen Tag gesetzt werden, der bereits vergangen ist (oder heute ist), - // da setNextDate das nächste Ausführungsdatum sonst auf den nächsten Monat setzt und dieser Monat übersprungen wird. - $executedThisMonth = $this->model->last_date && \Carbon\Carbon::parse($this->model->last_date)->isCurrentMonth(); + // Sperre: 10 Tage vor nächster Ausführung keine Änderungen mehr (Pakete werden vorgepackt) + if ($this->model->next_date) { + $daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); + if ($daysUntilExecution >= 0 && $daysUntilExecution < self::LOCK_DAYS_CHANGE) { + \Session()->flash('alert-error', __('abo.error_change_locked', ['days' => $daysUntilExecution])); - if (! $executedThisMonth && $data['abo_interval'] <= now()->day) { - \Session()->flash('alert-error', __('abo.error_abo_interval_in_the_past')); + return false; + } + } + + // Prüfung: Das neue berechnete Ausführungsdatum muss mindestens LOCK_DAYS_CHANGE Tage entfernt sein. + // Falls next_date bereits in einem zukünftigen Monat liegt, wird das neue Datum in diesem Monat berechnet. + $newNextDate = $this->calculateNewNextDate($data['abo_interval']); + $daysUntilNewDate = (int) now()->diffInDays($newNextDate, false); + if ($daysUntilNewDate < self::LOCK_DAYS_CHANGE) { + \Session()->flash('alert-error', __('abo.error_abo_interval_too_soon', ['days' => $daysUntilNewDate])); return false; } return true; } + + /** + * Berechnet das neue Ausführungsdatum unter Berücksichtigung des aktuellen next_date. + * Falls next_date bereits in einem zukünftigen Monat liegt, wird der Monatsanfang + * dieses Monats als Referenz verwendet, sodass der neue Tag im selben Monat landet. + */ + private function calculateNewNextDate(int $aboInterval): \Carbon\Carbon + { + $referenceDate = now(); + + if ($this->model->next_date) { + $currentNextDate = \Carbon\Carbon::parse($this->model->next_date); + + if ($currentNextDate->format('Y-m') > now()->format('Y-m')) { + $referenceDate = $currentNextDate->startOfMonth(); + } + } + + return AboHelper::setNextDate($referenceDate, $aboInterval); + } } diff --git a/app/Repositories/InvoiceRepository.php b/app/Repositories/InvoiceRepository.php index 5fe2a0a..2492251 100644 --- a/app/Repositories/InvoiceRepository.php +++ b/app/Repositories/InvoiceRepository.php @@ -8,8 +8,10 @@ use App\Models\ShoppingOrder; use App\Models\UserInvoice; use App\Models\UserSalesVolume; use App\Services\BusinessPlan\SalesPointsVolume; +use App\Services\Incentive\IncentiveTracker; use App\Services\Invoice; use App\Services\UserService; +use App\Services\Util; use Storage; class InvoiceRepository extends BaseRepository @@ -217,10 +219,15 @@ class InvoiceRepository extends BaseRepository public function createAndSalesVolume($request = []) { - $this->user_sales_volume = SalesPointsVolume::addSalesPointsVolumeUser($this->model); - $user_invoice = $this->create($request); - $this->user_sales_volume->user_invoice_id = $user_invoice->id; - $this->user_sales_volume->save(); + $this->user_sales_volume = SalesPointsVolume::User($this->model); + if (! Util::isTestSystem(true)) { // rechnung erstellen nur in production + $user_invoice = $this->create($request); + $this->user_sales_volume->user_invoice_id = $user_invoice->id; + $this->user_sales_volume->save(); + } + + // Incentive: Track sales volume points + IncentiveTracker::trackSalesVolume($this->user_sales_volume); } /** diff --git a/app/Services/AboHelper.php b/app/Services/AboHelper.php index b5c98d6..615893d 100644 --- a/app/Services/AboHelper.php +++ b/app/Services/AboHelper.php @@ -8,12 +8,19 @@ use App\Models\ShoppingPayment; use App\Models\ShoppingUser; use App\Models\UserAbo; use App\Models\UserAboItem; +use App\Models\UserAboItemHistory; use App\Models\UserAboOrder; +use App\Services\Incentive\IncentiveTracker; use App\User; use Carbon\Carbon; class AboHelper { + /** + * Mindestabstand (Kalendertage) vom Bestell-/Referenzdatum bis zur ersten Abo-Ausführung. + */ + public const MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10; + public static $txaction_filter_text = [ 'paid' => 'paymend_paid', 'appointed' => 'paymend_open', @@ -50,9 +57,19 @@ class AboHelper public static function setAboStatus(ShoppingOrder $shopping_order, $status, $paid = false) { $user_abo = $shopping_order->getUserAbo(); - if ($user_abo && $user_abo->status < 2) { // status < 2 is not active - $user_abo->update(['status' => $status]); + if ($user_abo) { + // Neuaktivierung nach erfolgreicher Zahlung (z. B. Payone paid): immer wieder auf abo_okay (2), + // auch wenn das Abo vorher abo_hold (3) war (z. B. Cron-Zahlung fehlgeschlagen, spaeter bezahlt). + if ($paid && (int) $status === 2) { + $user_abo->update(['status' => 2]); + } elseif ($user_abo->status < 2) { + $user_abo->update(['status' => $status]); + } } + if (! $user_abo) { + return; + } + UserAboOrder::where('user_abo_id', $user_abo->id)->where('shopping_order_id', $shopping_order->id)->update(['status' => $status, 'paid' => $paid]); } @@ -153,47 +170,111 @@ class AboHelper public static function getFirstAboDate($date, $abo_interval) { - $nextDate = Carbon::parse($date)->firstOfMonth()->addMonth(1); - $nextDate->addDays($abo_interval - 1); + $reference = Carbon::parse($date)->startOfDay(); + $candidate = self::computeFirstAboCandidateWithoutMinDays($reference, (int) $abo_interval); - return $nextDate->gt($date) ? $nextDate : $nextDate->addMonth(1); + while ($reference->diffInDays($candidate->copy()->startOfDay(), true) < self::MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION) { + $candidate = self::advanceAboCandidateOneMonth($candidate, (int) $abo_interval); + } + + return $candidate; + } + + /** + * Kalendertage von $from bis $to (nur Datum, ohne Uhrzeit). + * Verhindert Abweichungen, wenn {@see now()} eine Tageszeit hat und Carbon {@see diffInDays} in 24h-Schritten zählt. + */ + public static function calendarDaysUntil(Carbon|string $from, Carbon $to): int + { + $start = Carbon::parse($from)->startOfDay(); + $end = $to->copy()->startOfDay(); + + return (int) $start->diffInDays($end, true); + } + + /** + * Erste mögliche Ausführung (nächster Monat, gewählter Liefertag) ohne Mindestabstand-Regel. + */ + private static function computeFirstAboCandidateWithoutMinDays(Carbon $reference, int $aboDayOfMonth): Carbon + { + $nextDate = $reference->copy()->firstOfMonth()->addMonth(1); + $nextDate->addDays($aboDayOfMonth - 1); + + if (! $nextDate->gt($reference)) { + $nextDate->addMonth(1); + } + + return $nextDate->copy()->startOfDay(); + } + + /** + * Gleicher Liefertag im Folgemonat (Monatsende beachten). + */ + private static function advanceAboCandidateOneMonth(Carbon $candidate, int $aboDayOfMonth): Carbon + { + $next = $candidate->copy()->addMonthNoOverflow(); + $dim = $next->daysInMonth; + $day = min($aboDayOfMonth, $dim); + + return $next->day($day)->startOfDay(); } public static function createNewAbo(ShoppingPayment $shopping_payment) { - // is Abo - create init Abo from PP or else - if ($shopping_payment->shopping_order->is_abo && $shopping_payment->shopping_order->abo_interval > 0) { - $payment_transaction = $shopping_payment->payment_transactions->last(); + $order = $shopping_payment->shopping_order; + if (! $order || ! $order->is_abo || (int) $order->abo_interval <= 0) { + return; + } - // next_date immer im nächsten Monat starten - // is auth_user_id = Berater bestellung - // is member_id = Kunden bestellung - // is for = me = mich oder ot = kunde - $user_abo = UserAbo::create([ - 'user_id' => $shopping_payment->shopping_order->auth_user_id, - 'member_id' => $shopping_payment->shopping_order->member_id, - 'shopping_user_id' => $shopping_payment->shopping_order->shopping_user_id, - 'email' => $shopping_payment->shopping_order->shopping_user->billing_email, - 'is_for' => $shopping_payment->shopping_order->shopping_user->is_for, - 'payone_userid' => $payment_transaction->userid, - 'clearingtype' => $shopping_payment->clearingtype, - 'wallettype' => $shopping_payment->wallettype, - 'carddata' => $shopping_payment->carddata, - 'amount' => $shopping_payment->amount, + // Bereits verknüpft (z. B. Checkout-Erfolgsseite vor Callback) oder wiederholter Aufruf + if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) { + return; + } + + $aboInterval = (int) ($shopping_payment->abo_interval ?? $order->abo_interval); + if ($aboInterval <= 0) { + return; + } + + $payment_transaction = $shopping_payment->payment_transactions->last(); + $payoneUserId = $payment_transaction ? (int) $payment_transaction->userid : 0; + + // next_date immer im nächsten Monat starten + // is auth_user_id = Berater bestellung + // is member_id = Kunden bestellung + // is for = me = mich oder ot = kunde + $user_abo = UserAbo::create([ + 'user_id' => $order->auth_user_id, + 'member_id' => $order->member_id, + 'shopping_user_id' => $order->shopping_user_id, + 'email' => $order->shopping_user->billing_email, + 'is_for' => $order->shopping_user->is_for, + 'payone_userid' => $payoneUserId, + 'clearingtype' => $shopping_payment->clearingtype, + 'wallettype' => $shopping_payment->wallettype, + 'carddata' => $shopping_payment->carddata, + 'amount' => $shopping_payment->amount, + 'status' => 1, + 'abo_interval' => $aboInterval, + 'start_date' => now(), + 'last_date' => now(), + 'next_date' => self::getFirstAboDate(now(), $aboInterval), + ]); + + if ($user_abo) { + self::createAboItems($user_abo, $shopping_payment); + UserAboOrder::create([ + 'user_abo_id' => $user_abo->id, + 'shopping_order_id' => $shopping_payment->shopping_order_id, 'status' => 1, - 'abo_interval' => $shopping_payment->abo_interval, - 'start_date' => now(), - 'last_date' => now(), - 'next_date' => self::getFirstAboDate(now(), $shopping_payment->abo_interval), ]); - if ($user_abo) { - self::createAboItems($user_abo, $shopping_payment); - UserAboOrder::create([ - 'user_abo_id' => $user_abo->id, - 'shopping_order_id' => $shopping_payment->shopping_order_id, - 'status' => 1, - ]); + // Payone-Status-URL kann vor dem Checkout-Redirect laufen: dann existierte + // noch kein UserAboOrder → Payment::paymentStatusPaidAction → trackAboActivated ohne Wirkung. + // Nach Anlage hier erneut versuchen, wenn die Bestellung bereits als bezahlt gilt. + $shopping_payment->shopping_order->refresh(); + if ($shopping_payment->shopping_order->paid) { + IncentiveTracker::trackAboActivated($shopping_payment->shopping_order); } } } @@ -214,6 +295,57 @@ class AboHelper AboItemHistoryService::logInitialCreation($user_abo, 'system'); } + /** + * Stellt Abo-Artikel aus der letzten Bestellung mit Positionen wieder her, wenn user_abo_items leer sind + * (z. B. manuell angelegtes Abo ohne Checkout-AboItem-Anlage). + */ + public static function ensureUserAboItemsFromLatestOrder(UserAbo $userAbo): bool + { + if ($userAbo->user_abo_items()->exists()) { + return true; + } + + $userAboOrders = $userAbo->user_abo_orders() + ->orderByDesc('id') + ->with(['shopping_order.shopping_order_items']) + ->get(); + + $order = null; + foreach ($userAboOrders as $link) { + $shoppingOrder = $link->shopping_order; + if ($shoppingOrder && $shoppingOrder->shopping_order_items->isNotEmpty()) { + $order = $shoppingOrder; + break; + } + } + + if (! $order) { + return false; + } + + foreach ($order->shopping_order_items as $item) { + UserAboItem::create([ + 'user_abo_id' => $userAbo->id, + 'product_id' => $item->product_id, + 'comp' => $item->comp ?? 0, + 'qty' => $item->qty, + 'status' => 1, + ]); + } + + $userAbo->unsetRelation('user_abo_items'); + + if (! UserAboItemHistory::query() + ->where('user_abo_id', $userAbo->id) + ->where('is_initial', true) + ->exists()) { + $userAbo->load('user_abo_items'); + AboItemHistoryService::logInitialCreation($userAbo, 'system'); + } + + return true; + } + public static function getTransStatusFilterText() { $ret = []; @@ -287,4 +419,79 @@ class AboHelper return array_values(array_unique($teamUserIds)); } + + /** + * Berechnet die Anzahl aktiver Abos pro Monat für ein gegebenes Jahr. + * Ein Abo gilt als aktiv in Monat M wenn: + * - start_date <= letzter Tag von M + * - cancel_date ist NULL oder >= erster Tag von M + * + * @param \Illuminate\Database\Eloquent\Builder $query Basis-Query (gefiltert nach User/Team etc.) + * @param int $year Jahr für die Berechnung + * @return int[] Array mit 12 Einträgen (Index 0 = Januar, 11 = Dezember) + */ + /** + * Liefert die Abo-Zählung pro Monat für ein Jahr. + * + * Vergangene Monate → aus DB-Snapshot (eingefroren, unabhängig von Strukturänderungen). + * Aktueller Monat → live berechnet. + * Zukünftige Monate → null (kein Balken im Chart). + * + * @param \Illuminate\Database\Eloquent\Builder $liveQuery Basis-Query für den aktuellen Monat + * @param string $scope 'ot' | 'team_abos' | 'team_cust_abos' + * @param int $userId Eingeloggter Berater + * @return array 12 Einträge (Index 0 = Jan), null = Zukunft + */ + public static function getMonthlyAboCounts( + \Illuminate\Database\Eloquent\Builder $liveQuery, + int $year, + string $scope, + int $userId + ): array { + $data = []; + $now = Carbon::now(); + $currentYear = (int) $now->year; + $currentMonth = (int) $now->month; + $lastCountableMonth = ($year === $currentYear) ? $currentMonth : 12; + + // Alle vorhandenen Snapshots für diesen User/Scope/Jahr auf einmal laden + $snapshots = \App\Models\AboChartSnapshot::where('user_id', $userId) + ->where('scope', $scope) + ->where('year', $year) + ->get() + ->keyBy('month'); + + for ($month = 1; $month <= 12; $month++) { + if ($month > $lastCountableMonth) { + $data[] = null; + + continue; + } + + $isPastMonth = $year < $currentYear || ($year === $currentYear && $month < $currentMonth); + + if ($isPastMonth && $snapshots->has($month)) { + // Eingefroren – aus DB + $data[] = $snapshots->get($month)->count; + } else { + // Aktueller Monat oder noch kein Snapshot → live + $startOfMonth = Carbon::create($year, $month, 1)->startOfMonth(); + $endOfMonth = Carbon::create($year, $month, 1)->endOfMonth(); + $terminalStatuses = [4, 5]; + + $data[] = (clone $liveQuery) + ->whereDate('start_date', '<=', $endOfMonth) + ->where(function ($q) use ($startOfMonth, $terminalStatuses) { + $q->whereDate('cancel_date', '>=', $startOfMonth) + ->orWhere(function ($q2) use ($terminalStatuses) { + $q2->whereNull('cancel_date') + ->whereNotIn('status', $terminalStatuses); + }); + }) + ->count(); + } + } + + return $data; + } } diff --git a/app/Services/AboOrderCart.php b/app/Services/AboOrderCart.php index 7d49b69..a64fb4e 100644 --- a/app/Services/AboOrderCart.php +++ b/app/Services/AboOrderCart.php @@ -92,6 +92,8 @@ class AboOrderCart ]); } + AboHelper::ensureUserAboItemsFromLatestOrder($user_abo); + // Sicherstellen, dass die Items für dieses spezifische Abo geladen werden // Verwende fresh() um sicherzustellen, dass wir die aktuellen Daten haben $abo_items = $user_abo->user_abo_items()->get(); diff --git a/app/Services/BusinessPlan/BusinessUserItem.php b/app/Services/BusinessPlan/BusinessUserItem.php index e1b53d8..2e1e1b8 100644 --- a/app/Services/BusinessPlan/BusinessUserItem.php +++ b/app/Services/BusinessPlan/BusinessUserItem.php @@ -1,55 +1,55 @@ date = $date; + return $this; } - public function makeUser($user_id){ + public function makeUser($user_id) + { - //check for user an load is saved + // check for user an load is saved $this->b_user = UserBusiness::where('user_id', $user_id)->where('month', $this->date->month)->where('year', $this->date->year)->first(); - if($this->b_user !== null){ + if ($this->b_user !== null) { return; } - //read User here, can delete in stored data. + // read User here, can delete in stored data. $user = User::find($user_id); - if(!$user){ + if (! $user) { return; } $user_level_active = $user->user_level ? $user->user_level : null; $this->user_level_active_pos = $user_level_active ? $user_level_active->pos : 0; - $this->b_user = new UserBusiness(); + $this->b_user = new UserBusiness; $fill = [ 'user_id' => $user->id, 'month' => $this->date->month, 'year' => $this->date->year, 'm_level_id' => $user->m_level, - 'user_level_name' => $user_level_active ? $user_level_active->name : '', + 'user_level_name' => $user_level_active ? $user_level_active->name : '', 'active_account' => $user->payment_account ? Carbon::parse($user->payment_account)->gt(Carbon::parse($this->date->start_date)) : false, - 'payment_account_date' => $user->payment_account ? $user->getPaymentAccountDateFormat(false) : NULL, - 'active_date' => $user->active_date ? $user->active_date : NULL, + 'payment_account_date' => $user->payment_account ? $user->getPaymentAccountDateFormat(false) : null, + 'active_date' => $user->active_date ? $user->active_date : null, 'm_account' => $user->account->m_account, 'email' => $user->email, 'first_name' => $user->account->first_name, @@ -61,17 +61,17 @@ class BusinessUserItem 'sales_volume_TP_points' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_TP_points'), 'sales_volume_points_shop' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_points_shop'), - 'sales_volume_points_KP_sum' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_points_KP_sum'), //KP + Shop Points - 'sales_volume_points_TP_sum' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_points_TP_sum'), //TP + Shop Points + 'sales_volume_points_KP_sum' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_points_KP_sum'), // KP + Shop Points + 'sales_volume_points_TP_sum' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_points_TP_sum'), // TP + Shop Points 'sales_volume_total' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_total'), 'sales_volume_total_shop' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_total_shop'), 'sales_volume_total_sum' => $user->getUserSalesVolumeBy($this->date->month, $this->date->year, 'sales_volume_total_sum'), - 'margin' => $user_level_active ? $user_level_active->margin : 0, //is fix Rabatt für Kundenbestellungen - 'margin_shop' => $user_level_active ? $user_level_active->margin_shop : 0, //is fix Rabatt für Shopbestellungen - 'qual_kp' => $user_level_active ? $user_level_active->qual_kp : 0, //KP Kundenpoints from level - 'qual_pp' => $user_level_active ? $user_level_active->qual_pp : 0, //PP Payline Points from level + 'margin' => $user_level_active ? $user_level_active->margin : 0, // is fix Rabatt für Kundenbestellungen + 'margin_shop' => $user_level_active ? $user_level_active->margin_shop : 0, // is fix Rabatt für Shopbestellungen + 'qual_kp' => $user_level_active ? $user_level_active->qual_kp : 0, // KP Kundenpoints from level + 'qual_pp' => $user_level_active ? $user_level_active->qual_pp : 0, // PP Payline Points from level 'payline_points' => 0, 'commission_pp_total' => 0, @@ -83,116 +83,133 @@ class BusinessUserItem $this->b_user->business_lines = []; $this->b_user->user_items = []; $this->b_user->commission_shop_sales = round($this->b_user->sales_volume_total_shop / 100 * $this->b_user->margin_shop, 2); - + } + public function getSalesVolumeTotalMargin() + { + return $this->b_user->getSalesVolumeTotalMargin(); + } - public function getSalesVolumeTotalMargin(){ - return $this->b_user->getSalesVolumeTotalMargin(); - } - - public function addUserID(){ + public function addUserID() + { TreeCalcBot::addUserID($this->b_user->user_id); } - public function getBUser(){ + public function getBUser() + { return $this->b_user; } - public function addBusinessLineToUser($line, $obj){ + public function addBusinessLineToUser($line, $obj) + { $this->b_user->business_lines[$line] = $obj; } - - public function addBusinessLinePoints($line, $points){ + + public function addBusinessLinePoints($line, $points) + { $obj = $this->business_lines[$line]; $obj->points += $points; $this->b_user->business_lines[$line] = $obj; } - public function addTotalTP($points){ + public function addTotalTP($points) + { $this->b_user->total_pp += $points; } - - public function isQualKP(){ + + public function isQualKP() + { return ($this->sales_volume_points_KP_sum >= $this->qual_kp) ? true : false; } - public function isQualLevel(){ + public function isQualLevel() + { return ($this->qual_user_level) ? true : false; } - public function isQualEqualLevel(){ - if($this->qual_user_level){ + public function isQualEqualLevel() + { + if ($this->qual_user_level) { return ($this->m_level_id == $this->qual_user_level['id']) ? true : false; } + return false; } - public function getQualLevelPaylines(){ - if($this->qual_user_level){ + public function getQualLevelPaylines() + { + if ($this->qual_user_level) { return $this->qual_user_level['paylines']; } + return 0; } - public function isQualLevelGrowth($line){ - if(isset($this->business_lines[$line])){ + public function isQualLevelGrowth($line) + { + if (isset($this->business_lines[$line])) { $object = $this->business_lines[$line]; - if(isset($object->growth_bonus)){ + if (isset($object->growth_bonus)) { return true; } } + return false; } - - public function getRestQualKP(){ + public function getRestQualKP() + { $ret = $this->sales_volume_points_KP_sum - $this->qual_kp; + return $ret > 0 ? $ret : 0; } - public function getCommissionTotal(){ + public function getCommissionTotal() + { return round($this->commission_shop_sales + $this->commission_pp_total + $this->commission_growth_total, 2); } - //Provisierungslevel brechnen, Berechnung der Provisionen nach Level - public function calcQualPP(){ - //das ist der erreichte Provisierungslevel, nach paylinePoints und KP + // Provisierungslevel brechnen, Berechnung der Provisionen nach Level + public function calcQualPP() + { + + // das ist der erreichte Provisierungslevel, nach paylinePoints und KP $qualUserLevel = $this->calcuQualLevel(); - if($qualUserLevel !== NULL){ - //prüfe einen Aufsieg im KarriereLevel + if ($qualUserLevel !== null) { + // prüfe einen Aufsieg im KarriereLevel $this->setNextUserLevel(); $this->b_user->qual_user_level = $qualUserLevel->toArray(); - //setzen nächsten ProvisionsLevel wenn not isQualEqualLevel + // setzen nächsten ProvisionsLevel wenn not isQualEqualLevel $this->setQualNextLevel(); - //Berechnung der Provisionen in der Payline + // Berechnung der Provisionen in der Payline $commission_pp_total = 0; $commission_growth_total = 0; - for ($i=1; $i <= $qualUserLevel->paylines ; $i++) { - if(isset($this->business_lines[$i])){ + for ($i = 1; $i <= $qualUserLevel->paylines; $i++) { + if (isset($this->business_lines[$i])) { $object = $this->business_lines[$i]; - $object->margin = $this->qual_user_level['pr_line_'.$i]; //provision in % - $object->commission = round($object->points / 100 * $object->margin, 2); //provision in points/euro + $object->margin = $this->qual_user_level['pr_line_'.$i]; // provision in % + $object->commission = round($object->points / 100 * $object->margin, 2); // provision in points/euro $object->payline = true; $commission_pp_total += $object->commission; - $this->b_user->business_lines[$i] = $object; + $this->b_user->business_lines[$i] = $object; } } - //Berechnung der Provisionen nach WB - if($qualUserLevel->growth_bonus){ - //['growth_bonus'] // + // Berechnung der Provisionen nach WB + if ($qualUserLevel->growth_bonus) { + // ['growth_bonus'] // $payline = (int) $this->b_user->qual_user_level['paylines'] + 1; $maxlines = count($this->business_lines) + 1; $growth_bonus = (float) $this->b_user->qual_user_level['growth_bonus']; - - for ($i=$payline; $i <= $maxlines ; $i++) { - if(isset($this->business_lines[$i])){ + + for ($i = $payline; $i <= $maxlines; $i++) { + if (isset($this->business_lines[$i])) { $object = $this->business_lines[$i]; - $object->margin = $growth_bonus; //provision in % - $object->commission = round($object->points / 100 * $object->margin, 2); //provision in points/euro + $object->margin = $growth_bonus; // provision in % + $object->commission = round($object->points / 100 * $object->margin, 2); // provision in points/euro $object->growth_bonus = true; $commission_growth_total += $object->commission; - $this->b_user->business_lines[$i] = $object; + $this->b_user->business_lines[$i] = $object; } } @@ -200,73 +217,78 @@ class BusinessUserItem $this->b_user->commission_pp_total = $commission_pp_total; $this->b_user->commission_growth_total = $commission_growth_total; - }else{ - //erste Provisierungslevel als next setzen, hat keine oder wenig points + } else { + // erste Provisierungslevel als next setzen, hat keine oder wenig points $qualUserLevelNext = UserLevel::where('pos', '=', 1)->orderBy('qual_pp', 'asc')->first(); - if($qualUserLevelNext){ + if ($qualUserLevelNext) { $this->b_user->qual_user_level_next = $qualUserLevelNext->toArray(); } } - } - //qualifikation nach qual_kp (KundenPoints) und qual_pp (PaylinePoints) - public function calcuQualLevel(){ - //alle levels wo die qual_kp erreicht ist, sortiert nach Rang > - $qualUserLevels = UserLevel::where('qual_kp', '<=', $this->sales_volume_points_KP_sum)->where('pos', '<=', $this->user_level_active_pos)->orderBy('qual_pp', 'desc')->get(); - foreach($qualUserLevels as $qualUserLevel){ - //brechnet die Points nach der Payline + // qualifikation nach qual_kp (KundenPoints) und qual_pp (PaylinePoints) + public function calcuQualLevel() + { + // alle levels wo die qual_kp erreicht ist, sortiert nach Rang > + $qualUserLevels = UserLevel::where('qual_kp', '<=', $this->sales_volume_points_KP_sum)->where('pos', '<=', $this->user_level_active_pos)->orderBy('qual_pp', 'desc')->get(); + foreach ($qualUserLevels as $qualUserLevel) { + // brechnet die Points nach der Payline $payline_points = $this->getPointsforPayline($qualUserLevel->paylines); $payline_points_qual_kp = $payline_points + $this->getRestQualKP(); - if($payline_points_qual_kp >= $qualUserLevel->qual_pp){ - //match payline_points erreicht, ist der akutelle Level für die Provision + if ($payline_points_qual_kp >= $qualUserLevel->qual_pp) { + // match payline_points erreicht, ist der akutelle Level für die Provision $this->b_user->payline_points = $payline_points; $this->b_user->payline_points_qual_kp = $payline_points_qual_kp; return $qualUserLevel; } - } - return NULL; + } + + return null; } - // PaylinePoints nach paylines / welche ebenen gezählt werden, 3,4,5,6 ... - private function getPointsforPayline($paylines){ + private function getPointsforPayline($paylines) + { $payline_points = 0; - for ($i=1; $i <= $paylines ; $i++) { - if(isset($this->business_lines[$i])){ + for ($i = 1; $i <= $paylines; $i++) { + if (isset($this->business_lines[$i])) { $payline_points += $this->business_lines[$i]->points; } } + return $payline_points; } - //wenn nicht erreicht, was wäre der nächste Provisionslevel? - private function setQualNextLevel(){ - if(!$this->isQualEqualLevel()){ + + // wenn nicht erreicht, was wäre der nächste Provisionslevel? + private function setQualNextLevel() + { + if (! $this->isQualEqualLevel()) { $qualUserLevelNext = UserLevel::where('id', '=', $this->b_user->qual_user_level['next_id'])->orderBy('qual_pp', 'asc')->first(); - if($qualUserLevelNext){ + if ($qualUserLevelNext) { $this->b_user->qual_user_level_next = $qualUserLevelNext->toArray(); } - } + } } - - private function setNextUserLevel(){ - // $this->b_user->payline_points_qual_kp // das sind die Payline Points + Rest KP - //$this->b_user->total_qual_pp = $this->total_pp + $this->getRestQualKP(); //hier werden alle Linien TP gezähle - //$this->b_user->total_qual_pp = $this->total_pp + $this->getRestQualKP(); //hier werden alle Linien TP gezähle - $nextQualUserLevel = UserLevel::where('qual_pp', '<=', $this->payline_points_qual_kp)->where('pos', '>', $this->user_level_active_pos)->orderBy('qual_pp', 'desc')->first(); - if($nextQualUserLevel && $this->isQualKP()){ - $this->b_user->next_qual_user_level = $nextQualUserLevel->toArray(); - }else{ - //wenn nicht erreicht, was wäre der nächste Karrierelevel? - $nextCanUserLevel = UserLevel::where('pos', '>', $this->user_level_active_pos)->orderBy('qual_pp', 'asc')->first(); - if($nextCanUserLevel){ - $this->b_user->next_can_user_level = $nextCanUserLevel->toArray(); - } + private function setNextUserLevel() + { + // $this->b_user->payline_points_qual_kp // das sind die Payline Points + Rest KP + // $this->b_user->total_qual_pp = $this->total_pp + $this->getRestQualKP(); //hier werden alle Linien TP gezähle + // $this->b_user->total_qual_pp = $this->total_pp + $this->getRestQualKP(); //hier werden alle Linien TP gezähle + + $nextQualUserLevel = UserLevel::where('qual_pp', '<=', $this->payline_points_qual_kp)->where('pos', '>', $this->user_level_active_pos)->orderBy('qual_pp', 'desc')->first(); + if ($nextQualUserLevel && $this->isQualKP()) { + $this->b_user->next_qual_user_level = $nextQualUserLevel->toArray(); + } else { + // wenn nicht erreicht, was wäre der nächste Karrierelevel? + $nextCanUserLevel = UserLevel::where('pos', '>', $this->user_level_active_pos)->orderBy('qual_pp', 'asc')->first(); + if ($nextCanUserLevel) { + $this->b_user->next_can_user_level = $nextCanUserLevel->toArray(); } + } } /*public function storeUser(){ @@ -286,75 +308,81 @@ class BusinessUserItem $obj->line = $line; $obj->points = $userItem->sales_volume_points_sum; $obj->parents = $temp; - $ret[] = $obj; + $ret[] = $obj; } return $ret; }*/ - public function readParentsBusinessUsers(){ - - $users = User::with('account')->select('users.*') - ->where('users.deleted_at', '=', null) - ->where('users.id', '!=', 1) - ->where('users.admin', "<", 4) - ->where('users.m_level', "!=", null) - ->where('users.m_sponsor', "=", $this->b_user->user_id) //<- need the id for parents / sponsors - ->where('users.payment_account', "!=", null) - ->where('users.active_date', "<=", $this->date->end_date) // wurde in dem Monat freigeschaltet - ->get(); + public function readParentsBusinessUsers() + { - if($users){ - foreach($users as $user){ - $BusinessUserItem = new BusinessUserItem($this->date); - $BusinessUserItem->makeUser($user->id); - $BusinessUserItem->addUserID(); - $this->businessUserItems[] = $BusinessUserItem; - } - } - foreach($this->businessUserItems as $businessUserItem){ - $businessUserItem->readParentsBusinessUsers(); + $users = User::with('account')->select('users.*') + ->where('users.deleted_at', '=', null) + ->where('users.id', '!=', 1) + ->where('users.admin', '<', 4) + ->where('users.m_level', '!=', null) + ->whereColumn('users.id', '!=', 'users.m_sponsor') + ->where('users.m_sponsor', '=', $this->b_user->user_id) // <- need the id for parents / sponsors + ->where('users.payment_account', '!=', null) + ->where('users.active_date', '<=', $this->date->end_date) // wurde in dem Monat freigeschaltet + ->get(); + + if ($users) { + foreach ($users as $user) { + $BusinessUserItem = new BusinessUserItem($this->date); + $BusinessUserItem->makeUser($user->id); + $BusinessUserItem->addUserID(); + $this->businessUserItems[] = $BusinessUserItem; } + } + foreach ($this->businessUserItems as $businessUserItem) { + $businessUserItem->readParentsBusinessUsers(); + } } - public function readStoredParentsBusinessUsers($structure){ + public function readStoredParentsBusinessUsers($structure) + { $parents = $this->findParentsBusinessOnStored($this->b_user->user_id, $structure); - if($parents){ - foreach($parents as $obj){ + if ($parents) { + foreach ($parents as $obj) { $BusinessUserItem = new BusinessUserItem($this->date); $BusinessUserItem->makeUser($obj->user_id); $BusinessUserItem->addUserID(); - $this->businessUserItems[] = $BusinessUserItem; + $this->businessUserItems[] = $BusinessUserItem; } - foreach($this->businessUserItems as $businessUserItem){ + foreach ($this->businessUserItems as $businessUserItem) { $businessUserItem->readStoredParentsBusinessUsers($parents); } } } - private function findParentsBusinessOnStored($user_id, $structures){ - if($structures){ - foreach($structures as $obj){ - if($user_id === $obj->user_id){ + private function findParentsBusinessOnStored($user_id, $structures) + { + if ($structures) { + foreach ($structures as $obj) { + if ($user_id === $obj->user_id) { return $obj->parents; } - if($obj->parents){ - if($ret = $this->findParentsBusinessOnStored($user_id, $obj->parents)){ + if ($obj->parents) { + if ($ret = $this->findParentsBusinessOnStored($user_id, $obj->parents)) { return $ret; - } - } + } + } } } + return null; } - public function checkSponsor($user){ + public function checkSponsor($user) + { - //check is store? has ID - if($this->b_user->isSave()){ + // check is store? has ID + if ($this->b_user->isSave()) { return; } - $sponsor = new stdClass(); + $sponsor = new stdClass; $sponsor->is_sponsor = false; $sponsor->user_id = false; @@ -364,34 +392,36 @@ class BusinessUserItem $sponsor->m_account = ''; $sponsor->full_name = 'Keinen Sponsor zugewiesen'; - if($user->m_sponsor){ + if ($user->m_sponsor) { - if($user->user_sponsor){ + if ($user->user_sponsor) { $sponsor->is_sponsor = true; $sponsor->user_id = $user->user_sponsor->id; - if($user->user_sponsor->account){ + if ($user->user_sponsor->account) { $sponsor->full_name = substr('Sponsor: '.$user->user_sponsor->account->first_name.' '.$user->user_sponsor->account->last_name.' | '.$user->user_sponsor->email.' | '.$user->user_sponsor->account->m_account, 0, 250); $sponsor->first_name = $user->user_sponsor->account->last_name; $sponsor->last_name = $user->user_sponsor->account->first_name; $sponsor->m_account = $user->user_sponsor->account->m_account; - }else{ + } else { $sponsor->full_name = 'Sponsor: '.$user->user_sponsor->email; } $sponsor->email = $user->user_sponsor->email; - }else{ - $sponsor->full_name = 'Sponsor wurde gelöscht.'; + } else { + $sponsor->full_name = 'Sponsor wurde gelöscht.'; } } $this->b_user->sponsor = $sponsor; - return; + } - public function isSave(){ + public function isSave() + { return $this->b_user->isSave(); } - public function __get($property) { - if($this->b_user === null){ + public function __get($property) + { + if ($this->b_user === null) { return null; } if (property_exists($this->b_user, $property)) { @@ -400,6 +430,5 @@ class BusinessUserItem if (isset($this->b_user->{$property})) { return $this->b_user->{$property}; } - } - + } } diff --git a/app/Services/BusinessPlan/BusinessUserItemOptimized.php b/app/Services/BusinessPlan/BusinessUserItemOptimized.php index 0cc5984..37f8c82 100644 --- a/app/Services/BusinessPlan/BusinessUserItemOptimized.php +++ b/app/Services/BusinessPlan/BusinessUserItemOptimized.php @@ -2,21 +2,20 @@ namespace App\Services\BusinessPlan; -use App\User; -use stdClass; -use Carbon\Carbon; -use App\Models\UserLevel; -use App\Models\UserBusiness; use App\Models\UserAccount; - +use App\Models\UserBusiness; +use App\Models\UserLevel; +use App\User; +use Carbon\Carbon; use Illuminate\Support\Facades\Log; +use stdClass; /** * Optimierte Version der BusinessUserItem Klasse - * + * * Hauptverbesserungen: * - makeUserFromModel() für bereits geladene User-Objekte - * - Bessere Error-Behandlung mit Logging + * - Bessere Error-Behandlung mit Logging * - Optimierte Datenbankzugriffe durch Relations-Nutzung * - Input-Validierung und Boundary-Checks */ @@ -25,10 +24,15 @@ class BusinessUserItemOptimized public $businessUserItems = []; private $date; + private $b_user; + private ?TreeCalcBotOptimized $treeCalcBot = null; + private $user_level_active_pos; + private $needsQualificationRecalculation = false; + private $qualificationCalculated = false; public function __construct($date, ?TreeCalcBotOptimized $treeCalcBot = null) @@ -36,6 +40,7 @@ class BusinessUserItemOptimized $this->date = $date; $this->treeCalcBot = $treeCalcBot; $this->businessUserItems = []; // Initialize array + return $this; } @@ -44,18 +49,17 @@ class BusinessUserItemOptimized return $this->qualificationCalculated; } - /** * Erstellt BusinessUser aus User-ID (Original-Methode für Rückwärtskompatibilität) - * - * @param int $user_id Die User-ID - * @param bool $forceLiveCalculation Erzwingt Live-Berechnung und überspringt gespeicherte Daten + * + * @param int $user_id Die User-ID + * @param bool $forceLiveCalculation Erzwingt Live-Berechnung und überspringt gespeicherte Daten */ public function makeUser($user_id, bool $forceLiveCalculation = false): void { try { // Prüfe nur nach gespeicherten Daten, wenn keine Live-Berechnung erzwungen wird - if (!$forceLiveCalculation) { + if (! $forceLiveCalculation) { $this->b_user = UserBusiness::where('user_id', $user_id) ->where('month', $this->date->month) ->where('year', $this->date->year) @@ -85,8 +89,9 @@ class BusinessUserItemOptimized // Lade User mit Relations (weniger effizient als makeUserFromModel) $user = User::with(['account', 'user_level'])->find($user_id); - if (!$user) { + if (! $user) { \Log::warning("BusinessUserItem: User not found: {$user_id}"); + return; } @@ -98,7 +103,7 @@ class BusinessUserItemOptimized $this->calcQualPP(); } } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error creating user {$user_id}: " . $e->getMessage()); + \Log::error("BusinessUserItem: Error creating user {$user_id}: ".$e->getMessage()); throw $e; } } @@ -106,20 +111,20 @@ class BusinessUserItemOptimized /** * NEUE OPTIMIERTE METHODE: Erstellt BusinessUser aus bereits geladenem User-Objekt * Konsistent zur ursprünglichen makeUser Logik - prüft explizit nach bereits berechneten Daten - * - * @param User $user Das User-Model - * @param bool $forceLiveCalculation Erzwingt Live-Berechnung und überspringt gespeicherte Daten + * + * @param User $user Das User-Model + * @param bool $forceLiveCalculation Erzwingt Live-Berechnung und überspringt gespeicherte Daten */ public function makeUserFromModel(User $user, bool $forceLiveCalculation = false): void { \Log::debug("BusinessUserItemOptimized: makeUserFromModel for user {$user->id} ({$this->date->month}/{$this->date->year})"); try { - if (!$user || !$user->id) { + if (! $user || ! $user->id) { throw new \InvalidArgumentException('Invalid user model provided'); } // Prüfe nur nach gespeicherten Daten, wenn keine Live-Berechnung erzwungen wird - if (!$forceLiveCalculation) { + if (! $forceLiveCalculation) { $this->b_user = UserBusiness::where('user_id', $user->id) ->where('month', $this->date->month) ->where('year', $this->date->year) @@ -147,10 +152,10 @@ class BusinessUserItemOptimized // WICHTIG: Bei Live-Berechnung auch Level-Qualifikationsdaten berechnen // (nicht bei forceLiveCalculation=false, da dort gespeicherte Daten bevorzugt werden) if ($forceLiveCalculation) { - //$this->calcQualPP(); + // $this->calcQualPP(); } } catch (\Exception $e) { - \Log::error("BusinessUserItemOptimized: Error creating user from model {$user->id}: " . $e->getMessage()); + \Log::error("BusinessUserItemOptimized: Error creating user from model {$user->id}: ".$e->getMessage()); throw $e; } } @@ -170,7 +175,7 @@ class BusinessUserItemOptimized $this->user_level_active_pos = $user_level_active ? $user_level_active->pos : 0; // Neues UserBusiness Objekt erstellen - $this->b_user = new UserBusiness(); + $this->b_user = new UserBusiness; // Account-Daten (mit intelligentem Laden und Error-Handling) $account = $this->getAccountForUser($user); @@ -208,7 +213,7 @@ class BusinessUserItemOptimized 'qual_kp' => $user_level_active ? max(0, $user_level_active->qual_kp) : 0, 'qual_pp' => $user_level_active ? max(0, $user_level_active->qual_pp) : 0, - 'active_growth_bonus' => $user_level_active ? (float)$user_level_active->growth_bonus : 0, + 'active_growth_bonus' => $user_level_active ? (float) $user_level_active->growth_bonus : 0, 'growth_bonus_details' => null, // Initialisierung @@ -230,7 +235,7 @@ class BusinessUserItemOptimized $this->b_user->commission_shop_sales = $calculatedCommission; \Log::debug("BusinessUserItem: Created optimized user {$user->id} for {$this->date->month}/{$this->date->year} - Shop commission: {$calculatedCommission} (Volume: {$shopVolume}, Margin: {$shopMargin}%)"); - \Log::debug("BusinessUserItemOptimized: b_user: " . json_encode($this->b_user)); + \Log::debug('BusinessUserItemOptimized: b_user: '.json_encode($this->b_user)); } /** @@ -270,7 +275,7 @@ class BusinessUserItemOptimized \Log::debug("BusinessUserItem: Enriched stored data for user {$user->id} with current user model data"); } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error enriching stored data for user {$user->id}: " . $e->getMessage()); + \Log::error("BusinessUserItem: Error enriching stored data for user {$user->id}: ".$e->getMessage()); } } @@ -289,12 +294,12 @@ class BusinessUserItemOptimized 'sales_volume_points_TP_sum', 'sales_volume_total', 'sales_volume_total_shop', - 'sales_volume_total_sum' + 'sales_volume_total_sum', ]; $needsUpdate = false; foreach ($fieldsToUpdate as $field) { - if (!isset($this->b_user->{$field}) || $this->b_user->{$field} === null || $this->b_user->{$field} === 0) { + if (! isset($this->b_user->{$field}) || $this->b_user->{$field} === null || $this->b_user->{$field} === 0) { $newValue = $this->getUserSalesVolumeOptimized($user, $field); $this->b_user->{$field} = $newValue; @@ -306,7 +311,7 @@ class BusinessUserItemOptimized } // Aktualisiere Shop Commission falls nötig - if (!isset($this->b_user->commission_shop_sales) || $this->b_user->commission_shop_sales === 0) { + if (! isset($this->b_user->commission_shop_sales) || $this->b_user->commission_shop_sales === 0) { $shopVolume = (float) $this->b_user->sales_volume_total_shop; $shopMargin = (float) $this->b_user->margin_shop; @@ -322,7 +327,7 @@ class BusinessUserItemOptimized \Log::info("BusinessUserItem: Updated sales volume fields for user {$user->id}"); } } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error updating sales volume fields for user {$user->id}: " . $e->getMessage()); + \Log::error("BusinessUserItem: Error updating sales volume fields for user {$user->id}: ".$e->getMessage()); } } @@ -334,18 +339,18 @@ class BusinessUserItemOptimized { try { // Prüfe ob Level-Qualifikationsdaten vorhanden sind - $hasNextQual = !empty($this->b_user->next_qual_user_level); - $hasNextCan = !empty($this->b_user->next_can_user_level); - $hasQualUserLevel = !empty($this->b_user->qual_user_level); + $hasNextQual = ! empty($this->b_user->next_qual_user_level); + $hasNextCan = ! empty($this->b_user->next_can_user_level); + $hasQualUserLevel = ! empty($this->b_user->qual_user_level); // Wenn Level-Qualifikationsdaten fehlen, führe Neuberechnung durch - if (!$hasNextQual && !$hasNextCan && !$hasQualUserLevel) { + if (! $hasNextQual && ! $hasNextCan && ! $hasQualUserLevel) { \Log::debug("BusinessUserItem: Level qualification data missing for user {$this->b_user->user_id}, triggering recalculation"); // Setze Flag für notwendige Neuberechnung $this->needsQualificationRecalculation = true; } } catch (\Exception $e) { - \Log::warning("BusinessUserItem: Error validating level qualification data for user {$this->b_user->user_id}: " . $e->getMessage()); + \Log::warning("BusinessUserItem: Error validating level qualification data for user {$this->b_user->user_id}: ".$e->getMessage()); } } @@ -355,14 +360,15 @@ class BusinessUserItemOptimized private function calculateActiveAccount(User $user): bool { try { - if (!$user->payment_account) { + if (! $user->payment_account) { return false; } // Verwende aktuelles Datum, nicht das Berechnungs-Startdatum return Carbon::parse($user->payment_account)->gt(Carbon::now()); } catch (\Exception $e) { - \Log::warning("BusinessUserItem: Error calculating active account for user {$user->id}: " . $e->getMessage()); + \Log::warning("BusinessUserItem: Error calculating active account for user {$user->id}: ".$e->getMessage()); + return false; } } @@ -378,12 +384,12 @@ class BusinessUserItemOptimized // Log nur bei ersten Aufruf für diesen User (Performance) static $loggedUsers = []; - if (!isset($loggedUsers[$user->id])) { + if (! isset($loggedUsers[$user->id])) { $loggedUsers[$user->id] = true; // Prüfe ob UserSalesVolume Daten existieren $userSalesVolume = $user->getUserSalesVolume($this->date->month, $this->date->year, 'first'); - if (!$userSalesVolume) { + if (! $userSalesVolume) { \Log::info("BusinessUserItem: No UserSalesVolume found for user {$user->id} in {$this->date->month}/{$this->date->year}"); // Prüfe neueste verfügbare Daten @@ -404,7 +410,8 @@ class BusinessUserItemOptimized return $value; } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error getting sales volume {$field} for user {$user->id}: " . $e->getMessage()); + \Log::error("BusinessUserItem: Error getting sales volume {$field} for user {$user->id}: ".$e->getMessage()); + return 0; // Sicherer Fallback } } @@ -422,7 +429,7 @@ class BusinessUserItemOptimized $this->treeCalcBot->addProcessedUserId($this->b_user->user_id); } else { // Fallback für Rückwärtskompatibilität - sollte in Logs sichtbar sein - \Log::warning("BusinessUserItemOptimized: TreeCalcBotOptimized Referenz fehlt für User ID: " . $this->b_user->user_id); + \Log::warning('BusinessUserItemOptimized: TreeCalcBotOptimized Referenz fehlt für User ID: '.$this->b_user->user_id); } } @@ -441,7 +448,7 @@ class BusinessUserItemOptimized */ public function initBusinessLines(): void { - if (!isset($this->b_user->business_lines) || !is_array($this->b_user->business_lines)) { + if (! isset($this->b_user->business_lines) || ! is_array($this->b_user->business_lines)) { $this->b_user->business_lines = []; } } @@ -456,8 +463,9 @@ class BusinessUserItemOptimized public function addBusinessLinePoints($line, $points) { - if (!isset($this->b_user->business_lines[$line])) { + if (! isset($this->b_user->business_lines[$line])) { \Log::warning("BusinessUserItem: Trying to add points to non-existent line {$line}"); + return; } @@ -468,7 +476,7 @@ class BusinessUserItemOptimized $obj['points'] = ($obj['points'] ?? 0) + (float) $points; } else { // Ensure it's an object - if (!is_object($obj)) { + if (! is_object($obj)) { $obj = (object) $obj; } $obj->points = ($obj->points ?? 0) + (float) $points; @@ -490,18 +498,19 @@ class BusinessUserItemOptimized return []; } - if (!$this->isQualLevel() || empty($this->b_user->qual_user_level['growth_bonus'])) { + if (! $this->isQualLevel() || empty($this->b_user->qual_user_level['growth_bonus'])) { return []; } try { - $calculator = new GrowthBonusCalculator(); + $calculator = new GrowthBonusCalculator; // Array zu Object konvertieren für Calculator $qualData = (object) $this->b_user->qual_user_level; return $calculator->getCalculationDetails($this, $qualData); } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error getting growth bonus breakdown: " . $e->getMessage()); + \Log::error('BusinessUserItem: Error getting growth bonus breakdown: '.$e->getMessage()); + return []; } } @@ -519,12 +528,12 @@ class BusinessUserItemOptimized return []; } - if (!$this->isQualLevel() || empty($this->b_user->qual_user_level['growth_bonus'])) { + if (! $this->isQualLevel() || empty($this->b_user->qual_user_level['growth_bonus'])) { return []; } // Use stored details if available (avoid recalculation) - if (!empty($this->b_user->growth_bonus_details)) { + if (! empty($this->b_user->growth_bonus_details)) { if (is_object($this->b_user->growth_bonus_details) && method_exists($this->b_user->growth_bonus_details, 'toArray')) { return $this->b_user->growth_bonus_details->toArray(); } @@ -538,13 +547,14 @@ class BusinessUserItemOptimized } try { - $calculator = new GrowthBonusCalculator(); + $calculator = new GrowthBonusCalculator; // Array zu Object konvertieren für Calculator $qualData = (object) $this->b_user->qual_user_level; return $calculator->getMatrixDetails($this, $qualData); } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error getting growth bonus matrix: " . $e->getMessage()); + \Log::error('BusinessUserItem: Error getting growth bonus matrix: '.$e->getMessage()); + return []; } } @@ -556,12 +566,12 @@ class BusinessUserItemOptimized public function isQualKP(): bool { - return ($this->b_user->sales_volume_points_KP_sum >= $this->b_user->qual_kp); + return $this->b_user->sales_volume_points_KP_sum >= $this->b_user->qual_kp; } public function isQualLevel(): bool { - return !empty($this->b_user->qual_user_level); + return ! empty($this->b_user->qual_user_level); } /** @@ -587,15 +597,15 @@ class BusinessUserItemOptimized /** * Gibt den Growth Bonus basierend auf dem ERREICHTEN Qualifikations-Level zurück. - * + * * WICHTIG: Diese Methode gibt den Growth Bonus nur zurück, wenn der Partner * in dem Monat tatsächlich das entsprechende Level qualifiziert hat. * Das ist entscheidend für die korrekte Differenz-Berechnung im GrowthBonusCalculator. - * + * * Die Methode funktioniert sowohl für: * - Live-berechnete Daten (qualificationCalculated = true) * - Gespeicherte/geladene Daten aus UserBusiness (qual_user_level bereits vorhanden) - * + * * @return float Der Growth Bonus des erreichten Qualifikations-Levels (0 wenn nicht qualifiziert) */ public function getQualifiedGrowthBonus(): float @@ -627,23 +637,26 @@ class BusinessUserItemOptimized public function isQualEqualLevel(): bool { - if (!$this->b_user->qual_user_level) { + if (! $this->b_user->qual_user_level) { return false; } - return ($this->b_user->m_level_id == $this->b_user->qual_user_level['id']); + + return $this->b_user->m_level_id == $this->b_user->qual_user_level['id']; } public function getQualPaylines(): int { - if (!$this->b_user->qual_user_level) { + if (! $this->b_user->qual_user_level) { return 0; } + return (int) $this->b_user->qual_user_level['paylines']; } public function getRestQualKP(): float { $ret = $this->b_user->sales_volume_points_KP_sum - $this->b_user->qual_kp; + return max(0, $ret); // Boundary-Check } @@ -661,7 +674,7 @@ class BusinessUserItemOptimized public function calcQualPP($force = false): void { - if ($this->qualificationCalculated && !$force) { + if ($this->qualificationCalculated && ! $force) { return; } @@ -670,9 +683,9 @@ class BusinessUserItemOptimized try { $qualUserLevel = $this->calcuQualLevel(); - \Log::debug("BusinessUserItemOptimized: calcQualPP for user {$this->b_user->user_id}: " . json_encode($qualUserLevel)); + \Log::debug("BusinessUserItemOptimized: calcQualPP for user {$this->b_user->user_id}: ".json_encode($qualUserLevel)); if ($qualUserLevel !== null) { - //das erreichte level setzen + // das erreichte level setzen $this->b_user->qual_user_level = $qualUserLevel->toArray(); // Wichtig: Setze die qual_kp und qual_pp des erreichten Levels im b_user Objekt // Diese Werte ändern sich je nach erreichtem Level und müssen hier aktualisiert werden @@ -681,17 +694,17 @@ class BusinessUserItemOptimized \Log::debug("BusinessUserItemOptimized: Set qual_kp={$qualUserLevel->qual_kp}, qual_pp={$qualUserLevel->qual_pp} for user {$this->b_user->user_id}"); - //next_qual_user_level nächster qualifizierten level + // next_qual_user_level nächster qualifizierten level $this->setNextUserLevel($force); - //qual_user_level_next nächste Provisions-Stufe, + // qual_user_level_next nächste Provisions-Stufe, $this->setQualNextLevel($force); - //provisionen berechnen + // provisionen berechnen $this->calculateCommissions($qualUserLevel); } else { $this->setFirstQualLevel(); } } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error calculating qualifications for user {$this->b_user->user_id}: " . $e->getMessage()); + \Log::error("BusinessUserItem: Error calculating qualifications for user {$this->b_user->user_id}: ".$e->getMessage()); } } @@ -708,7 +721,7 @@ class BusinessUserItemOptimized for ($i = 1; $i <= $qualUserLevel->paylines; $i++) { if (isset($this->b_user->business_lines[$i])) { $object = $this->b_user->business_lines[$i]; - $margin = (float) $this->b_user->qual_user_level['pr_line_' . $i]; + $margin = (float) $this->b_user->qual_user_level['pr_line_'.$i]; // Handle both array and object types (JSON deserialization inconsistency) if (is_array($object)) { @@ -730,7 +743,7 @@ class BusinessUserItemOptimized } // Growth Bonus - if (!empty($qualUserLevel->growth_bonus)) { + if (! empty($qualUserLevel->growth_bonus)) { // Fallback für alte Monate (vor November 2025) // Stichtag: 01.11.2025 - Alles davor nutzt die Legacy-Berechnung $isLegacy = ($this->date->year < 2025) || ($this->date->year == 2025 && $this->date->month < 11); @@ -741,10 +754,9 @@ class BusinessUserItemOptimized } else { // Neue Logik ab Dezember 2025 - delegated to new Calculator service try { - $growthCalculator = new GrowthBonusCalculator(); + $growthCalculator = new GrowthBonusCalculator; $commission_growth_total = $growthCalculator->calculate($this, $qualUserLevel); - // Calculate matrix details for storage and total sum // This ensures that the stored details match the calculated total exactly $matrixDetails = $growthCalculator->getMatrixDetails($this, $qualUserLevel); @@ -752,7 +764,7 @@ class BusinessUserItemOptimized // Store details in the model so they can be retrieved later without recalculation $this->b_user->growth_bonus_details = $matrixDetails; } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error calculating growth bonus for user {$this->b_user->user_id}: " . $e->getMessage()); + \Log::error("BusinessUserItem: Error calculating growth bonus for user {$this->b_user->user_id}: ".$e->getMessage()); // Fallback to 0 if calculation fails $commission_growth_total = 0; $this->b_user->growth_bonus_details = null; @@ -789,7 +801,7 @@ class BusinessUserItemOptimized $object['growth_bonus'] = true; $commission_growth_total += $object['commission']; } else { - if (!is_object($object)) { + if (! is_object($object)) { $object = (object) $object; } $points = (float) ($object->points ?? 0); @@ -824,13 +836,12 @@ class BusinessUserItemOptimized foreach ($qualUserLevels as $qualUserLevel) { // Berechne die Payline-Punkte für die spezifischen Paylines dieses Levels $payline_points = $this->getPointsforPayline($qualUserLevel->paylines); - \Log::debug("BusinessUserItemOptimized: payline_points: " . $payline_points); + \Log::debug('BusinessUserItemOptimized: payline_points: '.$payline_points); // WICHTIG: Berechne die Rest-KP basierend auf der qual_kp DES AKTUELL GEPRÜFTEN LEVELS // nicht der qual_kp des bereits gesetzten Levels (das war der Fehler!) $rest_kp = max(0, $this->b_user->sales_volume_points_KP_sum - $qualUserLevel->qual_kp); $payline_points_qual_kp = $payline_points + $rest_kp; - // Prüfe ob die Qualifikation für diesen spezifischen Level erfüllt ist if ($payline_points_qual_kp >= $qualUserLevel->qual_pp) { // Setze die berechneten Werte @@ -848,12 +859,13 @@ class BusinessUserItemOptimized } \Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} does not qualify for any level"); + return null; } private function getPointsforPayline($paylines): float { - \Log::debug("BusinessUserItemOptimized: getPointsforPayline for user {$this->b_user->user_id} ({$this->date->month}/{$this->date->year}) with paylines: " . $paylines . " and business_lines: " . json_encode($this->b_user->business_lines)); + \Log::debug("BusinessUserItemOptimized: getPointsforPayline for user {$this->b_user->user_id} ({$this->date->month}/{$this->date->year}) with paylines: ".$paylines.' and business_lines: '.json_encode($this->b_user->business_lines)); $payline_points = 0; for ($i = 1; $i <= $paylines; $i++) { if (isset($this->b_user->business_lines[$i])) { @@ -867,18 +879,20 @@ class BusinessUserItemOptimized } } } + return $payline_points; } + /** * Setzt das nächste Provision-Level * Wenn das aktuelle Level nicht erreicht ist, dann wird bei aktuelle Provisions-Stufe die erreichte level angezeigt und berechnet - * Zur Info wird das nächste level angezeigt, der folgt, sonst leer + * Zur Info wird das nächste level angezeigt, der folgt, sonst leer */ private function setQualNextLevel($force = false): void { - //ist der level nicht das aktuelle level, dann sucht es den nächsten level - //isQualEqualLevel wenn das erreichte level das akutelle user level ist. - if (!$this->isQualEqualLevel() && $this->b_user->qual_user_level['next_id'] != null) { + // ist der level nicht das aktuelle level, dann sucht es den nächsten level + // isQualEqualLevel wenn das erreichte level das akutelle user level ist. + if (! $this->isQualEqualLevel() && $this->b_user->qual_user_level['next_id'] != null) { $qualUserLevelNext = UserLevel::where('id', '=', $this->b_user->qual_user_level['next_id']) ->orderBy('qual_pp', 'asc') ->first(); @@ -910,10 +924,11 @@ class BusinessUserItemOptimized ->first(); // Wenn kein nächster Level existiert, beende - if (!$nextLevel) { + if (! $nextLevel) { $this->b_user->next_qual_user_level = null; $this->b_user->next_can_user_level = null; \Log::debug("BusinessUserItemOptimized: No next level found for user {$this->b_user->user_id} (already at highest level)"); + return; } @@ -935,6 +950,7 @@ class BusinessUserItemOptimized $this->b_user->next_can_user_level = $levelData; $this->b_user->next_qual_user_level = null; \Log::debug("BusinessUserItemOptimized: User {$this->b_user->user_id} does not meet KP requirement for next level {$nextLevel->name} ({$this->b_user->sales_volume_points_KP_sum} < {$nextLevel->qual_kp})"); + return; } @@ -982,7 +998,7 @@ class BusinessUserItemOptimized 'sales_volume_points_KP_sum' => 'sales_volume_points_KP_sum', 'sales_volume_points_TP_sum' => 'sales_volume_points_TP_sum', 'business_lines' => 'business_lines', - 'user_id' => 'user_id' + 'user_id' => 'user_id', ]; if (isset($legacyMap[$name]) && isset($this->b_user->{$legacyMap[$name]})) { @@ -1003,7 +1019,7 @@ class BusinessUserItemOptimized return; } - $sponsor = new stdClass(); + $sponsor = new stdClass; $sponsor->is_sponsor = false; $sponsor->user_id = false; $sponsor->first_name = ''; @@ -1019,9 +1035,9 @@ class BusinessUserItemOptimized if ($user->user_sponsor->account) { $sponsor->full_name = substr( - 'Sponsor: ' . $user->user_sponsor->account->first_name . ' ' . - $user->user_sponsor->account->last_name . ' | ' . - $user->user_sponsor->email . ' | ' . + 'Sponsor: '.$user->user_sponsor->account->first_name.' '. + $user->user_sponsor->account->last_name.' | '. + $user->user_sponsor->email.' | '. $user->user_sponsor->account->m_account, 0, 250 @@ -1030,7 +1046,7 @@ class BusinessUserItemOptimized $sponsor->last_name = $user->user_sponsor->account->last_name; $sponsor->m_account = $user->user_sponsor->account->m_account; } else { - $sponsor->full_name = 'Sponsor: ' . $user->user_sponsor->email; + $sponsor->full_name = 'Sponsor: '.$user->user_sponsor->email; } $sponsor->email = $user->user_sponsor->email; } else { @@ -1040,7 +1056,7 @@ class BusinessUserItemOptimized $this->b_user->sponsor = $sponsor; } catch (\Exception $e) { - Log::error("BusinessUserItem: Error checking sponsor for user {$user->id}: " . $e->getMessage()); + Log::error("BusinessUserItem: Error checking sponsor for user {$user->id}: ".$e->getMessage()); } } @@ -1054,6 +1070,7 @@ class BusinessUserItemOptimized $maxDepth = 20; if ($depth > $maxDepth) { Log::warning("BusinessUserItem: Maximale Rekursionstiefe ({$maxDepth}) erreicht für User {$this->b_user->user_id}"); + return; } @@ -1065,6 +1082,7 @@ class BusinessUserItemOptimized ->where('users.id', '!=', 1) ->where('users.admin', '<', 4) ->where('users.m_level', '!=', null) + ->whereColumn('users.id', '!=', 'users.m_sponsor') ->where('users.m_sponsor', '=', $this->b_user->user_id) ->where('users.payment_account', '!=', null) ->where('users.active_date', '<=', $this->date->end_date) @@ -1075,6 +1093,7 @@ class BusinessUserItemOptimized // KRITISCHER BUGFIX: Prüfe ob User bereits verarbeitet wurde if ($this->treeCalcBot && $this->treeCalcBot->isUserProcessed($user->id)) { Log::debug("BusinessUserItem: Überspringe bereits verarbeiteten User {$user->id} (zirkuläre Referenz verhindert)"); + continue; } @@ -1090,7 +1109,7 @@ class BusinessUserItemOptimized $businessUserItem->readParentsBusinessUsers($forceLiveCalculation, $depth + 1); } } catch (\Exception $e) { - Log::error("BusinessUserItem: Error reading parent users for {$this->b_user->user_id} at depth {$depth}: " . $e->getMessage()); + Log::error("BusinessUserItem: Error reading parent users for {$this->b_user->user_id} at depth {$depth}: ".$e->getMessage()); } } @@ -1104,6 +1123,7 @@ class BusinessUserItemOptimized $maxDepth = 50; if ($depth > $maxDepth) { Log::warning("BusinessUserItem: Maximale Rekursionstiefe ({$maxDepth}) erreicht für gespeicherte User {$this->b_user->user_id}"); + return; } @@ -1115,6 +1135,7 @@ class BusinessUserItemOptimized // KRITISCHER BUGFIX: Prüfe ob User bereits verarbeitet wurde if ($this->treeCalcBot && $this->treeCalcBot->isUserProcessed($obj->user_id)) { Log::debug("BusinessUserItem: Überspringe bereits verarbeiteten gespeicherten User {$obj->user_id} (zirkuläre Referenz verhindert)"); + continue; } @@ -1129,7 +1150,7 @@ class BusinessUserItemOptimized } } } catch (\Exception $e) { - Log::error("BusinessUserItem: Error reading stored parent users at depth {$depth}: " . $e->getMessage()); + Log::error("BusinessUserItem: Error reading stored parent users at depth {$depth}: ".$e->getMessage()); } } @@ -1138,7 +1159,7 @@ class BusinessUserItemOptimized */ private function findParentsBusinessOnStored($user_id, $structures) { - if (!$structures) { + if (! $structures) { return null; } @@ -1147,7 +1168,7 @@ class BusinessUserItemOptimized return $obj->parents ?? null; } - if (!empty($obj->parents)) { + if (! empty($obj->parents)) { $result = $this->findParentsBusinessOnStored($user_id, $obj->parents); if ($result) { return $result; @@ -1175,6 +1196,7 @@ class BusinessUserItemOptimized if ($this->b_user && isset($this->b_user->qual_user_level) && $this->b_user->qual_user_level) { return $this->b_user->qual_user_level['paylines'] ?? 0; } + return 0; } @@ -1189,6 +1211,7 @@ class BusinessUserItemOptimized return $object->growth_bonus > 0; } } + return false; } @@ -1204,13 +1227,15 @@ class BusinessUserItemOptimized $account = $user->account; if ($account instanceof UserAccount) { \Log::debug("BusinessUserItem: Using pre-loaded account for user {$user->id}"); + return $account; } } // Wenn User keine account_id hat, gibt es definitiv kein Account - if (!$user->account_id) { + if (! $user->account_id) { \Log::info("BusinessUserItem: User {$user->id} has no account_id - no account available"); + return null; } @@ -1218,15 +1243,18 @@ class BusinessUserItemOptimized \Log::info("BusinessUserItem: Loading account for user {$user->id} (account_id: {$user->account_id})"); $account = UserAccount::find($user->account_id); - if (!$account) { + if (! $account) { \Log::warning("BusinessUserItem: Account {$user->account_id} not found for user {$user->id}"); + return null; } \Log::debug("BusinessUserItem: Successfully loaded account {$account->id} for user {$user->id}"); + return $account; } catch (\Exception $e) { - \Log::error("BusinessUserItem: Error loading account for user {$user->id}: " . $e->getMessage()); + \Log::error("BusinessUserItem: Error loading account for user {$user->id}: ".$e->getMessage()); + return null; } } diff --git a/app/Services/BusinessPlan/SalesPointsVolume.php b/app/Services/BusinessPlan/SalesPointsVolume.php index 394f2e2..ae1094d 100644 --- a/app/Services/BusinessPlan/SalesPointsVolume.php +++ b/app/Services/BusinessPlan/SalesPointsVolume.php @@ -4,6 +4,7 @@ namespace App\Services\BusinessPlan; use App\Models\ShoppingOrder; use App\Models\UserSalesVolume; +use App\Services\Incentive\IncentiveTracker; use App\Services\Util; use App\User; use stdClass; @@ -122,7 +123,7 @@ class SalesPointsVolume } } - public static function addSalesPointsVolumeUser(ShoppingOrder $shoppingOrder) + public static function User(ShoppingOrder $shoppingOrder) { /* @@ -311,6 +312,9 @@ class SalesPointsVolume // Neuberechnung für aktuellen Monat self::reCalculateSalesPointsVolume($original_sales_volume->user_id, $month, $year); + // Incentive: Track storno + IncentiveTracker::trackStorno($original_sales_volume, $cancellation_sales_volume); + \Log::info('Punktekorrektur für Stornorechnung durchgeführt', [ 'original_invoice_id' => $original_sales_volume->user_invoice_id, 'cancellation_invoice_id' => $cancellation_invoice_id, diff --git a/app/Services/DcHelper.php b/app/Services/DcHelper.php index 758c607..1809319 100644 --- a/app/Services/DcHelper.php +++ b/app/Services/DcHelper.php @@ -1,108 +1,111 @@ - ['name' => 'German', 'script' => 'Latn', 'native' => 'Deutsch', 'regional' => 'de_DE'], - 'en' => ['name' => 'English', 'script' => 'Latn', 'native' => 'English', 'regional' => 'en_GB'], - 'es' => ['name' => 'Spanish', 'script' => 'Latn', 'native' => 'español', 'regional' => 'es_ES'], + public static function getTransChange() + { + + $langs = [ + 'de' => ['name' => 'German', 'script' => 'Latn', 'native' => 'Deutsch', 'regional' => 'de_DE'], + 'en' => ['name' => 'English', 'script' => 'Latn', 'native' => 'English', 'regional' => 'en_GB'], + 'es' => ['name' => 'Spanish', 'script' => 'Latn', 'native' => 'español', 'regional' => 'es_ES'], ]; - + $ret = []; - foreach($langs as $code => $lang){ - $ret[strtolower($code)] = strtolower($lang['native']); + foreach ($langs as $code => $lang) { + $ret[strtolower($code)] = strtolower($lang['native']); } + return $ret; } - public static function makeNestableList($category_id){ + public static function makeNestableList($category_id) + { $tags = DcTag::where('category_id', $category_id)->orderBy('pos')->get(); - $out = ""; - foreach ($tags as $tag){ - - $out .= '
  • - - - ' . ($tag->active ? '' : '') . ' + $out = ''; + foreach ($tags as $tag) { + + $out .= '
  • +
    '.$tag->name.'
  • '; } + return $out; } + public static function makeNestableListCheckbox($category_id, $file_id) + { - public static function makeNestableListCheckbox($category_id, $file_id){ - $tags = DcTag::where('category_id', $category_id)->orderBy('pos')->get(); $file_tags = DcFileTag::where('file_id', $file_id)->get(); - - $search = array(); + + $search = []; foreach ($file_tags as $file_tag) { $search[] = $file_tag->tag_id; } - - $out = ""; - foreach ($tags as $tag){ - $out .= '
  • + + $out = ''; + foreach ($tags as $tag) { + $out .= '
  • '; } + return $out; } - public static function makeFilterList($filter_list, $split = false, $chunk = false){ + public static function makeFilterList($filter_list, $split = false, $chunk = false) + { - $out = ""; + $out = ''; $splitOn = 0; - if($split){ + if ($split) { $count = count($filter_list); - if($count > 0){ + if ($count > 0) { $splitOn = intval(ceil($count / $split)); $filter_chunk = array_chunk($filter_list, $splitOn, true); $filter_list = $filter_chunk[$chunk]; } - } - - foreach($filter_list as $category_id => $value){ + + foreach ($filter_list as $category_id => $value) { $out .= ''; $out .= ''; - } + return $out; - } - - private function getAttributesOptions($ids = array(), $all = true){ - $ret = ""; - - return $ret; } + private function getAttributesOptions($ids = [], $all = true) + { + $ret = ''; -} \ No newline at end of file + return $ret; + } +} diff --git a/app/Services/HTMLHelper.php b/app/Services/HTMLHelper.php index 050d14f..6450875 100644 --- a/app/Services/HTMLHelper.php +++ b/app/Services/HTMLHelper.php @@ -117,7 +117,9 @@ class HTMLHelper foreach ($values as $value) { $attr = ($value == $default) ? 'selected="selected"' : ''; $str = self::getAboStrLang($value); - $ret .= '\n'; + $nextDate = AboHelper::getFirstAboDate(now(), $value); + $daysUntil = AboHelper::calendarDaysUntil(now(), $nextDate); + $ret .= '\n'; } return $ret; diff --git a/app/Services/Incentive/IncentiveCalculationService.php b/app/Services/Incentive/IncentiveCalculationService.php new file mode 100644 index 0000000..6e08a65 --- /dev/null +++ b/app/Services/Incentive/IncentiveCalculationService.php @@ -0,0 +1,54 @@ + 0, 'errors' => 0]; + + $participants = $incentive->participants()->with('user')->get(); + + foreach ($participants as $participant) { + try { + $this->recalculateParticipant($participant, $force); + $stats['participants']++; + } catch (\Throwable $e) { + $stats['errors']++; + Log::error('IncentiveCalculation error for participant '.$participant->id.': '.$e->getMessage()); + } + } + + IncentiveTracker::updateRanking($incentive); + + return $stats; + } + + /** + * Recalculate a single participant. + * Force: Kompletter Neuaufbau aus Quelldaten. + * Normal: Neuberechnung aus vorhandenen Tracking-Tabellen + Log. + */ + public function recalculateParticipant(IncentiveParticipant $participant, bool $force = false): void + { + if (! $participant->user) { + return; + } + + if ($force) { + $participant->rebuildFromSourceTables()->save(); + } else { + $participant->recalculateFromTrackingTables()->save(); + } + } +} diff --git a/app/Services/Incentive/IncentivePointsLogRepairService.php b/app/Services/Incentive/IncentivePointsLogRepairService.php new file mode 100644 index 0000000..b32af22 --- /dev/null +++ b/app/Services/Incentive/IncentivePointsLogRepairService.php @@ -0,0 +1,353 @@ +incentive; + if (! $incentive) { + return 0; + } + + $added = 0; + + $candidates = User::query() + ->where('m_sponsor', $participant->user_id) + ->whereBetween('created_at', [ + $incentive->qualification_start, + $incentive->qualification_end->copy()->endOfDay(), + ]) + ->whereHas('shopping_orders', function ($q) { + $q->wherePaidRegistrationIncludesStarterKit(); + }) + ->get(); + + foreach ($candidates as $partner) { + if (IncentiveNewPartner::query() + ->where('participant_id', $participant->id) + ->where('user_id', $partner->id) + ->exists()) { + continue; + } + + $order = ShoppingOrder::query() + ->where('auth_user_id', $partner->id) + ->wherePaidRegistrationIncludesStarterKit() + ->orderBy('id') + ->first(); + + if ($order) { + IncentiveTracker::trackNewPartner($order); + } + + if (! IncentiveNewPartner::query() + ->where('participant_id', $participant->id) + ->where('user_id', $partner->id) + ->exists()) { + $newPartner = IncentiveNewPartner::create([ + 'participant_id' => $participant->id, + 'user_id' => $partner->id, + 'registered_at' => $partner->created_at, + ]); + + IncentivePointsLog::create([ + 'participant_id' => $participant->id, + 'type' => 'partner', + 'source_type' => User::class, + 'source_id' => $partner->id, + 'source_label' => $partner->getFullName() ?: $partner->email ?: ('User #'.$partner->id), + 'month' => $partner->created_at->month, + 'year' => $partner->created_at->year, + 'points_onetime' => $incentive->points_partner_onetime, + 'points_accumulated' => 0, + 'incentive_new_partner_id' => $newPartner->id, + ]); + } + + if (IncentiveNewPartner::query() + ->where('participant_id', $participant->id) + ->where('user_id', $partner->id) + ->exists()) { + $added++; + } + } + + return $added; + } + + /** + * Fehlende Abo-Tracking-Zeilen anlegen (Kundenabo ot / Eigenabo me, wie IncentiveTracker). + * Zuerst trackAboActivated ueber Erstbestellung; ohne UserAboOrder manuell wie Neuaufbau B. + * + * @return int Anzahl nachgezogener Abo-Trackings fuer diesen Teilnehmer + */ + public function syncMissingTrackingAbos(IncentiveParticipant $participant): int + { + $incentive = $participant->incentive; + if (! $incentive) { + return 0; + } + + $added = 0; + + $qualEnd = $incentive->qualification_end->copy()->endOfDay(); + + $candidatesOt = UserAbo::query() + ->where('is_for', 'ot') + ->where('status', 2) + ->where('member_id', $participant->user_id) + ->whereBetween('created_at', [ + $incentive->qualification_start, + $qualEnd, + ]) + ->get(); + + $candidatesMe = UserAbo::query() + ->where('is_for', 'me') + ->where('status', 2) + ->where('user_id', $participant->user_id) + ->where(function ($q) use ($incentive, $qualEnd) { + $q->whereBetween('created_at', [ + $incentive->qualification_start, + $qualEnd, + ])->orWhere('created_at', '<', $incentive->qualification_start); + }) + ->get(); + + foreach ($candidatesOt->concat($candidatesMe) as $userAbo) { + if (IncentiveNewAbo::query() + ->where('participant_id', $participant->id) + ->where('user_abo_id', $userAbo->id) + ->exists()) { + continue; + } + + $order = UserAboOrder::query() + ->where('user_abo_id', $userAbo->id) + ->orderBy('id') + ->with('shopping_order') + ->first(); + + $shoppingOrder = $order?->shopping_order; + + if ($shoppingOrder) { + IncentiveTracker::trackAboActivated($shoppingOrder); + } + + if (! IncentiveNewAbo::query() + ->where('participant_id', $participant->id) + ->where('user_abo_id', $userAbo->id) + ->exists()) { + $qualStart = $incentive->qualification_start->copy()->startOfDay(); + $activatedAt = $userAbo->created_at; + $logMonth = (int) $userAbo->created_at->month; + $logYear = (int) $userAbo->created_at->year; + + if ($userAbo->is_for === 'me' && $userAbo->created_at->lt($qualStart)) { + $activatedAt = $qualStart->copy(); + $logMonth = (int) $qualStart->month; + $logYear = (int) $qualStart->year; + } + + $newAbo = IncentiveNewAbo::create([ + 'participant_id' => $participant->id, + 'user_abo_id' => $userAbo->id, + 'activated_at' => $activatedAt, + ]); + + IncentivePointsLog::create([ + 'participant_id' => $participant->id, + 'type' => 'abo', + 'source_type' => UserAbo::class, + 'source_id' => $userAbo->id, + 'source_label' => $userAbo->email ?: ('Abo #'.$userAbo->id), + 'month' => $logMonth, + 'year' => $logYear, + 'points_onetime' => $incentive->points_abo_onetime, + 'points_accumulated' => 0, + 'incentive_new_abo_id' => $newAbo->id, + ]); + } + + if (IncentiveNewAbo::query() + ->where('participant_id', $participant->id) + ->where('user_abo_id', $userAbo->id) + ->exists()) { + $added++; + } + } + + return $added; + } + + /** + * Setzt fehlende incentive_new_partner_id / incentive_new_abo_id an bestehenden Log-Zeilen. + * + * @return array{partner_fk: int, abo_fk: int, onetime_partner_fk: int, onetime_abo_fk: int} + */ + public function repairForeignKeys(IncentiveParticipant $participant): array + { + $stats = [ + 'partner_fk' => 0, + 'abo_fk' => 0, + 'onetime_partner_fk' => 0, + 'onetime_abo_fk' => 0, + ]; + + $newPartnerByUserId = $participant->newPartners()->get()->keyBy('user_id'); + $newAboByUserAboId = $participant->newAbos()->get()->keyBy('user_abo_id'); + + foreach ($participant->pointsLog()->where('type', 'partner')->whereNull('incentive_new_partner_id')->cursor() as $log) { + if ($log->source_type === User::class && $log->source_id) { + $np = $newPartnerByUserId->get($log->source_id); + if ($np) { + $log->update(['incentive_new_partner_id' => $np->id]); + $stats['onetime_partner_fk']++; + } + + continue; + } + + if ($log->user_sales_volume_id) { + $sv = UserSalesVolume::find($log->user_sales_volume_id); + if ($sv && $sv->user_id) { + $np = $newPartnerByUserId->get($sv->user_id); + if ($np) { + $log->update(['incentive_new_partner_id' => $np->id]); + $stats['partner_fk']++; + } + } + } + } + + $orderIdToUserAboId = []; + foreach ($participant->pointsLog()->where('type', 'abo')->whereNull('incentive_new_abo_id')->cursor() as $log) { + if ($log->source_type === UserAbo::class && $log->source_id) { + $na = $newAboByUserAboId->get($log->source_id); + if ($na) { + $log->update(['incentive_new_abo_id' => $na->id]); + $stats['onetime_abo_fk']++; + } + + continue; + } + + if ($log->user_sales_volume_id) { + $sv = UserSalesVolume::find($log->user_sales_volume_id); + if ($sv && $sv->shopping_order_id) { + if (! isset($orderIdToUserAboId[$sv->shopping_order_id])) { + $orderIdToUserAboId[$sv->shopping_order_id] = UserAboOrder::where('shopping_order_id', $sv->shopping_order_id)->value('user_abo_id'); + } + $userAboId = $orderIdToUserAboId[$sv->shopping_order_id] ?? null; + if ($userAboId) { + $na = $newAboByUserAboId->get($userAboId); + if ($na) { + $log->update(['incentive_new_abo_id' => $na->id]); + $stats['abo_fk']++; + } + } + } + } + } + + return $stats; + } + + /** + * Ruft IncentiveTracker::trackSalesVolume fuer Kandidaten-USVs auf, bei denen noch kein Log-Eintrag fuer diesen Teilnehmer existiert. + * + * @return int Anzahl neu angelegter Log-Zeilen (geschaetzt ueber Vorher/Nachher pro Teilnehmer) + */ + public function syncMissingSalesVolumeLogs(IncentiveParticipant $participant): int + { + $incentive = $participant->incentive; + if (! $incentive) { + return 0; + } + + $synced = 0; + $months = $incentive->getCalculationMonths(); + + $newPartnerUserIds = $participant->newPartners()->pluck('user_id')->filter()->values(); + $trackedUserAboIds = $participant->newAbos()->pluck('user_abo_id')->filter()->values(); + + $orderIdsForAbos = $trackedUserAboIds->isNotEmpty() + ? UserAboOrder::query()->whereIn('user_abo_id', $trackedUserAboIds)->pluck('shopping_order_id')->unique()->values() + : collect(); + + foreach ($months as $period) { + if ($newPartnerUserIds->isNotEmpty()) { + $svs = UserSalesVolume::query() + ->whereIn('user_id', $newPartnerUserIds) + ->where('month', $period['month']) + ->where('year', $period['year']) + ->where('status', '!=', 6) + ->get(); + + foreach ($svs as $sv) { + if ((int) abs($sv->points ?? 0) <= 0) { + continue; + } + if ($this->participantHasSalesVolumeLog($participant, $sv->id)) { + continue; + } + IncentiveTracker::trackSalesVolume($sv); + if ($this->participantHasSalesVolumeLog($participant, $sv->id)) { + $synced++; + } + } + } + + if ($orderIdsForAbos->isNotEmpty()) { + $svs = UserSalesVolume::query() + ->whereIn('shopping_order_id', $orderIdsForAbos) + ->where('month', $period['month']) + ->where('year', $period['year']) + ->where('status', '!=', 6) + ->get(); + + foreach ($svs as $sv) { + if ((int) abs($sv->points ?? 0) <= 0) { + continue; + } + if ($this->participantHasSalesVolumeLog($participant, $sv->id)) { + continue; + } + IncentiveTracker::trackSalesVolume($sv); + if ($this->participantHasSalesVolumeLog($participant, $sv->id)) { + $synced++; + } + } + } + } + + return $synced; + } + + private function participantHasSalesVolumeLog(IncentiveParticipant $participant, int $userSalesVolumeId): bool + { + return IncentivePointsLog::query() + ->where('participant_id', $participant->id) + ->where('user_sales_volume_id', $userSalesVolumeId) + ->where('is_storno', false) + ->exists(); + } +} diff --git a/app/Services/Incentive/IncentiveTracker.php b/app/Services/Incentive/IncentiveTracker.php new file mode 100644 index 0000000..1be9c96 --- /dev/null +++ b/app/Services/Incentive/IncentiveTracker.php @@ -0,0 +1,373 @@ +qualifiesForIncentiveTrackedPartner()) { + return; + } + + $new_user = User::find($shopping_order->auth_user_id); + if (! $new_user || ! $new_user->m_sponsor) { + return; + } + + $sponsor_id = $new_user->m_sponsor; + $registration_date = $shopping_order->created_at ?? Carbon::now(); + + $incentives = Incentive::query() + ->active() + ->where('qualification_start', '<=', $registration_date) + ->where('qualification_end', '>=', $registration_date) + ->get(); + + foreach ($incentives as $incentive) { + $participant = IncentiveParticipant::ensureForIncentiveUser($incentive, $sponsor_id); + + // Tracking-Tabelle: Partner erfassen (keine Duplikate) + $newPartner = IncentiveNewPartner::firstOrCreate( + ['participant_id' => $participant->id, 'user_id' => $new_user->id], + ['registered_at' => $registration_date] + ); + + // Log-Eintrag (Audit-Trail, keine Duplikate) + self::writeLog($participant, 'partner', User::class, $new_user->id, $new_user->getFullName() ?: $new_user->email ?: ('User #'.$new_user->id), $registration_date, $incentive->points_partner_onetime, $newPartner->id); + + // Neuberechnung aus Tracking-Tabellen + $participant->recalculateFromTrackingTables()->save(); + + self::updateRanking($incentive); + } + } catch (\Throwable $e) { + Log::error('IncentiveTracker::trackNewPartner error: '.$e->getMessage(), [ + 'shopping_order_id' => $shopping_order->id, + ]); + } + } + + /** + * Track an abo activation (Kundenabo is_for=ot oder Berater-Eigenabo is_for=me, bezahlt + aktiv). + * Fuegt Abo in Tracking-Tabelle ein + Log-Eintrag + Neuberechnung. + */ + /** + * Berater-ID für Incentive-Zuordnung: Bei Kundenabos (ot) sitzt der Berater in member_id, + * bei Berater-Eigenabo (me) in user_id (vgl. AboHelper::createNewAbo). + */ + public static function consultantUserIdForAboIncentive(UserAbo $user_abo): ?int + { + if ($user_abo->is_for === 'ot') { + return $user_abo->member_id ? (int) $user_abo->member_id : null; + } + + return $user_abo->user_id ? (int) $user_abo->user_id : null; + } + + public static function trackAboActivated(ShoppingOrder $shopping_order): void + { + try { + $user_abo = $shopping_order->getUserAbo(); + if (! $user_abo) { + return; + } + + if ($user_abo->is_for === 'ot') { + $consultant_id = self::consultantUserIdForAboIncentive($user_abo); + } elseif ($user_abo->is_for === 'me') { + $consultant_id = $user_abo->user_id ? (int) $user_abo->user_id : null; + } else { + return; + } + + if (! $consultant_id) { + return; + } + + $activation_date = $shopping_order->created_at ?? Carbon::now(); + + $incentives = Incentive::query() + ->active() + ->where('qualification_start', '<=', $activation_date) + ->where('qualification_end', '>=', $activation_date) + ->get(); + + foreach ($incentives as $incentive) { + $participant = IncentiveParticipant::ensureForIncentiveUser($incentive, $consultant_id); + + // Tracking-Tabelle: Abo erfassen (keine Duplikate) + $newAbo = IncentiveNewAbo::firstOrCreate( + ['participant_id' => $participant->id, 'user_abo_id' => $user_abo->id], + ['activated_at' => $activation_date] + ); + + // Log-Eintrag (Audit-Trail) + self::writeLog($participant, 'abo', get_class($user_abo), $user_abo->id, $user_abo->email ?: ('Abo #'.$user_abo->id), $activation_date, $incentive->points_abo_onetime, null, $newAbo->id); + + // Neuberechnung aus Tracking-Tabellen + $participant->recalculateFromTrackingTables()->save(); + + self::updateRanking($incentive); + } + } catch (\Throwable $e) { + Log::error('IncentiveTracker::trackAboActivated error: '.$e->getMessage(), [ + 'shopping_order_id' => $shopping_order->id, + ]); + } + } + + /** + * Track accumulated sales volume points. + * Punkte werden NUR gezaehlt wenn der Umsatz von einem gettrackten + * Neupartner oder Neuabo stammt. + */ + public static function trackSalesVolume(UserSalesVolume $user_sales_volume): void + { + try { + $month = $user_sales_volume->month; + $year = $user_sales_volume->year; + + if (! $month || ! $year) { + return; + } + + $points = (int) abs($user_sales_volume->points ?? 0); + if ($points <= 0) { + return; + } + + // A. Pruefen ob der User ein gettrackter Neupartner ist + $partner_trackings = IncentiveNewPartner::where('user_id', $user_sales_volume->user_id) + ->whereHas('participant.incentive', fn ($q) => $q->active()) + ->with('participant.incentive') + ->get(); + + foreach ($partner_trackings as $tracking) { + $participant = $tracking->participant; + $incentive = $participant->incentive; + + if (! $incentive->isDateInScope($month, $year)) { + continue; + } + + $exists = IncentivePointsLog::where('participant_id', $participant->id) + ->where('user_sales_volume_id', $user_sales_volume->id) + ->where('is_storno', false) + ->exists(); + + if (! $exists) { + IncentivePointsLog::create([ + 'participant_id' => $participant->id, + 'type' => 'partner', + 'source_type' => UserSalesVolume::class, + 'source_id' => $user_sales_volume->id, + 'source_label' => $user_sales_volume->message ?? ('SV '.$month.'/'.$year), + 'month' => $month, + 'year' => $year, + 'points_onetime' => 0, + 'points_accumulated' => $points, + 'user_sales_volume_id' => $user_sales_volume->id, + 'incentive_new_partner_id' => $tracking->id, + ]); + } + + $participant->recalculateFromTrackingTables()->save(); + self::updateRanking($incentive); + } + + // B. Pruefen ob die Bestellung zu einem getrackten Neuabo gehoert (Kundenabo ot oder Berater me). + // Bei Verlaengerung weicht shopping_order.shopping_user_id oft vom Stamm-user_abos.shopping_user_id ab (Replikat). + if ($user_sales_volume->shopping_order_id) { + $order = ShoppingOrder::find($user_sales_volume->shopping_order_id); + + if ($order) { + $userAboFromOrder = $order->getUserAbo(); + if (! $userAboFromOrder || ! in_array($userAboFromOrder->is_for, ['ot', 'me'], true)) { + $userAboFromOrder = null; + } + + $abo_trackings = $userAboFromOrder + ? IncentiveNewAbo::query() + ->where('user_abo_id', $userAboFromOrder->id) + ->whereHas('participant.incentive', fn ($q) => $q->active()) + ->with('participant.incentive') + ->get() + : collect(); + foreach ($abo_trackings as $tracking) { + $participant = $tracking->participant; + $incentive = $participant->incentive; + if (! $incentive->isDateInScope($month, $year)) { + continue; + } + + $exists = IncentivePointsLog::where('participant_id', $participant->id) + ->where('user_sales_volume_id', $user_sales_volume->id) + ->where('is_storno', false) + ->exists(); + + if (! $exists) { + IncentivePointsLog::create([ + 'participant_id' => $participant->id, + 'type' => 'abo', + 'source_type' => UserSalesVolume::class, + 'source_id' => $user_sales_volume->id, + 'source_label' => $user_sales_volume->message ?? ('SV '.$month.'/'.$year), + 'month' => $month, + 'year' => $year, + 'points_onetime' => 0, + 'points_accumulated' => $points, + 'user_sales_volume_id' => $user_sales_volume->id, + 'incentive_new_abo_id' => $tracking->id, + ]); + } + + $participant->recalculateFromTrackingTables()->save(); + self::updateRanking($incentive); + } + } + } + } catch (\Throwable $e) { + Log::error('IncentiveTracker::trackSalesVolume error: '.$e->getMessage(), [ + 'user_sales_volume_id' => $user_sales_volume->id, + ]); + } + } + + /** + * Track a storno (cancellation) of a sales volume. + * Storno-Log + Neuberechnung aus Tracking-Tabellen. + */ + public static function trackStorno(UserSalesVolume $original, UserSalesVolume $cancellation): void + { + try { + // Storno-Log-Eintraege schreiben + $original_logs = IncentivePointsLog::where('user_sales_volume_id', $original->id) + ->where('is_storno', false) + ->get(); + + $affected_participants = collect(); + + foreach ($original_logs as $original_log) { + IncentivePointsLog::create([ + 'participant_id' => $original_log->participant_id, + 'type' => $original_log->type, + 'source_type' => $original_log->source_type, + 'source_id' => $original_log->source_id, + 'source_label' => 'Storno: '.$original_log->source_label, + 'month' => $cancellation->month ?? $original_log->month, + 'year' => $cancellation->year ?? $original_log->year, + 'points_onetime' => -$original_log->points_onetime, + 'points_accumulated' => -$original_log->points_accumulated, + 'is_storno' => true, + 'storno_of_id' => $original_log->id, + 'user_sales_volume_id' => $cancellation->id, + 'incentive_new_partner_id' => $original_log->incentive_new_partner_id, + 'incentive_new_abo_id' => $original_log->incentive_new_abo_id, + ]); + + $affected_participants->push($original_log->participant_id); + } + + // Auch ohne Log-Eintraege: alle Teilnehmer dieses Users neu berechnen + if ($affected_participants->isEmpty() && $original->user_id) { + $affected_participants = IncentiveParticipant::whereHas('incentive', function ($q) { + $q->active(); + })->where('user_id', $original->user_id)->pluck('id'); + } + + // Neuberechnung fuer alle betroffenen Teilnehmer + foreach ($affected_participants->unique() as $participant_id) { + $participant = IncentiveParticipant::with('incentive')->find($participant_id); + if (! $participant) { + continue; + } + + $participant->recalculateFromTrackingTables()->save(); + + if ($participant->incentive) { + self::updateRanking($participant->incentive); + } + } + } catch (\Throwable $e) { + Log::error('IncentiveTracker::trackStorno error: '.$e->getMessage(), [ + 'original_id' => $original->id, + 'cancellation_id' => $cancellation->id, + ]); + } + } + + /** + * Update ranking for all participants of an incentive. + */ + public static function updateRanking(Incentive $incentive): void + { + // Nur Teilnehmer mit Punkten bekommen einen Rang (bei Punktgleichstand: Teilnahme bestaetigt vor anonym) + $with_points = IncentiveParticipant::where('incentive_id', $incentive->id) + ->where('total_points', '>', 0) + ->orderByDesc('total_points') + ->orderByRaw('accepted_terms_at IS NOT NULL DESC') + ->get(); + + $rank = 1; + foreach ($with_points as $participant) { + $participant->rank = $rank; + $participant->save(); + $rank++; + } + + // Teilnehmer ohne Punkte: Rang entfernen + IncentiveParticipant::where('incentive_id', $incentive->id) + ->where('total_points', '<=', 0) + ->whereNotNull('rank') + ->update(['rank' => null]); + } + + /** + * Log-Eintrag schreiben (Audit-Trail, keine Duplikate). + */ + private static function writeLog(IncentiveParticipant $participant, string $type, string $source_type, int $source_id, string $source_label, Carbon $date, int $points_onetime, ?int $incentive_new_partner_id = null, ?int $incentive_new_abo_id = null): void + { + $exists = IncentivePointsLog::where('participant_id', $participant->id) + ->where('type', $type) + ->where('source_type', $source_type) + ->where('source_id', $source_id) + ->where('is_storno', false) + ->exists(); + + if ($exists) { + return; + } + + IncentivePointsLog::create([ + 'participant_id' => $participant->id, + 'type' => $type, + 'source_type' => $source_type, + 'source_id' => $source_id, + 'source_label' => $source_label, + 'month' => $date->month, + 'year' => $date->year, + 'points_onetime' => $points_onetime, + 'points_accumulated' => 0, + 'incentive_new_partner_id' => $incentive_new_partner_id, + 'incentive_new_abo_id' => $incentive_new_abo_id, + ]); + } +} diff --git a/app/Services/LocaleGuard.php b/app/Services/LocaleGuard.php new file mode 100644 index 0000000..ab0a738 --- /dev/null +++ b/app/Services/LocaleGuard.php @@ -0,0 +1,30 @@ + + */ + public static function supportedLocaleCodes(): array + { + return array_keys(config('localization.supportedLocales')); + } + + public static function isSupported(string $locale): bool + { + return in_array(strtolower($locale), self::supportedLocaleCodes(), true); + } + + public static function normalize(?string $locale): ?string + { + if ($locale === null || $locale === '') { + return null; + } + + $lower = strtolower($locale); + + return self::isSupported($lower) ? $lower : null; + } +} diff --git a/app/Services/Payment.php b/app/Services/Payment.php index f2bc930..b843eaa 100644 --- a/app/Services/Payment.php +++ b/app/Services/Payment.php @@ -10,6 +10,7 @@ use App\Models\UserCreditItem; use App\Models\UserLevel; use App\Repositories\InvoiceRepository; use App\Services\BusinessPlan\SalesPointsVolume; +use App\Services\Incentive\IncentiveTracker; use App\User; use Illuminate\Support\Facades\Mail; @@ -277,17 +278,47 @@ class Payment // the Order is Pay, so we can set the Status in the Abo if ($shopping_order->is_abo) { + // Payone-Server-Callback kann vor dem Checkout-Erfolgs-Redirect laufen; dann existiert + // noch kein UserAbo/UserAboOrder — setAboActive wirkt erst nach Anlage. + if ($paid && $shopping_payment) { + $shopping_payment->loadMissing([ + 'payment_transactions', + 'shopping_order.shopping_user', + 'shopping_order.shopping_order_items', + ]); + if (! $shopping_order->getUserAbo()) { + AboHelper::createNewAbo($shopping_payment); + $shopping_order->refresh(); + } + } + AboHelper::setAboActive($shopping_order, 2, true); + + // Incentive: Track activated customer abo + IncentiveTracker::trackAboActivated($shopping_order); + } + + // Incentive: Track new partner registration (ggf. mit Starterpaket) + if ($shopping_order->payment_for == 1) { + IncentiveTracker::trackNewPartner($shopping_order); } // make Invoice is not exist and is live + // Wrapped in try/catch: Rechnungserstellung darf den Payment-Flow nicht crashen if ($shopping_order->mode === 'live' || Util::isTestSystem(true)) { // Reload the shopping order to check for invoice again (defense against race conditions) $shopping_order->refresh(); if (! $shopping_order->isInvoice()) { - $invoice_repo = new InvoiceRepository($shopping_order); - $invoice_repo->createAndSalesVolume(); + try { + $invoice_repo = new InvoiceRepository($shopping_order); + $invoice_repo->createAndSalesVolume(); + } catch (\Throwable $e) { + \Log::error('Payment::paymentStatusPaidAction - Rechnungserstellung fehlgeschlagen', [ + 'shopping_order_id' => $shopping_order->id, + 'error' => $e->getMessage(), + ]); + } } } diff --git a/app/Services/Payone.php b/app/Services/Payone.php index 5bc575d..4051a59 100644 --- a/app/Services/Payone.php +++ b/app/Services/Payone.php @@ -1,4 +1,5 @@ . * - * @package Simple PHP Integration * @link https://www.bspayone.com/ + * * @copyright (C) BS PAYONE GmbH 2016, 2018 * @author Florian Bender * @author Timo Kuchel @@ -26,87 +27,124 @@ namespace App\Services; - -//require 'vendor/autoload.php'; +// require 'vendor/autoload.php'; use Exception; - use GuzzleHttp\Client; +use GuzzleHttp\Exception\BadResponseException; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; use Psr\Http\Message\ResponseInterface; /** * Class Payone */ -class Payone { - +class Payone +{ /** * The URL of the Payone API */ const PAYONE_SERVER_API_URL = 'https://api.pay1.de/post-gateway/'; + const PAYONE_CLIENT_API_URL = 'https://secure.pay1.de/client-api/'; /** * performing the HTTP POST request to the PAYONE platform * - * @param array $request - * @param string $responsetype - * @throws Exception + * @param array $request + * @param string $responsetype + * @param Client|null $client Optional Guzzle client (e.g. mocked in tests). * @return array|\Psr\Http\Message\StreamInterface Returns an array of response - * parameters in "classic" mode, a Stream for any other mode. + * parameters in "classic" mode, a Stream for any other mode. + * + * @throws Exception */ - public static function sendRequest($request, $responsetype = "") + public static function sendRequest($request, $responsetype = '', ?Client $client = null) { - if ($responsetype === "json") { - // appends the accept: application/json header to the request - // This is used to retrieve structured JSON in the response - // $client = new Client(['headers' => ['accept' => 'application/json', 'content-type' => 'text/plain;charset=UTF-8']]); - $client = new Client(['headers' => ['accept' => 'application/json']]); - - } - else { - // if $responsetype is set to anything else than "json", use the standard request - // $client = new Client(['headers' => ['content-type' => 'text/plain;charset=UTF-8']]); - $client = new Client(); + if ($client === null) { + if ($responsetype === 'json') { + // appends the accept: application/json header to the request + // This is used to retrieve structured JSON in the response + // $client = new Client(['headers' => ['accept' => 'application/json', 'content-type' => 'text/plain;charset=UTF-8']]); + $client = new Client(['headers' => ['accept' => 'application/json']]); + } else { + // if $responsetype is set to anything else than "json", use the standard request + // $client = new Client(['headers' => ['content-type' => 'text/plain;charset=UTF-8']]); + $client = new Client; + } } -// echo "Requesting..."; + // echo "Requesting..."; $begin = microtime(true); + $userMessage = 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1002'; + try { $response = $client->request('POST', self::PAYONE_SERVER_API_URL, ['form_params' => $request]); - } - catch (\GuzzleHttp\Exception\ClientException $e) { + } catch (BadResponseException $e) { $error = $e->getResponse(); $responseBodyAsString = $error->getBody()->getContents(); MyLog::writeLog( - 'payone', - 'error', - 'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest Something went wrong during the HTTP request.', + 'payone', + 'error', + 'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest HTTP-Fehlerantwort (4xx/5xx).', ['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => $request] ); - abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1002'); + abort(403, $userMessage); + } catch (ConnectException $e) { + MyLog::writeLog( + 'payone', + 'error', + 'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest Netzwerk-/Transportfehler (keine HTTP-Antwort).', + [ + 'exception' => $e->getMessage(), + 'request' => $request, + ] + ); + abort(403, $userMessage); + } catch (RequestException $e) { + if ($e->hasResponse()) { + $error = $e->getResponse(); + $responseBodyAsString = $error !== null ? $error->getBody()->getContents() : ''; + MyLog::writeLog( + 'payone', + 'error', + 'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest HTTP-Fehlerantwort.', + ['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => $request] + ); + } else { + MyLog::writeLog( + 'payone', + 'error', + 'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest Transportfehler (RequestException ohne Antwort).', + [ + 'exception' => $e->getMessage(), + 'request' => $request, + ] + ); + } - } + abort(403, $userMessage); + } if (isset($response)) { - if (implode($response->getHeader('Content-Type')) == 'text/plain;charset=UTF-8'){ + if (implode($response->getHeader('Content-Type')) == 'text/plain;charset=UTF-8') { // if the content type is text/plain, parse response into array $return = self::parseResponse($response); // \Log::channel('payone')->error('App\Services\Payone::sendRequest content type is text/plain: '.$response); } else { // if the content type is anything else, just return the response body - $return = json_decode($response->getBody(),true); + $return = json_decode($response->getBody(), true); MyLog::writeLog( - 'payone', - 'error', - 'Error: App\Services\Payone::sendRequest content type is anything else', + 'payone', + 'error', + 'Error: App\Services\Payone::sendRequest content type is anything else', ['error' => $return, 'response' => $response, 'request' => $request] ); } - } else { MyLog::writeLog( - 'payone', - 'error', - 'Error: App\Services\Payone::sendRequest Something went wrong during the HTTP request', + 'payone', + 'error', + 'Error: App\Services\Payone::sendRequest Something went wrong during the HTTP request', ['request' => $request] ); throw new Exception('Something went wrong during the HTTP request.'); @@ -114,60 +152,62 @@ class Payone { $end = microtime(true); $duration = $end - $begin; - if(!is_array($return)){ + if (! is_array($return)) { MyLog::writeLog( - 'payone', - 'error', - 'Error: 1003 App\Http\Controllers\Pay\PayoneController::ResponseData response is non array: return:', - ['return'=>$return, 'response' => $response, 'request' => $request] + 'payone', + 'error', + 'Error: 1003 App\Http\Controllers\Pay\PayoneController::ResponseData response is non array: return:', + ['return' => $return, 'response' => $response, 'request' => $request] ); abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1003'); } - if(!isset($return['status'])){ + if (! isset($return['status'])) { MyLog::writeLog( - 'payone', - 'error', - 'Error: 1004 App\Http\Controllers\Pay\PayoneController::ResponseData response has non status', - ['return'=>$return, 'response' => $response, 'request' => $request] + 'payone', + 'error', + 'Error: 1004 App\Http\Controllers\Pay\PayoneController::ResponseData response has non status', + ['return' => $return, 'response' => $response, 'request' => $request] ); abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1004'); } - /* echo "done.\n"; - echo "Request took " . $duration . " seconds.\n"; - echo "
    "; - */ + + /* echo "done.\n"; + echo "Request took " . $duration . " seconds.\n"; + echo "
    "; + */ return $return; } /** * gets response string an puts it into an array * - * @param \Psr\Http\Message\ResponseInterface $response - * @throws Exception * @return array + * + * @throws Exception */ public static function parseResponse(ResponseInterface $response) { - $responseArray = array(); + $responseArray = []; $explode = explode("\n", $response->getBody()); foreach ($explode as $e) { - $keyValue = explode("=", $e); - if (trim($keyValue[0]) != "") { + $keyValue = explode('=', $e); + if (trim($keyValue[0]) != '') { if (count($keyValue) == 2) { $responseArray[$keyValue[0]] = trim($keyValue[1]); } else { $key = $keyValue[0]; unset($keyValue[0]); - $value = implode("=", $keyValue); + $value = implode('=', $keyValue); $responseArray[$key] = $value; } } } - /*if ($responseArray['status'] == "ERROR") { - $msg = "Payone returned an error:\n" . print_r($responseArray, true); - throw new Exception($msg); - }*/ + + /*if ($responseArray['status'] == "ERROR") { + $msg = "Payone returned an error:\n" . print_r($responseArray, true); + throw new Exception($msg); + }*/ return $responseArray; } } diff --git a/app/Services/ProductOrderContext.php b/app/Services/ProductOrderContext.php new file mode 100644 index 0000000..9d565ab --- /dev/null +++ b/app/Services/ProductOrderContext.php @@ -0,0 +1,51 @@ + + */ + public static function allowedShowOnIds(bool $isAbo, string $shippingIsFor): array + { + if ($shippingIsFor === 'me' || $shippingIsFor === 'abo-me') { + return $isAbo ? ['12', '13'] : ['2']; + } + + return $isAbo ? ['12', '13'] : ['3']; + } + + /** + * @param list $allowedIds + */ + public static function productMatchesShowOn(Product $product, array $allowedIds): bool + { + $showOn = $product->show_on; + if (! is_array($showOn)) { + return false; + } + + foreach ($allowedIds as $id) { + foreach ($showOn as $value) { + if ((string) $value === (string) $id) { + return true; + } + } + } + + return false; + } + + public static function isProductAllowedInContext(Product $product, bool $isAbo, string $shippingIsFor): bool + { + return self::productMatchesShowOn($product, self::allowedShowOnIds($isAbo, $shippingIsFor)); + } + + public static function isProductAllowedInCustomerWebshop(Product $product): bool + { + return self::isProductAllowedInContext($product, false, 'ot-customer'); + } +} diff --git a/app/Services/SyS/AboOrdersOverview.php b/app/Services/SyS/AboOrdersOverview.php new file mode 100644 index 0000000..18d4899 --- /dev/null +++ b/app/Services/SyS/AboOrdersOverview.php @@ -0,0 +1,127 @@ + + */ + private const SUCCESS_TXACTIONS = ['paid', 'extern_paid', 'invoice_paid']; + + public static function show() + { + $filter = request('filter', 'all'); + + $aboOrders = UserAboOrder::with([ + 'user_abo', + 'user_abo.user', + 'user_abo.user.account', + 'shopping_order', + 'shopping_order.shopping_user', + 'shopping_order.shopping_payments', + ]) + ->whereHas('shopping_order') + ->when($filter === 'berater', fn ($q) => $q->whereHas('user_abo', fn ($q) => $q->where('is_for', 'me'))) + ->when($filter === 'kunde', fn ($q) => $q->whereHas('user_abo', fn ($q) => $q->where('is_for', '!=', 'me'))) + ->orderByDesc('created_at') + ->get(); + + $summary = [ + 'total_orders' => $aboOrders->count(), + 'total_diff' => 0.0, + 'affected_orders' => 0, + ]; + + $rows = []; + + foreach ($aboOrders as $aboOrder) { + $order = $aboOrder->shopping_order; + if (! $order) { + continue; + } + + $subtotalWs = (float) $order->subtotal_ws; + $totalShipping = (float) $order->total_shipping; + $tax = (float) $order->tax; + + $expectedCents = (int) round($totalShipping * 100); + $actualCents = self::actualChargedCentsFromPayments($order); + + $actualEur = $actualCents !== null ? round($actualCents / 100, 2) : null; + $diff = ($actualCents !== null) + ? round(($expectedCents - $actualCents) / 100, 2) + : null; + + if ($diff !== null && abs($diff) <= 0.01) { + $diff = 0; + } + + $payments = $order->shopping_payments; + $paymentTxSummary = $payments->isEmpty() + ? null + : $payments->pluck('txaction')->filter()->unique()->implode(', '); + + $user = $aboOrder->user_abo->user ?? null; + + $rows[] = [ + 'abo_order_id' => $aboOrder->id, + 'abo_id' => $aboOrder->user_abo_id, + 'order_id' => 'shopping_order_id]).'>'.$aboOrder->shopping_order_id.'', + 'user_id' => $user->id ?? null, + 'user_name' => $aboOrder->shopping_order->shopping_user ? ($aboOrder->shopping_order->shopping_user->billing_firstname ?? '').' '.($aboOrder->shopping_order->shopping_user->billing_lastname ?? '') : '-', + 'user_email' => $aboOrder->shopping_order->shopping_user ? $aboOrder->shopping_order->shopping_user->billing_email ?? '-' : '-', + 'is_for' => $aboOrder->user_abo->is_for ?? '-', + 'subtotal_ws' => $subtotalWs, + 'tax' => $tax, + 'total_shipping' => $totalShipping, + 'actual_charged_eur' => $actualEur, + 'payment_count' => $payments->count(), + 'payment_txactions' => $paymentTxSummary, + 'diff' => $diff, + 'status' => $aboOrder->status, + 'paid' => $aboOrder->paid, + 'txaction' => $order->txaction, + 'created_at' => $aboOrder->created_at, + ]; + + if ($diff !== null && abs($diff) >= 0.01) { + $summary['total_diff'] += $diff; + $summary['affected_orders']++; + } + } + + $summary['total_diff'] = round($summary['total_diff'], 2); + + return view('sys.tools.abo-orders-overview', [ + 'rows' => $rows, + 'summary' => $summary, + 'filter' => $filter, + ]); + } + + /** + * Summiert erfolgreiche Abbuchungen aus `shopping_payments` (Cent). + * Kein Treffer bei erfolgreichen Status → null (kein belastbarer Eingang). + */ + public static function actualChargedCentsFromPayments(ShoppingOrder $order): ?int + { + $payments = $order->shopping_payments; + if ($payments === null || $payments->isEmpty()) { + return null; + } + + $successful = $payments->filter( + fn ($p) => in_array($p->txaction, self::SUCCESS_TXACTIONS, true) + ); + + $sum = (int) $successful->sum('amount'); + + return $sum > 0 ? $sum : null; + } +} diff --git a/app/Services/SyS/PayoneCallbackTestbench.php b/app/Services/SyS/PayoneCallbackTestbench.php new file mode 100644 index 0000000..cbe9865 --- /dev/null +++ b/app/Services/SyS/PayoneCallbackTestbench.php @@ -0,0 +1,536 @@ + session('payone_testbench_fixture'), + 'simulateResult' => session('payone_testbench_simulate'), + 'checkoutSuccess' => session('payone_testbench_checkout_success'), + 'userAboId' => session('payone_testbench_user_abo_id'), + 'cronRenewal' => session('payone_testbench_cron_renewal'), + 'cronRenewalOrderId' => session('payone_testbench_cron_renewal_order_id'), + ]); + } + + public static function store() + { + self::ensureAllowed(); + + $action = request('action'); + + if ($action === 'create_fixture') { + return self::createFixture(); + } + + if ($action === 'simulate_paid') { + return self::simulatePaidCallback(); + } + + if ($action === 'simulate_checkout_success') { + return self::simulateCheckoutSuccess(); + } + + if ($action === 'simulate_cron_renewal') { + return self::simulateCronRenewal(); + } + + if ($action === 'clear_fixture') { + session()->forget([ + 'payone_testbench_fixture', + 'payone_testbench_simulate', + 'payone_testbench_checkout_success', + 'payone_testbench_user_abo_id', + 'payone_testbench_cron_renewal', + 'payone_testbench_cron_renewal_order_id', + ]); + + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); + } + + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Unbekannte Aktion.'); + } + + /** + * Payload wie Payone ihn an die Status-URL sendet (Route: api.{domain}/payment/status). + * + * @return array + */ + public static function buildPayoneCallbackPayload(ShoppingOrder $order, ShoppingPayment $payment, ?int $txid = null): array + { + $txid = $txid ?? random_int(100_000_000, 999_999_999); + $price = number_format(round($payment->amount / 100, 2), 2, '.', ''); + + return [ + 'key' => (string) config('payone.defaults.key'), + 'param' => (string) $order->id, + 'userid' => '999999999', + 'txid' => (string) $txid, + 'reference' => $payment->reference, + 'price' => $price, + 'txaction' => 'paid', + 'mode' => $payment->mode ?? 'test', + 'clearingtype' => $payment->clearingtype, + ]; + } + + /** + * Vollständige URL der Payone-Server-zu-Server-Route (z. B. http://api.mivita.test/payment/status). + */ + public static function paymentStatusUrl(): string + { + return route('api.payment_status', [], true); + } + + private static function ensureAllowed(): void + { + if (app()->isProduction()) { + abort(403, 'Payone-Testbench ist in Production deaktiviert.'); + } + } + + private static function createFixture() + { + $validated = request()->validate([ + 'amount_eur' => ['required', 'numeric', 'min:0.01', 'max:99999.99'], + 'consultant_user_id' => ['required', 'integer', 'exists:users,id'], + 'is_abo' => ['sometimes', 'boolean'], + 'is_for_ot' => ['sometimes', 'boolean'], + ]); + + $amountEur = round((float) $validated['amount_eur'], 2); + $amountCents = (int) round($amountEur * 100); + $isAbo = request()->boolean('is_abo'); + $isFor = request()->boolean('is_for_ot') ? 'ot' : 'me'; + $consultantId = (int) $validated['consultant_user_id']; + + session()->forget(['payone_testbench_simulate', 'payone_testbench_checkout_success']); + + $country = ShippingCountry::query()->first(); + if (! $country) { + abort(500, 'Kein Eintrag in shipping_countries – bitte Stammdaten anlegen.'); + } + + $userShop = UserShop::query()->first(); + if (! $userShop) { + abort(500, 'Kein user_shops Eintrag – bitte Shop anlegen.'); + } + + $fixture = DB::transaction(function () use ($amountEur, $amountCents, $isAbo, $isFor, $country, $userShop, $consultantId) { + $email = 'payone-bench-'.Str::lower(Str::random(8)).'@example.test'; + + if ($isFor === 'me') { + $shoppingUserAttrs = [ + 'billing_firstname' => 'Bench', + 'billing_lastname' => 'Payone', + 'billing_email' => $email, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'is_for' => $isFor, + 'is_from' => 'user_order', + 'auth_user_id' => $consultantId, + 'member_id' => null, + ]; + $orderAttrs = [ + 'auth_user_id' => $consultantId, + 'member_id' => null, + ]; + } else { + $shoppingUserAttrs = [ + 'billing_firstname' => 'Bench', + 'billing_lastname' => 'Payone', + 'billing_email' => $email, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'is_for' => $isFor, + 'is_from' => 'user_order', + 'auth_user_id' => null, + 'member_id' => $consultantId, + ]; + $orderAttrs = [ + 'auth_user_id' => null, + 'member_id' => $consultantId, + ]; + } + + $shoppingUser = ShoppingUser::create($shoppingUserAttrs); + + $order = ShoppingOrder::create(array_merge([ + 'shopping_user_id' => $shoppingUser->id, + 'country_id' => $country->id, + 'language' => app()->getLocale(), + 'user_shop_id' => $userShop->id, + 'payment_for' => $shoppingUser->getOrderPaymentFor(), + 'total' => $amountEur, + 'subtotal' => $amountEur, + 'shipping' => 0, + 'shipping_net' => 0, + 'subtotal_ws' => $amountEur, + 'tax' => 0, + 'total_shipping' => $amountEur, + 'points' => 0, + 'weight' => 0, + 'paid' => false, + 'is_abo' => $isAbo, + 'abo_interval' => $isAbo ? 30 : 0, + 'txaction' => 'prev', + 'mode' => 'test', + ], $orderAttrs)); + + $reference = self::generatePaymentReference(); + + $payment = ShoppingPayment::create([ + 'shopping_order_id' => $order->id, + 'clearingtype' => 'wlt', + 'wallettype' => 'PPE', + 'onlinebanktransfertype' => '', + 'reference' => $reference, + 'amount' => $amountCents, + 'currency' => 'EUR', + 'txaction' => null, + 'mode' => 'test', + 'is_abo' => $isAbo, + 'abo_interval' => $isAbo ? ($order->abo_interval ?? 0) : 0, + ]); + + return [ + 'shopping_order_id' => $order->id, + 'shopping_payment_id' => $payment->id, + 'reference' => $reference, + 'amount_eur' => $amountEur, + 'amount_cents' => $amountCents, + 'is_abo' => $isAbo, + 'is_for' => $isFor, + 'consultant_user_id' => $consultantId, + 'assignment_note' => $isFor === 'me' + ? 'Berater: auth_user_id = Berater-ID, member_id leer' + : 'Kunde: auth_user_id leer, member_id = Berater-ID (Zuordnung / Provision)', + 'api_url' => self::paymentStatusUrl(), + 'curl' => self::buildCurlExample(self::paymentStatusUrl(), self::buildPayoneCallbackPayload($order->fresh(), $payment->fresh())), + ]; + }); + + session()->put('payone_testbench_fixture', $fixture); + + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); + } + + /** + * Entspricht dem Browser-Erfolg nach Zahlung: {@see CheckoutController::handleSuccessfulTransaction} + * und {@see CheckoutController::transactionApproved} → {@see AboHelper::createNewAbo}. + */ + private static function simulateCheckoutSuccess() + { + $validated = request()->validate([ + 'shopping_order_id' => ['required', 'integer', 'exists:shopping_orders,id'], + ]); + + $order = ShoppingOrder::query() + ->with(['shopping_order_items', 'shopping_user', 'shopping_payments']) + ->findOrFail($validated['shopping_order_id']); + + $payment = $order->shopping_payments->last(); + + if (! $payment) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Keine ShoppingPayment zu dieser Bestellung.'); + } + + if (! $order->is_abo || (int) $order->abo_interval <= 0) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Bestellung ist kein Abo (is_abo / abo_interval).'); + } + + if (UserAboOrder::query()->where('shopping_order_id', $order->id)->exists()) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Für diese Bestellung existiert bereits ein UserAboOrder – Abo wurde bereits angelegt.'); + } + + $payment->load('payment_transactions'); + + if ($payment->payment_transactions->isEmpty()) { + PaymentTransaction::create([ + 'shopping_payment_id' => $payment->id, + 'request' => 'transaction', + 'txid' => random_int(1, 999_999_999), + 'userid' => 999_999_999, + 'status' => 'PAYONE', + 'key' => (string) config('payone.defaults.key'), + 'txaction' => 'paid', + 'transmitted_data' => [], + 'mode' => $payment->mode ?? 'test', + ]); + } + + try { + AboHelper::createNewAbo($payment->fresh([ + 'shopping_order.shopping_user', + 'shopping_order.shopping_order_items', + 'payment_transactions', + ])); + } catch (Throwable $e) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'createNewAbo: '.$e->getMessage()); + } + + $userAboOrder = UserAboOrder::query() + ->where('shopping_order_id', $order->id) + ->with('user_abo') + ->first(); + + session()->put('payone_testbench_checkout_success', [ + 'user_abo_id' => $userAboOrder?->user_abo_id, + 'user_abo_order_id' => $userAboOrder?->id, + 'shopping_order_id' => $order->id, + 'order_paid_after' => (bool) $order->fresh()->paid, + 'hint' => 'Erstbestellung: Abo-Stammdaten wie nach Checkout-Redirect. Die Bestätigung (paid, setAboActive, Incentive) folgt im nächsten Schritt über die Payone-API.', + ]); + + if ($userAboOrder?->user_abo_id) { + session()->put('payone_testbench_user_abo_id', $userAboOrder->user_abo_id); + } + + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); + } + + /** + * @return array + */ + private static function buildCurlExample(string $url, array $payload): string + { + $parts = []; + foreach ($payload as $k => $v) { + $parts[] = escapeshellarg($k).'='.escapeshellarg((string) $v); + } + + return 'curl -X POST '.escapeshellarg($url).' -d '.implode(' -d ', $parts); + } + + private static function generatePaymentReference(): string + { + return substr(str_replace('-', '', (string) Str::uuid()), 0, 16); + } + + private static function simulatePaidCallback() + { + $validated = request()->validate([ + 'shopping_order_id' => ['required', 'integer', 'exists:shopping_orders,id'], + ]); + + $order = ShoppingOrder::query()->with('shopping_payments')->findOrFail($validated['shopping_order_id']); + $payment = $order->shopping_payments->last(); + + if (! $payment) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Keine ShoppingPayment zu dieser Bestellung.'); + } + + if ($order->is_abo && (int) $order->abo_interval > 0) { + if (! UserAboOrder::query()->where('shopping_order_id', $order->id)->exists()) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Bei einer Abo-Erstbestellung zuerst Schritt 2 (Checkout: createNewAbo) ausführen, danach erst die Payone-API (paid). So existiert ein UserAboOrder für setAboActive und trackAboActivated.'); + } + } + + $payload = self::buildPayoneCallbackPayload($order, $payment); + + $request = Request::create(self::paymentStatusUrl(), 'POST', $payload); + $response = app()->handle($request); + + $order->refresh(); + + $result = [ + 'http_status' => $response->getStatusCode(), + 'body' => $response->getContent(), + 'order_paid' => (bool) $order->paid, + 'order_txaction' => $order->txaction, + 'payload' => $payload, + 'hint' => 'Entspricht Payment::paymentStatusPaidAction: Abo bestätigen (setAboActive), ggf. Incentive trackAboActivated, Rechnung …', + ]; + + session()->put('payone_testbench_simulate', $result); + + $userAbo = $order->getUserAbo(); + if ($userAbo) { + session()->put('payone_testbench_user_abo_id', $userAbo->id); + } + + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); + } + + /** + * Wie {@see UserMakeAboOrder::checkAbosToOrder}: next_date = heute und keine doppelte Verarbeitung am selben Tag. + * Nur fuer Testbench (nicht Production). + */ + public static function prepareUserAboForCronRun(UserAbo $userAbo): void + { + self::ensureAllowed(); + + $today = Carbon::today()->format('Y-m-d'); + + DB::transaction(function () use ($userAbo, $today) { + UserAboOrder::query() + ->where('user_abo_id', $userAbo->id) + ->whereDate('created_at', $today) + ->delete(); + + $userAbo->update(['next_date' => $today]); + }); + } + + /** + * Einmaliger Cron-Lauf: {@see UserMakeAboOrder::makeOrder} (Bestellung + Payone-Zahlung). + * Danach ggf. Schritt 5: Payone-API (paid) fuer die Verlaengerungs-Bestellung. + */ + private static function simulateCronRenewal() + { + self::ensureAllowed(); + + $validated = request()->validate([ + 'user_abo_id' => ['nullable', 'integer', 'exists:user_abos,id'], + ]); + + $userAboId = $validated['user_abo_id'] ?? session('payone_testbench_user_abo_id'); + if (! $userAboId) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Kein user_abo_id – zuerst Abo-Erstkauf (Schritte 2–3) abschließen oder ID eintragen.'); + } + + $userAbo = UserAbo::query() + ->with(['user_abo_items', 'shopping_user']) + ->findOrFail($userAboId); + + AboHelper::ensureUserAboItemsFromLatestOrder($userAbo); + $userAbo->refresh(); + $userAbo->load(['user_abo_items', 'shopping_user']); + + if (! $userAbo->active || (int) $userAbo->status !== 2) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'UserAbo nicht aktiv oder nicht status 2 (abo_okay).'); + } + if ($userAbo->user_abo_items->isEmpty()) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Keine Abo-Artikel – Verlängerung nicht möglich.'); + } + + self::prepareUserAboForCronRun($userAbo); + $userAbo->refresh(); + + $command = new UserMakeAboOrder; + self::bindNullConsoleOutput($command); + + $makeOrder = new ReflectionMethod(UserMakeAboOrder::class, 'makeOrder'); + $makeOrder->setAccessible(true); + + try { + /** @var ShoppingOrder|null $shoppingOrder */ + $shoppingOrder = $makeOrder->invoke($command, $userAbo->fresh(['user_abo_items', 'shopping_user'])); + } catch (Throwable $e) { + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']) + ->with('error', 'Cron makeOrder: '.$e->getMessage()); + } + + if (! $shoppingOrder) { + session()->put('payone_testbench_cron_renewal', [ + 'success' => false, + 'message' => 'makeOrder hat keine Bestellung zurückgegeben (typisch: makeShoppingOrder false, z. B. fehlende Referenz-Bestellung/user_shop_id, oder createShoppingUser liefert nichts).', + 'diagnosis' => self::cronRenewalDiagnosis($userAbo->fresh(['user_abo_items', 'shopping_user'])), + ]); + session()->forget('payone_testbench_cron_renewal_order_id'); + + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); + } + + session()->put('payone_testbench_cron_renewal', [ + 'success' => true, + 'shopping_order_id' => $shoppingOrder->id, + 'user_abo_id' => $userAbo->id, + 'hint' => 'Entspricht user:make_abo_order / makeOrder. Für Rechnung und Incentive-Umsatz wie in Produktion: Schritt 5 (Payone paid) für diese Verlängerungs-Bestellung ausführen.', + ]); + session()->put('payone_testbench_cron_renewal_order_id', $shoppingOrder->id); + + return Redirect::route('sysadmin_tool', ['payone_callback_testbench']); + } + + /** + * Ohne Log-Datei: Ursachen fuer fehlgeschlagenes makeOrder eingrenzen. + * + * @return array + */ + private static function cronRenewalDiagnosis(UserAbo $userAbo): array + { + $out = [ + 'user_abo_items' => $userAbo->user_abo_items->count(), + ]; + + $su = $userAbo->shopping_user; + if (! $su) { + $out['shopping_user'] = 'fehlt (UserAbo.shopping_user_id)'; + + return $out; + } + + $out['shopping_user_id'] = $su->id; + $out['shopping_orders_count'] = $su->shopping_orders()->count(); + $ref = $su->shopping_orders()->orderByDesc('id')->first(); + $out['reference_order_id'] = $ref?->id; + $out['reference_user_shop_id'] = $ref?->user_shop_id; + + if ($ref && $ref->user_shop_id) { + $shop = UserShop::withTrashed()->find($ref->user_shop_id); + $out['user_shop_row_exists'] = $shop !== null; + $out['user_shop_row_trashed'] = $shop !== null && $shop->trashed(); + $out['user_shop_relation_loaded'] = $ref->user_shop !== null; + } + + return $out; + } + + /** + * Ohne Artisan-Kontext ist $command->output null – info()/error() wuerden crashen. + */ + private static function bindNullConsoleOutput(ConsoleCommand $command): void + { + $command->setLaravel(app()); + + $input = new ArrayInput([]); + $nullOutput = new NullOutput; + $outputStyle = app()->make(OutputStyle::class, [ + 'input' => $input, + 'output' => $nullOutput, + ]); + + $reflection = new \ReflectionClass($command); + $property = $reflection->getProperty('output'); + $property->setAccessible(true); + $property->setValue($command, $outputStyle); + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 051cab5..753e733 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -1,70 +1,97 @@ $lang){ - $ret[strtolower($code)] = strtolower($lang['native']); + foreach ($langs as $code => $lang) { + $ret[strtolower($code)] = strtolower($lang['native']); } + return $ret; } - public static function setInstance($instance){ + public static function setInstance($instance) + { self::$instance = $instance; } - //init Yard for user order Customer - public static function initCustomerYard($shopping_user, $for){ + // init Yard for user order Customer + public static function initCustomerYard($shopping_user, $for) + { self::$user_tax_free = false; - if($shopping_user->same_as_billing){ + if ($shopping_user->same_as_billing) { self::$user_country = $shopping_user->billing_country; self::$shipping_country = $shopping_user->billing_country; - }else{ + } else { self::$user_country = $shopping_user->billing_country; self::$shipping_country = $shopping_user->shipping_country; } - if(self::$user_country->supply_country && self::$shipping_country->supply_country){ + if (self::$user_country->supply_country && self::$shipping_country->supply_country) { self::$user_tax_free = true; } - $ShippingCountry = ShippingCountry::whereCountryId(self::$shipping_country->id)->first(); - self::$shipping_free = $ShippingCountry->shipping ? $ShippingCountry->shipping->free : false; + + $shippingCountry = ShippingCountry::whereCountryId(self::$shipping_country->id)->first(); + if (! $shippingCountry) { + $shippingCountry = ShippingCountry::query() + ->whereHas('shipping', fn ($q) => $q->where('active', true)) + ->orderBy('id') + ->first(); + } + if (! $shippingCountry) { + $shippingCountry = ShippingCountry::query()->orderBy('id')->first(); + } + if (! $shippingCountry) { + throw new RuntimeException('Kein Eintrag in shipping_countries (Tabelle leer oder nicht migriert).'); + } + + self::$shipping_free = $shippingCountry->shipping?->free ?? false; self::$shipping_free = self::$shipping_free !== null ? self::$shipping_free : false; - Yard::instance(self::$instance)->setShippingCountryWithPrice($ShippingCountry->id, $for); + Yard::instance(self::$instance)->setShippingCountryWithPrice($shippingCountry->id, $for); Yard::instance(self::$instance)->setUserPriceInfos(self::getYardInfo()); } - //init Yard for user order Berater - public static function initUserYard(User $user, $shipping_country_id, $for){ + // init Yard for user order Berater + public static function initUserYard(User $user, $shipping_country_id, $for) + { self::$shipping_free = false; - self::checkUserTaxShippingCountry($user, $shipping_country_id,); + self::checkUserTaxShippingCountry($user, $shipping_country_id); Yard::instance(self::$instance)->setShippingCountryWithPrice($shipping_country_id, $for); Yard::instance(self::$instance)->setUserPriceInfos(self::getYardInfo()); } - - public static function checkUserTaxShippingCountry(User $user, $shipping_country_id) { - - if(!$user->account || !$user->account->country_id){ + public static function checkUserTaxShippingCountry(User $user, $shipping_country_id) + { + + if (! $user->account || ! $user->account->country_id) { abort(403, 'Error: User hat kein Land!'); } $ShippingCountry = ShippingCountry::findOrFail($shipping_country_id); self::$user_tax_free = self::performUserTaxShippingCountry($user, $ShippingCountry); + return $ShippingCountry; /* dump( self::$user_price_code ); @@ -73,50 +100,56 @@ class UserService */ } - public static function performUserTaxShippingCountry($user, $ShippingCountry){ - //preise für das Land + public static function performUserTaxShippingCountry($user, $ShippingCountry) + { + // preise für das Land self::$user_country = $user->account->country; self::$shipping_country = $ShippingCountry->country; - //ausgehend vom Land des Rechnungsempfänger $user->account->country - //ist der Rechnungsempfänger im Drittland? - if($user->account->country->supply_country){ - if($ShippingCountry->country->supply_country){ - //Lieferadresse im Drittland? + // ausgehend vom Land des Rechnungsempfänger $user->account->country + // ist der Rechnungsempfänger im Drittland? + if ($user->account->country->supply_country) { + if ($ShippingCountry->country->supply_country) { + // Lieferadresse im Drittland? return true; } } - //Rechnungsempfänger in der EU - - //Lieferland mit RSV - if($ShippingCountry->country->eu_country){ - //Rechnungsempfänger mit valid aktiv RSV - if($user->account->reverse_charge && $user->account->reverse_charge_valid){ - //Rechnungsland ist auch Lieferland, dann RSV - if(strtolower($user->account->reverse_charge_code) == strtolower($ShippingCountry->country->code)){ + // Rechnungsempfänger in der EU + + // Lieferland mit RSV + if ($ShippingCountry->country->eu_country) { + // Rechnungsempfänger mit valid aktiv RSV + if ($user->account->reverse_charge && $user->account->reverse_charge_valid) { + // Rechnungsland ist auch Lieferland, dann RSV + if (strtolower($user->account->reverse_charge_code) == strtolower($ShippingCountry->country->code)) { self::$user_reverse_charge = true; + return true; } } - } - //Lieferland ohne RSV + } + + // Lieferland ohne RSV return false; } - public static function getYardInfo(){ + public static function getYardInfo() + { return [ - 'shipping_free' => self::$shipping_free, + 'shipping_free' => self::$shipping_free, 'user_tax_free' => self::$user_tax_free, 'user_reverse_charge' => self::$user_reverse_charge, 'user_country_id' => self::$user_country->id, - 'shipping_country_id' => self::$shipping_country->id, + 'shipping_country_id' => self::$shipping_country->id, ]; } - public static function getTaxFree(){ + public static function getTaxFree() + { return self::$user_tax_free ? true : false; } - public static function getUserPriceInfos(){ + public static function getUserPriceInfos() + { return [ 'user_tax_free' => self::$user_tax_free, 'user_reverse_charge' => self::$user_reverse_charge, @@ -124,8 +157,9 @@ class UserService ]; } - public static function getOrderInfo($key = false){ - if(!self::$user_country){ + public static function getOrderInfo($key = false) + { + if (! self::$user_country) { return ''; } switch ($key) { @@ -139,22 +173,21 @@ class UserService return self::$user_tax_free ? __('no') : __('yes'); break; case 'user_reverse_charge': - return self::$user_reverse_charge ? __('yes') : __('no'); + return self::$user_reverse_charge ? __('yes') : __('no'); break; - } } - public static function createConfirmationCode() { + public static function createConfirmationCode() + { $unique = false; - do{ + do { $confirmation_code = Str::random(30); - if(User::where('confirmation_code', '=', $confirmation_code)->count() == 0){ + if (User::where('confirmation_code', '=', $confirmation_code)->count() == 0) { $unique = true; } - } - while(!$unique); + } while (! $unique); + return $confirmation_code; } - -} \ No newline at end of file +} diff --git a/app/Services/Util.php b/app/Services/Util.php index 923666f..b736a2c 100644 --- a/app/Services/Util.php +++ b/app/Services/Util.php @@ -2,7 +2,9 @@ namespace App\Services; +use App\Models\ShoppingOrder; use App\Models\UserHistory; +use App\User; use Illuminate\Support\Str; use Request; use Yard; @@ -21,7 +23,7 @@ class Util $uuid = (string) Str::uuid(); $e_uuid = explode('-', $uuid); if (isset($e_uuid[0]) && $e_uuid[1]) { - return $e_uuid[0] . '-' . $e_uuid[1]; + return $e_uuid[0].'-'.$e_uuid[1]; } return $uuid; @@ -76,7 +78,7 @@ class Util if (strlen($str) > $length) { $str = substr($str, 0, $length); // $str = substr($str, 0, strrpos($str, " ")); - $str = $str . ' ...'; + $str = $str.' ...'; } return $str; @@ -329,9 +331,9 @@ class Util public static function getMyMivitaShopUrl($add_url = '') { if (\Session::has('user_shop_domain')) { - $url = \Session::get('user_shop_domain') . $add_url; + $url = \Session::get('user_shop_domain').$add_url; if (! str_starts_with($url, 'http')) { - $url = 'https://' . ltrim($url, '/'); + $url = 'https://'.ltrim($url, '/'); } return $url; @@ -339,22 +341,124 @@ class Util // alois sein shop $user = \App\User::find(6); if ($user && $user->shop) { - return config('app.protocol') . $user->shop->slug . '.' . config('app.domain') . config('app.tld_care') . $add_url; + return config('app.protocol').$user->shop->slug.'.'.config('app.domain').config('app.tld_care').$add_url; } } + /** + * Vollständige URL zum Warenkorb (User-Shop) nach „Nachbestellen“ im Portal. + * Verhindert Weiterleitung auf Portal/CRM/Checkout, wo /user/card/show nicht existiert (404). + */ + public static function getCustomerReorderCartUrl(?ShoppingOrder $shoppingOrder = null): string + { + $cartPath = '/user/card/show'; + $candidates = []; + + if ($shoppingOrder?->member?->shop) { + $candidates[] = config('app.protocol').$shoppingOrder->member->shop->slug.'.'.config('app.domain').config('app.tld_care'); + } + + if (\Auth::guard('customers')->check()) { + $stored = \Auth::guard('customers')->user()->user_shop_domain; + if ($stored) { + $candidates[] = $stored; + } + } + + if (\Session::has('user_shop_domain')) { + $candidates[] = \Session::get('user_shop_domain'); + } + + $user = User::find(6); + if ($user?->shop) { + $candidates[] = config('app.protocol').$user->shop->slug.'.'.config('app.domain').config('app.tld_care'); + } + + $defaultSlug = config('domains.domains.shop.default_user_shop', 'aloevera'); + $candidates[] = config('app.protocol').$defaultSlug.'.'.config('app.domain').config('app.tld_care'); + + foreach ($candidates as $candidate) { + $normalized = self::normalizeShopBaseUrl($candidate); + if ($normalized === null || self::isShopBaseUrlInvalidForUserCard($normalized)) { + continue; + } + + return $normalized.$cartPath; + } + + return config('domains.protocol').config('domains.domains.shop.host').$cartPath; + } + + /** + * Portal, CRM und Checkout hosten keine User-Shop-Warenkorb-Route unter /user/card/show. + */ + public static function isShopBaseUrlInvalidForUserCard(?string $baseUrl): bool + { + if ($baseUrl === null || $baseUrl === '') { + return true; + } + + $host = self::extractHostFromUrl($baseUrl); + if ($host === null) { + return true; + } + + $host = strtolower($host); + + $invalidHosts = array_filter([ + config('domains.domains.portal.host'), + config('domains.domains.crm.host'), + config('domains.domains.checkout.host'), + ]); + + foreach ($invalidHosts as $invalid) { + if ($invalid !== null && $invalid !== '' && strtolower($invalid) === $host) { + return true; + } + } + + return false; + } + + private static function normalizeShopBaseUrl(?string $url): ?string + { + if ($url === null || trim($url) === '') { + return null; + } + + $u = trim($url); + if (! str_starts_with($u, 'http')) { + $u = 'https://'.ltrim($u, '/'); + } + + return rtrim($u, '/'); + } + + private static function extractHostFromUrl(string $url): ?string + { + $host = parse_url($url, PHP_URL_HOST); + if (! empty($host)) { + return $host; + } + + $stripped = preg_replace('#^https?://#i', '', $url); + $parts = explode('/', $stripped, 2); + + return $parts[0] !== '' ? $parts[0] : null; + } + public static function getMyMivitaPortalUrl($protocol = true) { $pro = $protocol ? config('app.protocol') : ''; - return $pro . config('app.pre_url_portal') . config('app.domain') . config('app.tld_care'); + return $pro.config('app.pre_url_portal').config('app.domain').config('app.tld_care'); } public static function getMyMivitaUrl($protocol = true) { $pro = $protocol ? config('app.protocol') : ''; - return $pro . config('app.pre_url_crm') . config('app.domain') . config('app.tld_care'); + return $pro.config('app.pre_url_crm').config('app.domain').config('app.tld_care'); } public static function getUserPaymentFor($instance = 'shopping') @@ -377,11 +481,11 @@ class Util return \Session::get('user_shop_domain'); } if ($user_shop = \Session::get('user_shop')) { - return config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') . '/back/to/shop/' . $reference; + return config('app.protocol').$user_shop->slug.'.'.config('app.domain').config('app.tld_care').'/back/to/shop/'.$reference; } } - return config('app.protocol') . config('app.domain') . config('app.tld_care'); + return config('app.protocol').config('app.domain').config('app.tld_care'); } public static function getUserCardBackUrl($uri, $instance = 'shopping') @@ -393,36 +497,36 @@ class Util return \Session::get('back_link'); } if (self::getUserPaymentFor($instance) === 3) { - return \Session::get('user_shop_domain') . '/user/membership'; + return \Session::get('user_shop_domain').'/user/membership'; } if (self::getUserPaymentFor($instance) === 2) { - return \Session::get('user_shop_domain') . '/user/orders'; + return \Session::get('user_shop_domain').'/user/orders'; } return \Session::get('user_shop_domain'); } if ($user_shop = \Session::get('user_shop')) { - return config('app.protocol') . $user_shop->slug . '.' . config('app.domain') . config('app.tld_care') . $uri; + return config('app.protocol').$user_shop->slug.'.'.config('app.domain').config('app.tld_care').$uri; } } - return config('app.protocol') . config('app.domain') . config('app.tld_care'); + return config('app.protocol').config('app.domain').config('app.tld_care'); } public static function isMivitaShop() { - if (Request::getHost() === 'checkout.' . config('app.domain') . config('app.tld_care')) { + if (Request::getHost() === 'checkout.'.config('app.domain').config('app.tld_care')) { if ($user_shop = \Session::get('user_shop')) { if ($user_shop->slug === 'aloevera' || $user_shop->slug === 'naturcosmetic') { return true; } } } - if (Request::getHost() === 'naturcosmetic.' . config('app.domain') . config('app.tld_care')) { + if (Request::getHost() === 'naturcosmetic.'.config('app.domain').config('app.tld_care')) { return true; } - return \Config::get('app.url') === config('app.domain') . config('app.tld_shop'); + return \Config::get('app.url') === config('app.domain').config('app.tld_shop'); } public static function isTestSystem($dev = false) @@ -445,7 +549,7 @@ class Util $base = log($size) / log(1024); $suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB']; - return round(pow(1024, $base - floor($base)), $precision) . $suffixes[floor($base)]; + return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)]; } else { return $size; } diff --git a/app/User.php b/app/User.php index 4184452..8f917ad 100644 --- a/app/User.php +++ b/app/User.php @@ -2,14 +2,14 @@ namespace App; -use App\Models\PaymentMethod; -use Carbon\Carbon; -use Illuminate\Notifications\Notifiable; -use Illuminate\Foundation\Auth\User as Authenticatable; -use Illuminate\Support\Facades\Mail; use App\Mail\MailResetPassword; +use App\Models\PaymentMethod; use App\Models\UserSalesVolume; +use Carbon\Carbon; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Mail; use Laravel\Passport\HasApiTokens; /** @@ -24,6 +24,7 @@ use Laravel\Passport\HasApiTokens; * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications + * * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereEmail($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereId($value) @@ -32,6 +33,7 @@ use Laravel\Passport\HasApiTokens; * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereRememberToken($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereToken($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereUpdatedAt($value) + * * @property int $confirmed * @property string|null $confirmation_code * @property string|null $confirmation_date @@ -47,6 +49,7 @@ use Laravel\Passport\HasApiTokens; * @property \Illuminate\Support\Carbon|null $deleted_at * @property-read \App\Models\Account $account * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserUpdateEmail[] $user_update_email + * * @method static bool|null forceDelete() * @method static \Illuminate\Database\Query\Builder|\App\User onlyTrashed() * @method static bool|null restore() @@ -65,11 +68,14 @@ use Laravel\Passport\HasApiTokens; * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereNotes($value) * @method static \Illuminate\Database\Query\Builder|\App\User withTrashed() * @method static \Illuminate\Database\Query\Builder|\App\User withoutTrashed() + * * @property int|null $account_id + * * @method static \Illuminate\Database\Eloquent\Builder|\App\User newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\User newQuery() * @method static \Illuminate\Database\Eloquent\Builder|\App\User query() * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereAccountId($value) + * * @property int|null $wizard * @property int|null $blocked * @property string|null $payment_account @@ -77,16 +83,20 @@ use Laravel\Passport\HasApiTokens; * @property-read int|null $notifications_count * @property-read \App\Models\UserShop $shop * @property-read int|null $user_update_email_count + * * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereBlocked($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentAccount($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentShop($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereWizard($value) + * * @property int|null $m_level * @property int|null $m_sponsor * @property-read \App\Models\UserLevel|null $user_level * @property-read \App\User|null $user_sponsor + * * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereMLevel($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereMSponsor($value) + * * @property string|null $release_account * @property int|null $payment_order_id * @property int|null $abo_options @@ -95,9 +105,11 @@ use Laravel\Passport\HasApiTokens; * @property-read \App\Models\Product|null $payment_order * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingOrder[] $shopping_orders * @property-read int|null $shopping_orders_count + * * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereAboOptions($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentOrderId($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereReleaseAccount($value) + * * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\UserHistory[] $user_histories * @property-read int|null $user_histories_count * @property int|null $test_mode @@ -107,28 +119,37 @@ use Laravel\Passport\HasApiTokens; * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\ShoppingUser[] $member_shopping_users * @property-read int|null $member_shopping_users_count * @property-read \App\Models\Product|null $payment_order_product + * * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereSettings($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereTestMode($value) + * * @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Client[] $clients * @property-read int|null $clients_count * @property-read \Illuminate\Database\Eloquent\Collection|\Laravel\Passport\Token[] $tokens * @property-read int|null $tokens_count * @property array|null $payment_methods + * * @method static \Illuminate\Database\Eloquent\Builder|\App\User wherePaymentMethods($value) + * * @property int|null $pre_sponsor * @property-read User|null $user_pre_sponsor + * * @method static \Illuminate\Database\Eloquent\Builder|User wherePreSponsor($value) + * * @property \Illuminate\Support\Carbon|null $pre_deleted_at + * * @method static \Illuminate\Database\Eloquent\Builder|User wherePreDeletedAt($value) + * * @property-read \Illuminate\Database\Eloquent\Collection $userBusiness * @property-read int|null $user_business_count + * * @mixin \Eloquent */ class User extends Authenticatable { - use Notifiable, HasApiTokens; - + use HasApiTokens, Notifiable; use SoftDeletes; + protected $dates = ['deleted_at']; protected $table = 'users'; @@ -138,7 +159,6 @@ class User extends Authenticatable * * @var array */ - protected $fillable = [ 'email', 'password', @@ -195,7 +215,6 @@ class User extends Authenticatable return $this->hasMany('App\Models\File', 'user_id', ''); } - public function shopping_orders() { return $this->hasMany('App\Models\ShoppingOrder', 'auth_user_id', ''); @@ -236,24 +255,26 @@ class User extends Authenticatable return $this->lang ? $this->lang : \App::getLocale(); } - public function getMUserSponsor() { if ($this->user_sponsor && $this->user_sponsor->account) { - return $this->user_sponsor->account->first_name . " " . $this->user_sponsor->account->last_name . " | " . $this->user_sponsor->email; + return $this->user_sponsor->account->first_name.' '.$this->user_sponsor->account->last_name.' | '.$this->user_sponsor->email; } } + public function getFullName($email = true) { - $ret = ""; + $ret = ''; if ($this->account) { - $ret = $this->account->first_name . " " . $this->account->last_name; + $ret = $this->account->first_name.' '.$this->account->last_name; } if ($email && $this->id > 1) { - $ret .= " | " . $this->email; + $ret .= ' | '.$this->email; } + return $ret; } + /** * @return bool */ @@ -262,10 +283,10 @@ class User extends Authenticatable if ($this->password == env('APP_KEY')) { return false; } + return true; } - /** * @return bool */ @@ -274,10 +295,10 @@ class User extends Authenticatable if ($this->admin >= 1) { return true; } + return false; } - /** * @return bool */ @@ -286,6 +307,7 @@ class User extends Authenticatable if ($this->admin >= 2) { return true; } + return false; } @@ -297,6 +319,7 @@ class User extends Authenticatable if ($this->admin >= 3) { return true; } + return false; } @@ -308,15 +331,16 @@ class User extends Authenticatable if ($this->admin >= 4) { return true; } + return false; } - public function isUserHasApi() { if ($this->id === 3) { return true; } + return false; } @@ -328,6 +352,7 @@ class User extends Authenticatable if ($this->admin >= 5) { return true; } + return false; } @@ -339,15 +364,25 @@ class User extends Authenticatable return $this->test_mode ? true : false; } - /** * @return bool */ public function showSideNav() { - if ($this->active == 1 && $this->blocked == 0 && $this->wizard >= 10) { + if ($this->blocked != 0 || $this->wizard < 10) { + return false; + } + + if ($this->active == 1) { return true; } + + // Nach Account-Ablauf setzt u. a. cleanUpInActiveUser active=0; Zahlung/Mitgliedschaft + // muss im CRM weiterhin erreichbar sein (Navigation „Mein Konto“). + if ($this->payment_account && ! $this->isActiveAccount()) { + return true; + } + return false; } @@ -361,6 +396,7 @@ class User extends Authenticatable { return ($this->active == 1 && $this->blocked == 0) ? true : false; } + public function isActiveAccount() { return $this->payment_account ? Carbon::parse($this->payment_account)->gt(Carbon::now()) : false; @@ -374,14 +410,15 @@ class User extends Authenticatable public function isRenewalAccount() { if ($this->payment_account) { - return Carbon::parse($this->payment_account)->modify('-' . (config('mivita.renewal_days') + 1) . ' days')->lt(Carbon::now()); + return Carbon::parse($this->payment_account)->modify('-'.(config('mivita.renewal_days') + 1).' days')->lt(Carbon::now()); } + return false; } public function nextRenewalAccount() { - return $this->payment_account ? Carbon::parse($this->payment_account)->modify('-' . config('mivita.renewal_days') . ' days')->format(\Util::formatDateTimeDB()) : false; + return $this->payment_account ? Carbon::parse($this->payment_account)->modify('-'.config('mivita.renewal_days').' days')->format(\Util::formatDateTimeDB()) : false; } public function daysActiveAccount() @@ -389,7 +426,7 @@ class User extends Authenticatable return Carbon::now()->diffInDays(Carbon::parse($this->payment_account), false); } - public function modifyActiveAccount($add = "1 year") + public function modifyActiveAccount($add = '1 year') { return Carbon::parse($this->payment_account)->modify($add)->format(\Util::formatDateTimeDB()); } @@ -404,7 +441,7 @@ class User extends Authenticatable return Carbon::now()->diffInDays(Carbon::parse($this->payment_shop), false); } - public function modifyActiveShop($add = "1 year") + public function modifyActiveShop($add = '1 year') { return Carbon::parse($this->payment_shop)->modify($add)->format(\Util::formatDateTimeDB()); } @@ -417,12 +454,13 @@ class User extends Authenticatable public function isAcountAboPayDate() { if ($this->isAboOption()) { - $pay_days = Carbon::parse($this->payment_account)->modify('- ' . config('mivita.abo_booking_days') . ' days'); + $pay_days = Carbon::parse($this->payment_account)->modify('- '.config('mivita.abo_booking_days').' days'); $diff_days = Carbon::now()->diffInDays($pay_days, false); if ($diff_days <= 0) { return true; } } + return false; } @@ -431,9 +469,10 @@ class User extends Authenticatable */ public function getConfirmationDateFormat() { - if (!$this->attributes['confirmation_date']) { - return ""; + if (! $this->attributes['confirmation_date']) { + return ''; } + return Carbon::parse($this->attributes['confirmation_date'])->format(\Util::formatDateTimeDB()); } @@ -442,12 +481,13 @@ class User extends Authenticatable */ public function getActiveDateFormat($time = true) { - if (!$this->attributes['active_date']) { - return ""; + if (! $this->attributes['active_date']) { + return ''; } - if (!$time) { + if (! $time) { return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateDB()); } + return Carbon::parse($this->attributes['active_date'])->format(\Util::formatDateTimeDB()); } @@ -456,55 +496,59 @@ class User extends Authenticatable */ public function getAgreementFormat() { - if (!$this->attributes['agreement']) { - return ""; + if (! $this->attributes['agreement']) { + return ''; } + return Carbon::parse($this->attributes['agreement'])->format(\Util::formatDateTimeDB()); } public function getPaymentAccountDateFormat($time = true) { - if (!$this->attributes['payment_account']) { - return ""; + if (! $this->attributes['payment_account']) { + return ''; } - if (!$time) { + if (! $time) { return Carbon::parse($this->attributes['payment_account'])->format(\Util::formatDateDB()); } + return Carbon::parse($this->attributes['payment_account'])->format(\Util::formatDateTimeDB()); } public function getPaymentShopDateFormat($time = true) { - if (!$this->attributes['payment_shop']) { - return ""; + if (! $this->attributes['payment_shop']) { + return ''; } - if (!$time) { + if (! $time) { return Carbon::parse($this->attributes['payment_shop'])->format(\Util::formatDateDB()); } + return Carbon::parse($this->attributes['payment_shop'])->format(\Util::formatDateTimeDB()); } public function getReleaseAccountFormat($time = true) { - if (!$this->attributes['release_account']) { - return ""; + if (! $this->attributes['release_account']) { + return ''; } - if (!$time) { + if (! $time) { return Carbon::parse($this->attributes['release_account'])->format(\Util::formatDateDB()); } + return Carbon::parse($this->attributes['release_account'])->format(\Util::formatDateTimeDB()); } - public function setSetting(array $revisions, bool $save = true) { - if (!$this->settings) { + if (! $this->settings) { $this->settings = []; } $this->settings = array_merge($this->settings, $revisions); if ($save) { $this->save(); } + return $this; } @@ -515,38 +559,41 @@ class User extends Authenticatable public function getPaymentMethodsShort() { - $ret = ""; + $ret = ''; if ($this->payment_methods !== null) { foreach ($this->payment_methods as $payment_method) { if ($find = PaymentMethod::find($payment_method)) { - $ret .= $find->short . " | "; + $ret .= $find->short.' | '; } } - $ret = rtrim($ret, " | "); + $ret = rtrim($ret, ' | '); } + return $ret; } + /** * @return string */ public function getLandByCountry() { - if ($this->account && $this->account->country_id) { + if ($this->account && $this->account->country_id) { $code = $this->account->country->code; - if ($code == "FR") { + if ($code == 'FR') { return 'fr'; } - if ($code == "CH") { + if ($code == 'CH') { return 'de'; } - if ($code == "NL") { + if ($code == 'NL') { return 'nl'; } - if ($code == "DE") { + if ($code == 'DE') { return 'de'; } } - return "de"; + + return 'de'; } /** @@ -557,16 +604,16 @@ class User extends Authenticatable */ public function sendPasswordResetNotification($token) { - //$bcc[] = "kevin.adametz@me.com"; //config('app.checkout_mail'); - //Mail::to($this->email)->bcc($bcc)->locale(\App::getLocale())->send(new MailResetPassword($token, $this)); + // $bcc[] = "kevin.adametz@me.com"; //config('app.checkout_mail'); + // Mail::to($this->email)->bcc($bcc)->locale(\App::getLocale())->send(new MailResetPassword($token, $this)); Mail::to($this->email)->locale(\App::getLocale())->send(new MailResetPassword($token, $this)); - //$this->notify(new ResetPasswordNotification($token)); + // $this->notify(new ResetPasswordNotification($token)); } public function getUserSalesVolumeBy($month, $year, $key) { - //NOTE check ist, cant change month year ! + // NOTE check ist, cant change month year ! if ($this->userSalesVolume === false) { $this->userSalesVolume = $this->getUserSalesVolume($month, $year, 'first'); } @@ -588,7 +635,7 @@ class User extends Authenticatable case 'sales_volume_points_TP_sum': return $this->userSalesVolume->getPointsTPSum(); break; - //price net + // price net case 'sales_volume_total': return $this->userSalesVolume->month_total_net; break; @@ -602,10 +649,11 @@ class User extends Authenticatable break; } } + return 0; } - //with = ['shopping_order.shopping_user'] <- optional wenn es noch weitere relations gibt + // with = ['shopping_order.shopping_user'] <- optional wenn es noch weitere relations gibt public function getUserSalesVolume($month, $year, $record = 'get', $with = []) { $relations = array_merge(['shopping_order'], $with); diff --git a/database/factories/IncentiveFactory.php b/database/factories/IncentiveFactory.php new file mode 100644 index 0000000..eb641fe --- /dev/null +++ b/database/factories/IncentiveFactory.php @@ -0,0 +1,44 @@ + 'Incentive '.$this->faker->city().' '.$this->faker->year(), + 'description' => $this->faker->sentence(), + 'terms' => $this->faker->paragraph(), + 'qualification_start' => '2026-04-01', + 'qualification_end' => '2026-07-31', + 'calculation_end' => '2026-08-31', + 'points_partner_onetime' => 600, + 'points_abo_onetime' => 400, + 'min_direct_partners' => 4, + 'min_customer_abos' => 6, + 'max_winners' => 30, + 'status' => 1, // active + ]; + } + + public function draft(): static + { + return $this->state(['status' => 0]); + } + + public function active(): static + { + return $this->state(['status' => 1]); + } + + public function closed(): static + { + return $this->state(['status' => 2]); + } +} diff --git a/database/factories/IncentiveNewAboFactory.php b/database/factories/IncentiveNewAboFactory.php new file mode 100644 index 0000000..a193164 --- /dev/null +++ b/database/factories/IncentiveNewAboFactory.php @@ -0,0 +1,21 @@ + IncentiveParticipant::factory(), + 'user_abo_id' => $this->faker->randomNumber(3), + 'activated_at' => now(), + ]; + } +} diff --git a/database/factories/IncentiveNewPartnerFactory.php b/database/factories/IncentiveNewPartnerFactory.php new file mode 100644 index 0000000..19f5c97 --- /dev/null +++ b/database/factories/IncentiveNewPartnerFactory.php @@ -0,0 +1,22 @@ + IncentiveParticipant::factory(), + 'user_id' => fn () => User::inRandomOrder()->first()?->id ?? 1, + 'registered_at' => now(), + ]; + } +} diff --git a/database/factories/IncentiveParticipantFactory.php b/database/factories/IncentiveParticipantFactory.php new file mode 100644 index 0000000..ea27c02 --- /dev/null +++ b/database/factories/IncentiveParticipantFactory.php @@ -0,0 +1,46 @@ + Incentive::factory(), + 'user_id' => fn () => User::inRandomOrder()->first()?->id ?? 1, + 'accepted_terms_at' => now(), + 'total_points' => 0, + 'qualified_partners' => 0, + 'qualified_abos' => 0, + 'is_qualified' => false, + 'rank' => null, + ]; + } + + public function qualified(): static + { + return $this->state([ + 'is_qualified' => true, + 'qualified_partners' => 4, + 'qualified_abos' => 6, + ]); + } + + /** + * Automatisch angelegt, Teilnahmebedingungen noch nicht akzeptiert (anonym in der Rangliste). + */ + public function unconfirmed(): static + { + return $this->state([ + 'accepted_terms_at' => null, + ]); + } +} diff --git a/database/factories/IncentivePointsLogFactory.php b/database/factories/IncentivePointsLogFactory.php new file mode 100644 index 0000000..1063c95 --- /dev/null +++ b/database/factories/IncentivePointsLogFactory.php @@ -0,0 +1,51 @@ + IncentiveParticipant::factory(), + 'type' => 'partner', + 'source_type' => 'App\\User', + 'source_id' => 1, + 'source_label' => $this->faker->name(), + 'month' => 4, + 'year' => 2026, + 'points_onetime' => 600, + 'points_accumulated' => 0, + 'is_storno' => false, + ]; + } + + public function partner(): static + { + return $this->state([ + 'type' => 'partner', + 'points_onetime' => 600, + ]); + } + + public function abo(): static + { + return $this->state([ + 'type' => 'abo', + 'points_onetime' => 400, + ]); + } + + public function storno(): static + { + return $this->state([ + 'is_storno' => true, + ]); + } +} diff --git a/database/migrations/2018_09_29_145909_create_countries_table.php b/database/migrations/2018_09_29_145909_create_countries_table.php index b2badba..5e60a88 100644 --- a/database/migrations/2018_09_29_145909_create_countries_table.php +++ b/database/migrations/2018_09_29_145909_create_countries_table.php @@ -1,8 +1,8 @@ boolean('currency_calc')->default(false); $table->decimal('currency_faktor', 4, 2)->nullable(); - $table->boolean('active')->default(true); $table->text('trans_name')->nullable(); $table->text('attr')->nullable(); $table->timestamps(); - - }); } diff --git a/database/migrations/2025_08_22_172138_add_routing_code_to_dhl_package_shipments_table.php b/database/migrations/2025_08_22_172138_add_routing_code_to_dhl_package_shipments_table.php index c1ee8bf..14a4e4a 100644 --- a/database/migrations/2025_08_22_172138_add_routing_code_to_dhl_package_shipments_table.php +++ b/database/migrations/2025_08_22_172138_add_routing_code_to_dhl_package_shipments_table.php @@ -11,9 +11,11 @@ return new class extends Migration */ public function up(): void { - Schema::table('dhl_package_shipments', function (Blueprint $table) { - $table->string('routing_code')->nullable()->after('dhl_shipment_no'); - }); + if (! Schema::hasColumn('dhl_package_shipments', 'routing_code')) { + Schema::table('dhl_package_shipments', function (Blueprint $table) { + $table->string('routing_code')->nullable()->after('dhl_shipment_no'); + }); + } } /** diff --git a/database/migrations/2026_03_17_000001_create_incentives_table.php b/database/migrations/2026_03_17_000001_create_incentives_table.php new file mode 100644 index 0000000..d48953c --- /dev/null +++ b/database/migrations/2026_03_17_000001_create_incentives_table.php @@ -0,0 +1,41 @@ +increments('id'); + + $table->string('name', 255); + $table->string('slug', 255)->unique(); + $table->text('description')->nullable(); + $table->string('image', 255)->nullable()->comment('Statisches Bild Dateiname'); + $table->text('terms')->nullable()->comment('Teilnahmebedingungen'); + + $table->date('qualification_start')->comment('Beginn Qualifikationszeitraum'); + $table->date('qualification_end')->comment('Ende Qualifikationszeitraum'); + $table->date('calculation_end')->comment('Ende Punkteberechnung (inkl. Verlaengerung)'); + + $table->unsignedInteger('points_partner_onetime')->default(600)->comment('Einmalpunkte pro Neupartner'); + $table->unsignedInteger('points_abo_onetime')->default(400)->comment('Einmalpunkte pro Kundenabo'); + $table->unsignedInteger('min_direct_partners')->default(4)->comment('Mindestanzahl direkte Teampartner'); + $table->unsignedInteger('min_customer_abos')->default(6)->comment('Mindestanzahl Kundenabos'); + $table->unsignedInteger('max_winners')->default(30)->comment('Maximale Anzahl Gewinner'); + + $table->unsignedTinyInteger('status')->default(0)->index()->comment('0=draft, 1=active, 2=closed'); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('incentives'); + } +}; diff --git a/database/migrations/2026_03_17_000002_create_incentive_participants_table.php b/database/migrations/2026_03_17_000002_create_incentive_participants_table.php new file mode 100644 index 0000000..f4da748 --- /dev/null +++ b/database/migrations/2026_03_17_000002_create_incentive_participants_table.php @@ -0,0 +1,44 @@ +increments('id'); + + $table->unsignedInteger('incentive_id'); + $table->foreign('incentive_id') + ->references('id') + ->on('incentives') + ->onDelete('cascade'); + + $table->unsignedInteger('user_id'); + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + + $table->datetime('accepted_terms_at')->comment('Zeitpunkt Teilnahme-Bestaetigung'); + + $table->integer('total_points')->default(0)->comment('Gesamtpunkte (Cache fuer Ranking)'); + $table->unsignedInteger('qualified_partners')->default(0)->comment('Anzahl qualifizierter Neupartner'); + $table->unsignedInteger('qualified_abos')->default(0)->comment('Anzahl qualifizierter Kundenabos'); + $table->boolean('is_qualified')->default(false)->index()->comment('Mindestqualifikation erreicht'); + $table->unsignedInteger('rank')->nullable()->comment('Aktuelle Platzierung'); + + $table->timestamps(); + + $table->unique(['incentive_id', 'user_id'], 'incentive_user_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('incentive_participants'); + } +}; diff --git a/database/migrations/2026_03_17_000003_create_incentive_points_log_table.php b/database/migrations/2026_03_17_000003_create_incentive_points_log_table.php new file mode 100644 index 0000000..5633822 --- /dev/null +++ b/database/migrations/2026_03_17_000003_create_incentive_points_log_table.php @@ -0,0 +1,55 @@ +increments('id'); + + $table->unsignedInteger('participant_id'); + $table->foreign('participant_id') + ->references('id') + ->on('incentive_participants') + ->onDelete('cascade'); + + $table->string('type', 10)->index()->comment('partner oder abo'); + + $table->string('source_type', 100)->comment('Polymorphic: App\\User oder App\\Models\\UserAbo'); + $table->unsignedInteger('source_id')->comment('ID des Neupartners oder des Abos'); + $table->string('source_label', 255)->comment('Name/Bezeichnung fuer Anzeige'); + + $table->unsignedTinyInteger('month')->comment('Bezugsmonat'); + $table->unsignedSmallInteger('year')->comment('Bezugsjahr'); + + $table->integer('points_onetime')->default(0)->comment('Einmalpunkte (600 bzw. 400)'); + $table->integer('points_accumulated')->default(0)->comment('Akkumulierte Punkte des Monats'); + + $table->boolean('is_storno')->default(false)->index()->comment('Storno-Eintrag'); + $table->unsignedInteger('storno_of_id')->nullable()->comment('Referenz auf stornierten Eintrag'); + $table->foreign('storno_of_id') + ->references('id') + ->on('incentive_points_log'); + + $table->unsignedInteger('user_sales_volume_id')->nullable(); + $table->foreign('user_sales_volume_id') + ->references('id') + ->on('user_sales_volumes'); + + $table->timestamps(); + + $table->index(['participant_id', 'type']); + $table->index(['source_type', 'source_id']); + $table->index(['month', 'year']); + }); + } + + public function down(): void + { + Schema::dropIfExists('incentive_points_log'); + } +}; diff --git a/database/migrations/2026_03_17_000004_create_incentive_new_partners_table.php b/database/migrations/2026_03_17_000004_create_incentive_new_partners_table.php new file mode 100644 index 0000000..d568fbf --- /dev/null +++ b/database/migrations/2026_03_17_000004_create_incentive_new_partners_table.php @@ -0,0 +1,38 @@ +increments('id'); + + $table->unsignedInteger('participant_id'); + $table->foreign('participant_id') + ->references('id') + ->on('incentive_participants') + ->onDelete('cascade'); + + $table->unsignedInteger('user_id')->comment('Der neue Partner'); + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + + $table->datetime('registered_at')->comment('Registrierungsdatum des Partners'); + + $table->timestamps(); + + $table->unique(['participant_id', 'user_id'], 'participant_partner_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('incentive_new_partners'); + } +}; diff --git a/database/migrations/2026_03_17_000005_create_incentive_new_abos_table.php b/database/migrations/2026_03_17_000005_create_incentive_new_abos_table.php new file mode 100644 index 0000000..a0f9cdd --- /dev/null +++ b/database/migrations/2026_03_17_000005_create_incentive_new_abos_table.php @@ -0,0 +1,38 @@ +increments('id'); + + $table->unsignedInteger('participant_id'); + $table->foreign('participant_id') + ->references('id') + ->on('incentive_participants') + ->onDelete('cascade'); + + $table->unsignedInteger('user_abo_id')->comment('Das Kundenabo'); + $table->foreign('user_abo_id') + ->references('id') + ->on('user_abos') + ->onDelete('cascade'); + + $table->datetime('activated_at')->comment('Aktivierungsdatum des Abos'); + + $table->timestamps(); + + $table->unique(['participant_id', 'user_abo_id'], 'participant_abo_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('incentive_new_abos'); + } +}; diff --git a/database/migrations/2026_03_17_141526_add_missing_columns_to_user_sales_volumes_table.php b/database/migrations/2026_03_17_141526_add_missing_columns_to_user_sales_volumes_table.php new file mode 100644 index 0000000..ece6672 --- /dev/null +++ b/database/migrations/2026_03_17_141526_add_missing_columns_to_user_sales_volumes_table.php @@ -0,0 +1,30 @@ +string('info', 255)->nullable()->after('message'); + } + if (! Schema::hasColumn('user_sales_volumes', 'month_KP_points')) { + $table->decimal('month_KP_points', 13, 2)->nullable()->after('month_shop_points'); + } + if (! Schema::hasColumn('user_sales_volumes', 'month_TP_points')) { + $table->decimal('month_TP_points', 13, 2)->nullable()->after('month_KP_points'); + } + }); + } + + public function down(): void + { + Schema::table('user_sales_volumes', function (Blueprint $table) { + $table->dropColumn(['info', 'month_KP_points', 'month_TP_points']); + }); + } +}; diff --git a/database/migrations/2026_03_17_155233_add_translations_to_incentives_table.php b/database/migrations/2026_03_17_155233_add_translations_to_incentives_table.php new file mode 100644 index 0000000..a10630a --- /dev/null +++ b/database/migrations/2026_03_17_155233_add_translations_to_incentives_table.php @@ -0,0 +1,24 @@ +json('trans_name')->nullable()->after('name'); + $table->json('trans_description')->nullable()->after('description'); + $table->json('trans_terms')->nullable()->after('terms'); + }); + } + + public function down(): void + { + Schema::table('incentives', function (Blueprint $table) { + $table->dropColumn(['trans_name', 'trans_description', 'trans_terms']); + }); + } +}; diff --git a/database/migrations/2026_03_17_163012_add_subtitle_to_incentives_table.php b/database/migrations/2026_03_17_163012_add_subtitle_to_incentives_table.php new file mode 100644 index 0000000..f27ed75 --- /dev/null +++ b/database/migrations/2026_03_17_163012_add_subtitle_to_incentives_table.php @@ -0,0 +1,23 @@ +string('subtitle')->nullable()->after('name'); + $table->json('trans_subtitle')->nullable()->after('trans_name'); + }); + } + + public function down(): void + { + Schema::table('incentives', function (Blueprint $table) { + $table->dropColumn(['subtitle', 'trans_subtitle']); + }); + } +}; diff --git a/database/migrations/2026_03_26_000001_add_tracking_fks_to_incentive_points_log_table.php b/database/migrations/2026_03_26_000001_add_tracking_fks_to_incentive_points_log_table.php new file mode 100644 index 0000000..e8457c1 --- /dev/null +++ b/database/migrations/2026_03_26_000001_add_tracking_fks_to_incentive_points_log_table.php @@ -0,0 +1,38 @@ +unsignedInteger('incentive_new_partner_id')->nullable()->after('user_sales_volume_id'); + $table->unsignedInteger('incentive_new_abo_id')->nullable()->after('incentive_new_partner_id'); + + $table->foreign('incentive_new_partner_id') + ->references('id') + ->on('incentive_new_partners') + ->nullOnDelete(); + + $table->foreign('incentive_new_abo_id') + ->references('id') + ->on('incentive_new_abos') + ->nullOnDelete(); + + $table->index('incentive_new_partner_id'); + $table->index('incentive_new_abo_id'); + }); + } + + public function down(): void + { + Schema::table('incentive_points_log', function (Blueprint $table) { + $table->dropForeign(['incentive_new_partner_id']); + $table->dropForeign(['incentive_new_abo_id']); + $table->dropColumn(['incentive_new_partner_id', 'incentive_new_abo_id']); + }); + } +}; diff --git a/database/migrations/2026_04_01_150511_make_accepted_terms_at_nullable_on_incentive_participants_table.php b/database/migrations/2026_04_01_150511_make_accepted_terms_at_nullable_on_incentive_participants_table.php new file mode 100644 index 0000000..7e8ec94 --- /dev/null +++ b/database/migrations/2026_04_01_150511_make_accepted_terms_at_nullable_on_incentive_participants_table.php @@ -0,0 +1,22 @@ +datetime('accepted_terms_at')->nullable()->change()->comment('Zeitpunkt Teilnahme-Bestaetigung; null = automatisch erfasst, noch nicht zugestimmt'); + }); + } + + public function down(): void + { + Schema::table('incentive_participants', function (Blueprint $table) { + $table->datetime('accepted_terms_at')->nullable(false)->change()->comment('Zeitpunkt Teilnahme-Bestaetigung'); + }); + } +}; diff --git a/database/migrations/2026_04_07_173038_create_abo_chart_snapshots_table.php b/database/migrations/2026_04_07_173038_create_abo_chart_snapshots_table.php new file mode 100644 index 0000000..3f58354 --- /dev/null +++ b/database/migrations/2026_04_07_173038_create_abo_chart_snapshots_table.php @@ -0,0 +1,37 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('scope', 50); // 'ot' | 'team_abos' | 'team_cust_abos' + $table->unsignedSmallInteger('year'); + $table->unsignedTinyInteger('month'); + $table->unsignedInteger('count')->default(0); + $table->timestamp('calculated_at'); + $table->timestamps(); + + // Einmaliger Eintrag pro User/Scope/Monat – verhindert Doppeleinträge + $table->unique(['user_id', 'scope', 'year', 'month']); + $table->index(['user_id', 'scope', 'year']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('abo_chart_snapshots'); + } +}; diff --git a/database/seeders/IncentiveParticipantSeeder.php b/database/seeders/IncentiveParticipantSeeder.php new file mode 100644 index 0000000..93d62e1 --- /dev/null +++ b/database/seeders/IncentiveParticipantSeeder.php @@ -0,0 +1,53 @@ +pluck('user_id') + ->toArray(); + + // Aktive Berater mit den meisten direkten Partnern holen + $consultants = User::where('active', 1) + ->whereNotNull('m_sponsor') + ->whereNotIn('id', $existing_user_ids) + ->select('users.*') + ->selectSub( + DB::table('users as u2')->selectRaw('COUNT(*)')->whereColumn('u2.m_sponsor', 'users.id'), + 'direct_partners_count' + ) + ->orderByDesc('direct_partners_count') + ->limit($limit) + ->get(); + + $count = 0; + foreach ($consultants as $consultant) { + IncentiveParticipant::create([ + 'incentive_id' => $incentive_id, + 'user_id' => $consultant->id, + 'accepted_terms_at' => Carbon::now(), + ]); + $count++; + } + + $this->command->info("Incentive #{$incentive_id}: {$count} Teilnehmer hinzugefuegt (bestehende uebersprungen)."); + } +} diff --git a/dev/2026-03-12/tasks.md b/dev/2026-03-12/tasks.md new file mode 100644 index 0000000..7e24c07 --- /dev/null +++ b/dev/2026-03-12/tasks.md @@ -0,0 +1,127 @@ +# Korrekturen & Verbesserungen - 12.03.2026 + +--- + +## 1. Bug-Fix: Falsche Zahlungsbeträge bei Abo-Bestellungen + +**Datei:** `app/Cron/UserMakeOrder.php` (Zeile 71) + +**Problem:** Bei der automatischen Abo-Zahlungsausführung wurde `subtotal_ws` (Nettobetrag + Versand) statt `total_shipping` (Bruttobetrag + Versand) als Zahlungsbetrag an Payone gesendet. Dadurch wurde bei Kunden mit Umsatzsteuer nur der Nettobetrag eingezogen - die MwSt fehlte. + +**Fix:** +```php +// Vorher (falsch): +$amount = $this->shopping_order->subtotal_ws * 100; + +// Nachher (korrekt): +$amount = $this->shopping_order->total_shipping * 100; +``` + +**Auswirkung:** Betrifft alle Abo-Bestellungen seit Go-Live, bei denen Umsatzsteuer anfällt. Bei Nettobeträgen (Drittländer, USt-ID hinterlegt) war der Betrag zufällig korrekt. + +--- + +## 2. SysAdmin Tool: Abo-Bestellungen Zahlungsdifferenzen + +**Neue Dateien:** +- `app/Services/SyS/AboOrdersOverview.php` +- `resources/views/sys/tools/abo-orders-overview.blade.php` + +**Geänderte Dateien:** +- `app/Http/Controllers/SyS/SysController.php` (neuer case `abo_orders_overview`) +- `resources/views/sys/index.blade.php` (neuer Menüpunkt) + +**Beschreibung:** Übersichtstool unter `sysadmin/tool/abo_orders_overview` zur Nachverfolgung der durch den Bug betroffenen Abo-Bestellungen. Zeigt pro Bestellung: +- Netto+Versand (`subtotal_ws`) = was eingezogen wurde +- MwSt (`tax`) = was gefehlt hat +- Brutto+Versand (`total_shipping`) = was hätte eingezogen werden sollen +- Differenz = fehlender Betrag (rot markiert) + +**Features:** +- Filter: Alle / Berater / Kunden (Button-Gruppe) +- Summary-Karten: Gesamtanzahl, betroffene Bestellungen, Gesamtfehlbetrag +- Betroffene Zeilen rot hervorgehoben + +--- + +## 3. Bug-Fix: Abo-Einstellungen - Vergleichsoperator + +**Datei:** `app/Repositories/AboRepository.php` (Zeile 66) + +**Problem:** Bei der Reaktivierung eines pausierten Abos wurde statt eines Vergleichs (`==`) eine Zuweisung (`=`) verwendet. Dadurch wurde der Status immer auf 6 gesetzt, bevor er auf 2 geändert wurde. + +**Fix:** +```php +// Vorher (falsch - Zuweisung): +if ($this->model->status = 6) + +// Nachher (korrekt - Vergleich): +if ($this->model->status == 6) +``` + +--- + +## 4. Abo-Liefertag: Validierung und Sperren + +**Datei:** `app/Repositories/AboRepository.php` + +**Problem:** Beim Ändern des Abo-Liefertags konnte ein Datum gewählt werden, das nur wenige Tage entfernt liegt. Da Pakete vorgepackt werden, muss ein Mindestabstand eingehalten werden. + +**Lösung:** Zwei Klassenkonstanten steuern die Sperren: + +```php +private const LOCK_DAYS_CHANGE = 10; // Liefertag-Änderung +private const LOCK_DAYS_PAUSE_CANCEL = 3; // Pausieren/Kündigen +``` + +### Regeln: + +| Aktion | Sperre | Beschreibung | +|--------|--------|--------------| +| Liefertag ändern | < 10 Tage vor aktueller Ausführung | `error_change_locked` - Pakete werden vorgepackt | +| Neuen Liefertag wählen | Neues Datum < 10 Tage entfernt | `error_abo_interval_too_soon` - Berechnung via `setNextDate()` | +| Abo pausieren | < 3 Tage vor Ausführung | `error_pause_locked` | +| Abo kündigen | < 3 Tage vor Ausführung | `error_cancel_locked` | + +### Validierungslogik: +1. Aktuelles `next_date` prüfen: Wenn < 10 Tage entfernt → komplett gesperrt +2. Neues Datum berechnen via `AboHelper::setNextDate()` und prüfen ob >= 10 Tage entfernt +3. Nach erfolgreichem Speichern: Orange Alert mit exaktem nächsten Ausführungsdatum + +### Beispiele (heute = 12. März): +| Von | Auf | Berechnet | Tage | Ergebnis | +|-----|-----|-----------|------|----------| +| 5. | 20. | 20. März | 8 | blockiert | +| 5. | 25. | 25. März | 13 | erlaubt | +| 20. | 10. | 10. April | ~29 | erlaubt (nächster Monat) | + +--- + +## 5. Warnungen bei Abo-Erstellung (Bestellformular) + +**Geänderte Dateien:** +- `app/Services/HTMLHelper.php` (`getAboDeliveryOptions`) +- `resources/views/user/order/yard_view_form.blade.php` +- `resources/views/portal/abo/_create_check.blade.php` + +**Beschreibung:** Beim Erstellen eines neuen Abos zeigt das Liefertag-Dropdown eine dynamische Warnung (orange) an, wenn die erste Abo-Ausführung weniger als 20 Tage entfernt ist. Die Warnung aktualisiert sich beim Wechsel des Liefertags. + +**Umsetzung:** +- `HTMLHelper::getAboDeliveryOptions()` gibt pro Option `data-days` und `data-date` Attribute aus +- JavaScript im Formular prüft beim Ändern der Auswahl die Tage und zeigt/versteckt die Warnung + +--- + +## 6. Neue Übersetzungen (DE/EN/ES) + +**Dateien:** `resources/lang/{de,en,es}/abo.php` + +| Key | Beschreibung | +|-----|-------------| +| `warning_next_date_soon` | Warnung: Nächste Ausführung < 20 Tage (Backend-Flash) | +| `warning_next_date_soon_select` | Warnung: Nächste Ausführung < 20 Tage (Frontend-Select) | +| `warning_next_date_info` | Info nach Speichern: Nächste Ausführung in X Tagen am Datum | +| `error_change_locked` | Fehler: Änderung gesperrt (< 10 Tage) | +| `error_abo_interval_too_soon` | Fehler: Neuer Liefertag zu nah (< 10 Tage) | +| `error_cancel_locked` | Fehler: Kündigung gesperrt (< 3 Tage) | +| `error_pause_locked` | Fehler: Pausierung gesperrt (< 3 Tage) | diff --git a/dev/Incentive-Modul/Incentive-montenegro.pdf b/dev/Incentive-Modul/Incentive-montenegro.pdf new file mode 100644 index 0000000..7fd5d8a Binary files /dev/null and b/dev/Incentive-Modul/Incentive-montenegro.pdf differ diff --git a/dev/Incentive-Modul/README.md b/dev/Incentive-Modul/README.md new file mode 100644 index 0000000..3b10678 --- /dev/null +++ b/dev/Incentive-Modul/README.md @@ -0,0 +1,92 @@ +# Incentive-Modul (CRM) + +Übersichtliche Beschreibung des **wiederverwendbaren Incentive-Systems** in mivita: zeitlich begrenzte Challenges (z. B. Reise-Anreize), eigenes Punkte- und Ranking-Modell, **getrennt** von MLM-Provisionsberechnung (`BusinessPlan` / `TreeCalcBot`). + +--- + +## Zweck + +- Mehrere **Incentives** parallel oder nacheinander (pro Jahr/Kampagne anlegbar). +- Berater **opt-in** mit Teilnahmebedingungen; **Punkte**, **Mindestqualifikation** (Partner + Kundenabos) und **Top-N-Gewinner** sind pro Incentive konfigurierbar. +- **Transparenz** für Berater: Live-Rangliste, Detailansicht mit Aufschlüsselung nach Neupartnern und Kundenabos (inkl. Monaten). + +--- + +## Architektur in Kürze + +| Schicht | Inhalt | +|--------|--------| +| **Konfiguration** | `incentives` – Zeiträume, Punkte, Mindestwerte, `max_winners`, Status, Slug, Übersetzungen | +| **Teilnahme** | `incentive_participants` – Opt-in, aggregierte Werte, Rang, Qualifikations-Flags | +| **Tracking** | `incentive_new_partners`, `incentive_new_abos` – welche Neupartner/Abos einem Teilnehmer zugeordnet sind | +| **Historie** | `incentive_points_log` – Einmal- und Akkumpunkte, optional Verknüpfung zu `UserSalesVolume`, Stornos | + +**Kernservice:** `App\Services\Incentive\IncentiveTracker` – wird aus Zahlungsfluss, Rechnung/Sales Volume und Storno aufgerufen. + +**Batch:** `php artisan incentive:calculate` (`IncentiveCalculate`) + optional `IncentiveCalculationService` / Neuaufbau aus Quellen. + +--- + +## Datenfluss (Events) + +1. **Neupartner mit Starterpaket** – bezahlte **Wizard-Registrierung** (`payment_for = 1`), Bestellung enthält mindestens ein Produkt **ohne** „reine Mitgliedschaft“ (`Product::is_membership_only = false`). → Eintrag in `incentive_new_partners`, Einmalpunkte im Log, Neuberechnung. +2. **Abo aktiviert** – nach `AboHelper::setAboActive`: **Kundenabo** `is_for = 'ot'` (Berater = `member_id`) **oder Berater-Eigenabo** `is_for = 'me'` (Berater = `user_id`). → `incentive_new_abos`, Einmalpunkte, Neuberechnung. Beim Neuaufbau zählen Kundenabos über `member_id` im Qualifikationszeitraum; Eigenabos (`me`) neu im Zeitraum oder **bereits vor Qualifikationsbeginn** aktiv (dann Einmalpunkte mit Wirkung ab Qualifikationsstart). +3. **Akkumulierte Punkte** – bei neuer **`UserSalesVolume`**-Zeile (Rechnung): Umsatzpunkte von **getrackten Neupartnern** (SV gehört zur User-ID des Partners) bzw. im **Neuabo-Pfad** über Bestellung/Kundenkontext. → Log `points_accumulated`, Neuberechnung. +4. **Storno** – Gegenbuchung im Log, Neuberechnung. + +Details und Integrationsstellen: **`entwicklungsplan.md`**. + +--- + +## UI + +| Bereich | Beschreibung | +|---------|----------------| +| **Admin** | CRUD Incentives, Konfiguration, Teilnehmer-Ranking-Tabelle | +| **Berater** | Teaser/Show (Info, Live-Ranking, Teilnahme), **Details** mit Monatsaufschlüsselung | +| **Navigation** | Einträge z. B. über Dashboard / Sidenav (je nach Rolle) | + +Content-Texte (Marketing) für eine konkrete Kampagne liegen separat in **`site.md`**. + +--- + +## Wichtige Geschäftsregeln (implementiert) + +- **Rangliste (User):** Sortierung: zuerst **qualifizierte** Teilnehmer (Mindest-Partner/Abos), dann nach **Rang**, Teilnehmer **ohne** Rang unten, Tiebreaker **Gesamtpunkte**; Anzeige begrenzt auf **`max_winners`**; optional nur Teilnehmer mit **Aktivität** (Partner, Abo oder Punkte > 0) – Scope `withRankingActivity()`. +- **Neupartner zählen nur mit Starterpaket** – keine reine Mitgliedschafts-Bestellung allein; technisch: `ShoppingOrder::wherePaidRegistrationIncludesStarterKit()` / `qualifiesForIncentiveTrackedPartner()`. + +--- + +## Dokumente in diesem Ordner + +| Datei | Inhalt | +|-------|--------| +| **README.md** (diese Datei) | Modulübersicht | +| **entwicklungsplan.md** | Datenmodell, Services, Hooks, Phasen, Dateiliste, Architektur-Tracking | +| **tasks.md** | Anforderungen, Aufgaben- und Statusübersicht | +| **site.md** | Vorgefertigte Textbausteine / Nutzungsbedingungen (Content Montenegro 2026) | + +--- + +## Relevante Code-Pfade (Referenz) + +``` +app/Models/Incentive.php +app/Models/IncentiveParticipant.php +app/Models/IncentivePointsLog.php +app/Models/IncentiveNewPartner.php +app/Models/IncentiveNewAbo.php +app/Services/Incentive/IncentiveTracker.php +app/Services/Incentive/IncentiveCalculationService.php +app/Services/Incentive/IncentivePointsLogRepairService.php +app/Http/Controllers/Admin/IncentiveController.php +app/Http/Controllers/User/IncentiveController.php +resources/lang/{de,en,es}/incentive.php +tests/Feature/Incentive/ +tests/Unit/Incentive/ +tests/Unit/Services/Incentive/ +``` + +--- + +*Letzte inhaltliche Aktualisierung der Modul-Doku: März 2026.* diff --git a/dev/Incentive-Modul/entwicklungsplan.md b/dev/Incentive-Modul/entwicklungsplan.md new file mode 100644 index 0000000..904daf9 --- /dev/null +++ b/dev/Incentive-Modul/entwicklungsplan.md @@ -0,0 +1,530 @@ +# Entwicklungsplan: Incentive-System (Montenegro 2026) + +**Erstellt**: 17.03.2026 +**Frist**: Fertigstellung innerhalb 2 Wochen (bis 31.03.2026) +**Ziel**: Wiederverwendbares Incentive-/Gewinnspiel-System, losgelöst von allen anderen Berechnungen. + +**Kurzüberblick für Einstieg:** siehe **`README.md`** im gleichen Ordner. + +--- + +## 1. Datenbank-Design (3 neue Tabellen) + +### Tabelle `incentives` – Master-Konfiguration + +| Feld | Typ | Beschreibung | +|-------------------------|------------------|--------------------------------------------------| +| id | bigint PK | | +| name | string | z.B. "Montenegro Incentive 2026" | +| slug | string unique | URL-Slug (Eloquent-Sluggable) | +| description | text nullable | Erklärungstext für Berater-Seite | +| image | string nullable | Dateiname des statischen Bildes | +| terms | text nullable | Teilnahmebedingungen | +| qualification_start | date | Beginn Qualifikationszeitraum (z.B. 2026-04-01) | +| qualification_end | date | Ende Qualifikationszeitraum (z.B. 2026-07-31) | +| calculation_end | date | Ende Punkteberechnung (z.B. 2026-08-31) | +| points_partner_onetime | int default 600 | Einmalpunkte pro Neupartner | +| points_abo_onetime | int default 400 | Einmalpunkte pro Kundenabo | +| min_direct_partners | int default 4 | Mindestanzahl direkte Teampartner | +| min_customer_abos | int default 6 | Mindestanzahl Kundenabos | +| max_winners | int default 30 | Maximale Anzahl Gewinner | +| status | enum | draft / active / closed | +| timestamps | | | +| soft_deletes | | | + +### Tabelle `incentive_participants` – Opt-in + aggregierte Werte + +| Feld | Typ | Beschreibung | +|----------------------|------------------|-------------------------------------------| +| id | bigint PK | | +| incentive_id | FK | | +| user_id | FK | Berater | +| accepted_terms_at | datetime | Zeitpunkt der Teilnahme-Bestätigung | +| total_points | int default 0 | Gesamtpunkte (Cache für Ranking) | +| qualified_partners | int default 0 | Anzahl qualifizierter Neupartner | +| qualified_abos | int default 0 | Anzahl qualifizierter Kundenabos | +| is_qualified | bool default false | Mindestqualifikation erreicht? | +| rank | int nullable | Aktuelle Platzierung | +| timestamps | | | +| unique(incentive_id, user_id) | | | + +### Tabelle `incentive_points_log` – Detaillierte Punktehistorie + +| Feld | Typ | Beschreibung | +|------------------------|------------------|---------------------------------------------| +| id | bigint PK | | +| participant_id | FK | → incentive_participants | +| type | enum | partner / abo | +| source_type | string | Polymorphic: User oder UserAbo | +| source_id | int | ID des Neupartners oder des Abos | +| source_label | string | Name/Bezeichnung für Anzeige | +| month | tinyint | Bezugsmonat | +| year | smallint | Bezugsjahr | +| points_onetime | int default 0 | Einmalpunkte (600 bzw. 400) | +| points_accumulated | int default 0 | Akkumulierte Punkte des Monats | +| is_storno | bool default false | Storno-Eintrag? | +| storno_of_id | FK nullable | Referenz auf stornierten Eintrag | +| user_sales_volume_id | FK nullable | Verknüpfung zur Quelle | +| timestamps | | | + +--- + +## 2. Models + +### `Incentive` (app/Models/Incentive.php) +- Casts: status als Enum, dates als Carbon +- Relationships: `participants()` hasMany, `activeParticipants()` +- Scopes: `scopeActive()`, `scopeInQualificationPeriod()` +- Sluggable-Konfiguration für URL-Slug +- Hilfsmethoden: `isActive()`, `isInQualificationPeriod()`, `isInCalculationPeriod()` + +### `IncentiveParticipant` (app/Models/IncentiveParticipant.php) +- Relationships: `incentive()` belongsTo, `user()` belongsTo, `pointsLog()` hasMany +- Scopes: `scopeQualified()`, `scopeWinners()` (qualified + rank <= max_winners) +- Methode: `recalculatePoints()`, `checkQualification()` + +### `IncentivePointsLog` (app/Models/IncentivePointsLog.php) +- Relationships: `participant()` belongsTo, `salesVolume()` belongsTo +- Scopes: `scopePartner()`, `scopeAbo()`, `scopeActive()` (nicht storniert) + +--- + +## 3. Service-Architektur + +### `IncentiveTracker` (app/Services/Incentive/IncentiveTracker.php) + +Wird aus bestehenden Klassen aufgerufen. Enthält statische Methoden: + +#### `trackNewPartner(ShoppingOrder $shopping_order)` +- Wird aufgerufen in: `Payment::paymentStatusPaidAction()` bei **`payment_for == 1`** (Wizard-/Registrierungskauf) +- **Zusätzliche Bedingung:** `ShoppingOrder::qualifiesForIncentiveTrackedPartner()` — bezahlte Registrierung **und** mindestens eine Bestellposition mit Produkt, das **keine** reine Mitgliedschaft ohne Starterpaket ist (`Product::is_membership_only === false`). Siehe `wherePaidRegistrationIncludesStarterKit()` auf `ShoppingOrder`. +- Logik (Kern): + 1. Neuer User aus `auth_user_id`; Sponsor: `User::m_sponsor` + 2. Aktive Incentives im Registrierungszeitpunkt (Qualifikationszeitraum) + 3. Sponsor muss `IncentiveParticipant` haben + 4. `incentive_new_partners` (Tracking) + `incentive_points_log` (Einmalpunkte) + `recalculateFromTrackingTables()` + Ranking + +#### `trackAboActivated(ShoppingOrder $shopping_order)` +- Wird aufgerufen in: `Payment::paymentStatusPaidAction()` (nach `AboHelper::setAboActive`, Zeile 280) +- **Wichtig**: Nur wenn `setAboActive` mit `status=2, paid=true` aufgerufen wird +- Bedingung: Abo `is_for === 'ot'` (Kundenabo, nicht eigenes Berater-Abo) +- Logik: + 1. Berater ermitteln: `UserAbo->user_id` (= auth_user_id des Sponsors) + 2. Prüfen ob Berater Teilnehmer eines aktiven Incentives ist + 3. Prüfen ob Abo-Abschluss im Qualifikationszeitraum liegt + 4. `incentive_points_log` anlegen: type=abo, source=UserAbo, points_onetime=400 + 5. `incentive_participants.qualified_abos` inkrementieren + 6. Qualifikation + Ranking aktualisieren + +#### `trackSalesVolume(UserSalesVolume $user_sales_volume)` +- Wird aufgerufen in: `InvoiceRepository::createAndSalesVolume()` (nach Erzeugung des `UserSalesVolume`) +- Logik: + 1. **Pfad A:** `user_sales_volume.user_id` ist ein **getrackter Neupartner** (`IncentiveNewPartner`) → akkumulierte Punkte zum Sponsor-Teilnehmer (Log-Typ `partner`) + 2. **Pfad B:** Bestellung gehört zu einem **getrackten Kundenabo** (`IncentiveNewAbo` über `UserAbo` `is_for = 'ot'`) → akkumulierte Punkte (Log-Typ `abo`) + 3. Monat/Jahr im Berechnungs-Scope des jeweiligen Incentives (`isDateInScope`) + 4. `recalculateFromTrackingTables()` + Ranking + +#### `trackStorno(UserSalesVolume $original, UserSalesVolume $cancellation)` +- Wird aufgerufen in: `SalesPointsVolume::cancelSalesPointsVolume()` (nach Zeile 312) +- Logik: + 1. Prüfen ob original `user_sales_volume_id` in `incentive_points_log` existiert + 2. Storno-Eintrag anlegen: `is_storno=true`, negative Punkte, `storno_of_id` verknüpfen + 3. Gesamtpunkte + Qualifikation + Ranking aktualisieren + 4. Bei Storno eines Partners/Abos: `qualified_partners` bzw. `qualified_abos` dekrementieren + +--- + +### `IncentiveCalculationService` (app/Services/Incentive/IncentiveCalculationService.php) + +Batch-Berechnung als Sicherheitsnetz (Cron + manuelle Auslösung): + +#### `recalculate(Incentive $incentive, bool $force = false)` +- Iteriert über alle Teilnehmer +- Für jeden Teilnehmer: + 1. Neupartner ermitteln: Users mit `m_sponsor = participant.user_id`, registriert im Qualifikationszeitraum, mit bezahltem Starterpaket + 2. Kundenabos ermitteln: `UserAbo` mit `user_id = participant.user_id`, `is_for = 'ot'`, `status = 2`, erstellt im Qualifikationszeitraum + 3. Akkumulierte Punkte: `UserSalesVolume` pro Monat (Qualifikationsstart bis calculation_end) + 4. Stornos berücksichtigen (status=6 Einträge) + 5. `incentive_points_log` aktualisieren (bei --force: löschen + neu anlegen) + 6. `incentive_participants` Summen + Qualifikation aktualisieren + +#### `updateRanking(Incentive $incentive)` +- Alle Teilnehmer nach `total_points` DESC sortieren +- `rank` fortlaufend vergeben (1, 2, 3, ...) +- `is_qualified` prüfen: `qualified_partners >= min_direct_partners AND qualified_abos >= min_customer_abos` + +--- + +## 4. Integrationspunkte (Hooks in bestehenden Code) + +### Hook 1: Neupartner-Registrierung bezahlt +**Datei**: `app/Services/Payment.php` → `paymentStatusPaidAction()` +**Bedingung**: `payment_for == 1` (entspricht Wizard laut `ShoppingUser::getOrderPaymentFor()`). +**Intern:** `trackNewPartner` bricht ab, wenn keine Starterpaket-Position vorliegt (`qualifiesForIncentiveTrackedPartner()`). + +### Hook 2: Kundenabo aktiviert (Zahlung bestätigt) +**Datei**: `app/Services/Payment.php` → `paymentStatusPaidAction()` +**Position**: Nach Zeile 280 (nach `AboHelper::setAboActive`) +**Code**: +```php +// Incentive: Track activated customer abo +if ($shopping_order->is_abo) { + IncentiveTracker::trackAboActivated($shopping_order); +} +``` + +### Hook 3: Sales Volume erstellt +**Datei**: `app/Repositories/InvoiceRepository.php` → `createAndSalesVolume()` +**Position**: Nach Zeile 223 (nach Verknüpfung Sales Volume ↔ Invoice) +**Code**: +```php +// Incentive: Track sales volume points +IncentiveTracker::trackSalesVolume($this->user_sales_volume); +``` + +### Hook 4: Storno +**Datei**: `app/Services/BusinessPlan/SalesPointsVolume.php` → `cancelSalesPointsVolume()` +**Position**: Nach Zeile 312 (nach Recalculation) +**Code**: +```php +// Incentive: Track storno +IncentiveTracker::trackStorno($original_sales_volume, $cancellation_sales_volume); +``` + +--- + +## 5. Admin-Bereich + +### Controller: `app/Http/Controllers/Admin/IncentiveController.php` + +| Route | Methode | Beschreibung | +|----------------------------------------|---------------|---------------------------------------| +| GET `/admin/incentive` | index | Liste aller Incentives (DataTable) | +| GET `/admin/incentive/create` | create | Anlageformular (Spatie\Html) | +| POST `/admin/incentive` | store | Speichern | +| GET `/admin/incentive/{id}` | show | Detail + Teilnehmer-Ranking | +| GET `/admin/incentive/{id}/edit` | edit | Bearbeiten | +| PUT `/admin/incentive/{id}` | update | Aktualisieren | +| POST `/admin/incentive/{id}/recalculate` | recalculate | Neuberechnung auslösen | + +### Views +- `resources/views/admin/incentive/index.blade.php` – DataTable mit allen Incentives +- `resources/views/admin/incentive/create.blade.php` – Formular (Spatie\Html) +- `resources/views/admin/incentive/edit.blade.php` – Bearbeitungsformular +- `resources/views/admin/incentive/show.blade.php` – Detail: Konfiguration + Ranking-Tabelle aller Teilnehmer + +--- + +## 6. Berater-Frontend (User-Bereich) + +### Controller: `app/Http/Controllers/User/IncentiveController.php` + +| Route | Methode | Beschreibung | +|--------------------------------------|---------------|-----------------------------------------| +| GET `/incentive/{slug}` | show | Incentive-Seite mit Ranking | +| POST `/incentive/{slug}/participate` | participate | Teilnahme bestätigen (Terms akzeptieren)| +| GET `/incentive/{slug}/details` | details | Persönliche Berechnungsübersicht | + +### View: Incentive-Seite (`user/incentive/show.blade.php`) + +**Layout:** +1. **Header**: Statisches Incentive-Bild +2. **Beschreibung**: Erklärungstext aus DB +3. **Teilnahme-Box** (nur wenn noch nicht teilgenommen): + - Checkbox: "Ich akzeptiere die Teilnahmebedingungen" (Link zu Terms) + - Button: "Jetzt teilnehmen" +4. **Ranking-Tabelle** (nur Teilnehmer sichtbar): + +| Rang | Name | Punkte | Partner | Abos | Status | +|------|------------------|--------|---------|-------|------------------| +| 1 | **Max Muster** | **4800** | **5/4 ✓** | **8/6 ✓** | **🏆 Gewinner** | +| 2 | **Anna Beispiel** | **4200** | **4/4 ✓** | **7/6 ✓** | **🏆 Gewinner** | +| ... | ... | ... | ... | ... | ... | +| 31 | **Klaus Test** | **1200** | **4/4 ✓** | **6/6 ✓** | Qualifiziert | +| 32 | Peter Demo | 900 | 3/4 | 4/6 | Offen | + +**Darstellungslogik:** +- **Normal** (nicht fett): Mindestqualifikation NICHT erreicht +- **Fett**: Mindestqualifikation erreicht (≥4 Partner + ≥6 Abos) +- **Fett + farbig hinterlegt (Gold/Grün)**: Qualifiziert + Rang ≤ max_winners → aktueller Gewinner + +### View: Berechnungsübersicht (`user/incentive/details.blade.php`) + +**Sektion A: Neupartner-Punkte** + +| Neupartner | Einstieg | Einmalig | Apr | Mai | Jun | Jul | Aug | Gesamt | +|-----------------|-----------|----------|------|------|------|------|------|--------| +| Max Muster | April | 600 | 120 | 150 | 130 | 140 | 110 | 1250 | +| Anna Beispiel | Juni | 600 | – | – | 80 | 90 | 100 | 870 | +| **Zwischensumme** | | | | | | | | **2120** | + +**Sektion B: Kundenabo-Punkte** + +| Kundenabo | Abschluss | Einmalig | Apr | Mai | Jun | Jul | Aug | Gesamt | +|-----------------|-----------|----------|------|------|------|------|------|--------| +| Abo #1 (Müller) | April | 400 | 80 | 80 | 80 | 80 | 80 | 800 | +| Abo #2 (Schmidt)| Mai | 400 | – | 60 | 60 | 60 | 60 | 640 | +| **Zwischensumme** | | | | | | | | **1440** | + +**Gesamtpunkte: 3560** + +--- + +## 7. Artisan Command + +### `php artisan incentive:calculate {incentive_id?} [--force]` + +- Ohne `incentive_id`: Berechnet alle aktiven Incentives +- Mit `incentive_id`: Berechnet nur das angegebene Incentive +- `--force`: Löscht bestehende Logs und berechnet komplett neu aus Quelldaten +- **Cron**: Täglich um 05:00 (nach business:store-optimized um 03:00) + +--- + +## 8. Localization + +`resources/lang/{de,en,es}/incentive.php` + +Enthält alle UI-Strings für Admin- und Berater-Bereich. + +--- + +## 9. Tests (Pest) + +| Test | Typ | Beschreibung | +|-------------------------------|---------|--------------------------------------------------------| +| Neupartner-Tracking | Feature | Partner registriert + bezahlt → 600 Einmalpunkte | +| Abo nur bei Aktivierung | Feature | Abo angelegt aber nicht bezahlt → keine Punkte | +| Abo bei Aktivierung | Feature | Abo bezahlt (status=2) → 400 Einmalpunkte | +| Nur Kundenabos zählen | Feature | Eigenes Berater-Abo (is_for=me) → keine Punkte | +| Akkumulierte Punkte | Feature | Monatliche Sales Volume → korrekt pro Monat summiert | +| Storno | Feature | Storno → negative Punkte, Qualifikation angepasst | +| Qualifikation erreicht | Unit | 4 Partner + 6 Abos → is_qualified=true | +| Qualifikation nicht erreicht | Unit | 3 Partner + 6 Abos → is_qualified=false | +| Ranking-Sortierung | Unit | Teilnehmer korrekt nach Punkten sortiert | +| Top-N Gewinner | Unit | Nur qualifizierte in Top 30 als Gewinner markiert | +| Opt-in erforderlich | Feature | Nicht-Teilnehmer nicht im Ranking | +| Terms-Akzeptanz | Feature | Teilnahme ohne Checkbox → Fehler | +| Admin CRUD | Feature | Incentive anlegen/bearbeiten/anzeigen | +| Zeitraum-Validierung | Unit | Events außerhalb Qualifikationszeitraum → ignoriert | +| Batch-Neuberechnung | Feature | Artisan Command berechnet korrekt | + +--- + +## 10. Implementierungs-Reihenfolge + +### Phase 1: Fundament (Tag 1-2) -- ERLEDIGT 17.03.2026 +- [x] Migrations erstellen (3 Tabellen: incentives, incentive_participants, incentive_points_log) +- [x] Models erstellen (Incentive, IncentiveParticipant, IncentivePointsLog) +- [x] Migrations ausgeführt +- [x] Factories für Tests + +### Phase 2: Kern-Logik (Tag 3-5) -- ERLEDIGT 17.03.2026 +- [x] `IncentiveTracker` Service implementieren (`app/Services/Incentive/IncentiveTracker.php`) + - trackNewPartner: Hook nach Registration-Payment (payment_for=1) + - trackAboActivated: Hook nach AboHelper::setAboActive (is_for='ot') + - trackSalesVolume: Hook nach InvoiceRepository::createAndSalesVolume + - trackStorno: Hook nach SalesPointsVolume::cancelSalesPointsVolume + - updateRanking: Ranking-Aktualisierung +- [x] `IncentiveCalculationService` implementieren (`app/Services/Incentive/IncentiveCalculationService.php`) + - recalculate: Batch-Berechnung aller Teilnehmer via `recalculateFromSource()` + - recalculateParticipant: Delegiert an `IncentiveParticipant::recalculateFromSource()` +- [x] Hooks in bestehende Klassen eingebaut: + - `app/Services/Payment.php`: trackNewPartner + trackAboActivated + - `app/Repositories/InvoiceRepository.php`: trackSalesVolume + - `app/Services/BusinessPlan/SalesPointsVolume.php`: trackStorno +- [x] Artisan Command `incentive:calculate` (`app/Console/Commands/IncentiveCalculate.php`) +- [x] Cron-Eintrag in `app/Console/Kernel.php` (täglich 05:00) + +### Phase 3: Admin-Backend (Tag 6-7) -- ERLEDIGT 17.03.2026 +- [x] Admin Controller (`app/Http/Controllers/Admin/IncentiveController.php`) + - CRUD: index, create, store, show, edit, update + - DataTable für Listenansicht + - Neuberechnung (normal + force) +- [x] Admin Views: + - `resources/views/admin/incentive/index.blade.php` (DataTable) + - `resources/views/admin/incentive/create.blade.php` + - `resources/views/admin/incentive/edit.blade.php` + - `resources/views/admin/incentive/_form.blade.php` (Shared Formular) + - `resources/views/admin/incentive/show.blade.php` (Detail + Ranking) +- [x] Admin Routes in `routes/domains/crm.php` (admin_incentive_*) + +### Phase 4: Berater-Frontend (Tag 8-9) -- ERLEDIGT 17.03.2026 +- [x] User Controller (`app/Http/Controllers/User/IncentiveController.php`) + - show: Incentive-Seite mit Ranking + - participate: Teilnahme mit Terms-Akzeptanz + - details: Persönliche Berechnungsübersicht +- [x] Ranking-View mit Qualifikations-Hervorhebung (`resources/views/user/incentive/show.blade.php`) + - Normal = nicht qualifiziert, Fett = qualifiziert, Grün+Fett = Gewinner (Top N) + - Eigener Rang hervorgehoben (table-primary) +- [x] Berechnungsübersicht-View (`resources/views/user/incentive/details.blade.php`) + - Sektion A: Neupartner mit monatlicher Aufschlüsselung + - Sektion B: Kundenabos mit monatlicher Aufschlüsselung + - Zusammenfassung: Gesamtpunkte, Rang, Qualifikationsstatus +- [x] Teilnahme-Flow (Checkbox + Terms mit Collapse) +- [x] User Routes in `routes/domains/crm.php` (user_incentive_*) + +### Phase 5: Abschluss (Tag 10) -- ERLEDIGT 17.03.2026 +- [x] Localization (de/en/es) in `resources/lang/{de,en,es}/incentive.php` +- [x] `./vendor/bin/pint` Code formatiert +- [x] Architektur-Refactoring: Eigene Tracking-Tabellen statt Live-Abfragen auf Quelltabellen + - Neue Tabellen: `incentive_new_partners`, `incentive_new_abos` + - Neue Models: `IncentiveNewPartner`, `IncentiveNewAbo` + - `IncentiveParticipant::recalculateFromTrackingTables()`: Berechnet aus eigenen Tabellen + - `IncentiveParticipant::rebuildFromSourceTables()`: Force-Rebuild aus Quelltabellen + - `IncentiveTracker`: Insert in Tracking-Tabellen + Log + `recalculateFromTrackingTables()` + - `IncentiveCalculationService`: Normal=TrackingTables, Force=RebuildFromSource + - Details-View: Partner/Abos aus Tracking-Tabellen statt aus Log gruppiert +- [x] Tests geschrieben + ausgeführt: **27 Tests, 55 Assertions, alle bestanden** + - `tests/Unit/Incentive/IncentiveModelTest.php` (19 Tests) + - Zeitraum-Prüfungen (Qualifikation, Berechnung, Scope) + - Status-Erkennung (draft/active/closed) + - Qualifikations-Logik (bestanden/nicht bestanden, Grenzwerte) + - Winner-Logik (Rang-Grenzen, Nicht-Qualifiziert) + - PointsLog Helpers + - `tests/Unit/Incentive/IncentiveTrackerTest.php` (8 Tests) + - Ranking-Sortierung nach Punkten + - Log-Typ-Erkennung (partner/abo) + - Qualifikations-Änderungen bei Storno + - Edge Cases (max_winners=1, negative Punkte) + - Factories: `IncentiveFactory`, `IncentiveParticipantFactory`, `IncentivePointsLogFactory` + - Hinweis: Unit-Tests ohne DB (bestehende countries-Migration hat SQLite-Inkompatibilität) +- [ ] Manueller Test mit Testdaten + +--- + +## 11. Verfeinerungen nach Erstrelease (März 2026) + +### Ranglisten & Darstellung (User + Admin) + +- **`orderByIncentiveLeaderboard()`** (`IncentiveParticipant`): Sortierung: `is_qualified` absteigend → Rang mit Wert vor `NULL` → `rank` aufsteigend → `total_points` absteigend (qualifizierte zuerst; ohne Rang unten). +- **`orderByRankNullsLast()`**: nur Rang-Sortierung (NULL zuletzt), falls separat benötigt. +- **User-Rangliste:** `limit` = `max(1, incentive.max_winners)` — dynamisch statt fest 30. +- **`withRankingActivity()`:** In der User-Tabelle werden nur Teilnehmer mit mindestens einem qualifizierten Partner, einem Kunden-Abo oder `total_points > 0` gelistet. + +### Neupartner nur mit Starterpaket + +- **`ShoppingOrder::wherePaidRegistrationIncludesStarterKit()`** / **`qualifiesForIncentiveTrackedPartner()`**: Registrierungsbestellung (`payment_for = 1`) mit bezahlter Zahlung **und** mindestens einer Position, deren Produkt **`is_membership_only = false`**. +- Verwendung in: `IncentiveTracker::trackNewPartner`, `IncentiveParticipant::rebuildFromSourceTables` (Block Neupartner), `IncentivePointsLogRepairService::syncMissingTrackingPartners`. + +### Tests (Auswahl) + +- `tests/Feature/Incentive/IncentiveParticipantRankOrderingTest.php` — Sortierung, Limit, Aktivitätsfilter. +- `tests/Feature/Incentive/IncentivePartnerRegistrationStarterKitTest.php` — Tracking nur mit Starterpaket-Position. + +### Dokumentation + +- **`dev/Incentive-Modul/README.md`** — Modulübersicht für Entwickler/PM. +- **`dev/Incentive-Modul/tasks.md`** — Anforderungen und Status. + +--- + +## Datei-Übersicht (erstellt) + +``` +database/migrations/ +├── 2026_03_17_000001_create_incentives_table.php ✅ +├── 2026_03_17_000002_create_incentive_participants_table.php ✅ +├── 2026_03_17_000003_create_incentive_points_log_table.php ✅ +├── 2026_03_17_000004_create_incentive_new_partners_table.php ✅ +└── 2026_03_17_000005_create_incentive_new_abos_table.php ✅ + +database/factories/ +├── IncentiveFactory.php ✅ +├── IncentiveParticipantFactory.php ✅ +├── IncentivePointsLogFactory.php ✅ +├── IncentiveNewPartnerFactory.php ✅ +└── IncentiveNewAboFactory.php ✅ + +app/Models/ +├── Incentive.php ✅ +├── IncentiveParticipant.php ✅ +├── IncentivePointsLog.php ✅ +├── IncentiveNewPartner.php ✅ +└── IncentiveNewAbo.php ✅ + +app/Services/Incentive/ +├── IncentiveTracker.php ✅ +└── IncentiveCalculationService.php ✅ + +app/Http/Controllers/Admin/ +└── IncentiveController.php ✅ + +app/Http/Controllers/User/ +└── IncentiveController.php ✅ + +app/Console/Commands/ +└── IncentiveCalculate.php ✅ + +resources/views/admin/incentive/ +├── index.blade.php ✅ +├── create.blade.php ✅ +├── edit.blade.php ✅ +├── _form.blade.php ✅ +└── show.blade.php ✅ + +resources/views/user/incentive/ +├── show.blade.php ✅ +└── details.blade.php ✅ + +resources/lang/de/incentive.php ✅ +resources/lang/en/incentive.php ✅ +resources/lang/es/incentive.php ✅ + +tests/Pest.php ✅ +tests/Unit/Incentive/ +├── IncentiveModelTest.php ✅ (19 Tests) +└── IncentiveTrackerTest.php ✅ (8 Tests) + +tests/Feature/Incentive/ +├── AboRenewalSalesVolumeIncentiveTest.php ✅ +├── IncentiveParticipantRankOrderingTest.php ✅ +└── IncentivePartnerRegistrationStarterKitTest.php ✅ +``` + +--- + +## Bestehende Dateien mit Änderungen (Hooks) + +| Datei | Änderung | +|------------------------------------------------|---------------------------------------------| +| `app/Services/Payment.php` | 2 Hooks: trackNewPartner (`payment_for==1`), trackAboActivated | +| `app/Models/ShoppingOrder.php` | Scopes/Methoden: Starterpaket-Registrierung (`wherePaidRegistrationIncludesStarterKit`, `qualifiesForIncentiveTrackedPartner`) | +| `app/Repositories/InvoiceRepository.php` | 1 Hook: trackSalesVolume | +| `app/Services/BusinessPlan/SalesPointsVolume.php` | 1 Hook: trackStorno | +| `routes/domains/crm.php` | Admin + User Routes hinzufügen | +| `app/Console/Kernel.php` | Cron-Eintrag für incentive:calculate | + +--- + +## Architektur-Änderung: Eigene Tracking-Tabellen (17.03.2026) + +**Entscheidung**: Statt bei jedem Event die Quelltabellen (Users, UserAbos, UserSalesVolumes) abzufragen, werden **eigene Tracking-Tabellen** geführt. + +**Grund**: Performance-Optimierung + saubere Nachverfolgbarkeit, welche Partner/Abos einem Teilnehmer zugeordnet sind. + +### Neue Tabellen: +- `incentive_new_partners` (participant_id, user_id, registered_at) - Welche Neupartner gehören zu welchem Teilnehmer +- `incentive_new_abos` (participant_id, user_abo_id, activated_at) - Welche Kundenabos gehören zu welchem Teilnehmer + +### Berechnungslogik: + +**Normal (bei Events + Cron)** via `recalculateFromTrackingTables()`: +``` +qualified_partners = COUNT(incentive_new_partners) +qualified_abos = COUNT(incentive_new_abos) +total_points = SUM(points_onetime + points_accumulated) aus incentive_points_log +``` + +**Force-Rebuild** via `rebuildFromSourceTables()`: +1. Leert Tracking-Tabellen + Log +2. Scannt Quelltabellen (Users, UserAbos, UserSalesVolumes) +3. **Neupartner (Block A):** nur User mit bezahlter Registrierung, die **`wherePaidRegistrationIncludesStarterKit()`** erfüllt (Starterpaket-Regel wie im Live-Tracking) +4. Füllt `incentive_new_partners`, `incentive_new_abos`, `incentive_points_log` neu +5. Berechnet Totals via `recalculateFromTrackingTables()` + +### Datenfluss bei Events: +1. **Neupartner**: → INSERT `incentive_new_partners` + Log + Recalculate +2. **Abo aktiviert**: → INSERT `incentive_new_abos` + Log + Recalculate +3. **Sales Volume**: → INSERT Log (akkumulierte Punkte) + Recalculate +4. **Storno**: → INSERT negativer Log-Eintrag + Recalculate diff --git a/dev/Incentive-Modul/site.md b/dev/Incentive-Modul/site.md new file mode 100644 index 0000000..f3e6d44 --- /dev/null +++ b/dev/Incentive-Modul/site.md @@ -0,0 +1,97 @@ +> **Dokumentation zum Incentive-Modul (Technik):** siehe [`README.md`](README.md) und [`entwicklungsplan.md`](entwicklungsplan.md). +> **Aufgaben/Status:** [`tasks.md`](tasks.md). +> Die folgende Datei enthält **Content-Texte** (Berater-Oberfläche / Montenegro 2026), keine technische Spezifikation. + +--- + +Das ist eine sehr gute Entscheidung, bei dem Begriff zu bleiben! Für das Frontend im CRM brauchen wir jetzt einen Text, der professionell, motivierend und auf den ersten Blick verständlich ist. Schließlich sollen die Berater direkt Lust bekommen, loszulegen. + +Hier ist ein Struktur- und Textentwurf für die Inhaltsseite im System. + +--- + +### [Platzhalter: Großes, ansprechendes Titelbild von einem Luxushotel oder der Küste Montenegros einfügen] + +# (icon) Montenegro Incentive 2026 – Deine exklusive Auszeit an der Adria! + +**Pack deine Koffer, denn mivita belohnt deine Bestleistungen!** Vom **30. September bis zum 4. Oktober 2026** geht es für unsere erfolgreichsten Partner an die malerische Küste Montenegros. Erlebe unvergessliche Tage, tausche dich mit den Top-Leadern aus und feiere deinen Erfolg mit uns! + +Gehörst du zu den **besten 30 Partnern**? Dann bist du dabei! + +--- + +### (icon) Der Qualifikationszeitraum +Der Startschuss für die Qualifikation fällt am **1. April**. +* **Regulärer Zeitraum:** April bis Juli +* **Endspurt:** Der Zeitraum wird um 1 Monat verlängert (Punkte zählen bis einschließlich August ). + +--- + +### (icon) Dein Ticket in den Flieger: Die Mindestqualifikation +Um dir deinen Platz im Ranking freizuschalten und überhaupt für die Reise in Frage zu kommen, musst du im Qualifikationszeitraum folgende Basis-Ziele erreichen: +* **4 direkte neue Teampartner** (jeweils mit einem Starterpaket) +* **6 neu abgeschlossene Kundenabos** + +*(Hinweis: Im Live-Ranking unten wird dein Name erst dann **fettgedruckt** hervorgehoben, wenn du diese Mindestqualifikation erfolgreich geknackt hast!)* + +--- + +### (icon) So sammelst du deine Incentive-Punkte +Sobald der Startschuss fällt, zählt jeder Umsatz. Deine Punkte setzen sich aus Einmal-Boni und laufenden Umsätzen zusammen: + +**1. Punkte für neue Teampartner** +* **600 Punkte einmalig** für jeden direkt gesponserten Neupartner. +* **Zusatz-Boost:** Du erhältst zusätzlich *alle* Kundenumsatzpunkte deines Neupartners ab seinem Startmonat bis einschließlich August! + +**2. Punkte für Kundenabos** +* **400 Punkte einmalig** pro neu abgeschlossenem Kundenabo im Qualifikationszeitraum. +* **Zusatz-Boost:** Du erhältst zusätzlich die jeweiligen monatlichen Abopunkte ab dem Abschlussmonat bis einschließlich August! + +--- + +### (icon) Das Live-Ranking +Deinen aktuellen Punktestand und deine Platzierung im Vergleich zu den anderen Beratern findest du jederzeit in unserer separaten Rangliste. +Nur die besten 30 Berater, die auch die Mindestqualifikation erfüllen, sichern sich das Ticket nach Montenegro! + [Hier geht es direkt zur Live-Rangliste] (Link zur neuen Ranking-Route) + +### (icon) Deine Teilnahme am Montenegro Incentive 2026 +Bist du bereit für die Challenge? Um am Incentive teilzunehmen und im offiziellen Ranking gelistet zu werden, musst du dich einmalig anmelden. + +[ ] Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese. Ich möchte am Montenegro Incentive 2026 teilnehmen und stimme zu, dass mein Name und mein Punktestand im internen Ranking für andere Berater sichtbar sind. + +[ BUTTON: Jetzt verbindlich teilnehmen ] +--- + + + + +### (icon) Die Nutzungsbedingungen (Teilnahmebedingungen) + +Nutzungsbedingungen (Teilnahmebedingungen) + +Diese Bedingungen sichern euch rechtlich ab und fassen die Regeln aus deinem Briefing für die Berater transparent zusammen. + +**Nutzungs- und Teilnahmebedingungen: Montenegro Incentive 2026** + +**1. Gegenstand des Incentives** +Die mivita veranstaltet das „Montenegro Incentive 2026“. Die erfolgreichsten Berater qualifizieren sich für eine exklusive Reise nach Montenegro vom 30. September bis zum 4. Oktober 2026. + +**2. Qualifikationszeitraum** +Der reguläre Qualifikationszeitraum startet am 1. April und läuft bis Ende Juli. Für die finale Punkteberechnung (laufende Kunden- und Aboumsätze) wird der Zeitraum um einen weiteren Monat bis einschließlich August verlängert. + +**3. Teilnahmevoraussetzung (Mindestqualifikation)** +Um für die Incentive-Reise infrage zu kommen, muss im Qualifikationszeitraum zwingend folgende Mindestqualifikation erreicht werden: +* Sponsoring von mindestens **4 direkten Teampartnern** (jeweils mit einem Starterpaket). +* Abschluss von mindestens **6 neuen Kundenabos**. + +**4. Punkteberechnung** +Teilnehmer sammeln tagesaktuell Punkte, die im Ranking sichtbar sind. Die Punkte setzen sich wie folgt zusammen: +* **Neupartner:** Pro direkt gesponsertem Neupartner im Qualifikationszeitraum gibt es einmalig 600 Punkte. Zusätzlich werden alle Kundenumsatzpunkte dieses Neupartners ab seinem Startmonat bis einschließlich August akkumuliert. +* **Kundenabos:** Pro neu abgeschlossenem Kundenabo im Qualifikationszeitraum gibt es einmalig 400 Punkte. Zusätzlich werden die monatlichen Abopunkte ab Abschluss bis einschließlich August akkumuliert. + +**5. Gewinnerermittlung und Ranking** +Die gesammelten Punkte werden in einer Live-Rangliste dargestellt. Die Darstellung des Namens und der Punkte eines Teilnehmers erfolgt erst dann in **Fettdruck**, wenn die Mindestqualifikation (siehe Punkt 3) vollständig erfüllt ist. +Qualifiziert für die Reise sind die **Top 30 Teilnehmer** der Rangliste, die zum Stichtag die Mindestqualifikation erfüllt haben. + +**6. Datenschutz und Sichtbarkeit** +Mit der Teilnahme am Incentive stimmt der Berater zu, dass sein Name, seine erreichten Qualifikationen (Anzahl Teampartner/Abos) sowie der Gesamtpunktestand im internen mivita-Ranking für andere Systemnutzer sichtbar sind. diff --git a/dev/Incentive-Modul/tasks.md b/dev/Incentive-Modul/tasks.md new file mode 100644 index 0000000..c63602c --- /dev/null +++ b/dev/Incentive-Modul/tasks.md @@ -0,0 +1,49 @@ +# Incentive-Modul – Anforderungen & Aufgaben + +Auszug aus dem Kundenbriefing (`dev/2026-03-17/Incentive-montenegro.pdf`) und Nachverfolgung der Umsetzung. + +--- + +## Produktziele + +| # | Anforderung | Status | +|---|-------------|--------| +| P1 | Eigenes Punkte-/Ranking-System, **losgelöst** von anderen MLM-Berechnungen | Erledigt | +| P2 | **Wiederverwendbar**: mehrere Incentives (z. B. jährlich wechselnd) anlegbar | Erledigt | +| P3 | Parameter pro Incentive: Zeiträume, Punkte, Mindest-Partner/Abos, **Anzahl Gewinner** (`max_winners`) | Erledigt | +| P4 | UI: Inhaltsseite mit Bild, Erklärung, **Rangliste**; Qualifikation und Gewinner **visuell** hervorheben | Erledigt | +| P5 | **Detailübersicht** für Berater: Neupartner- und Abo-Berechnung, nach Monaten, nachvollziehbar | Erledigt | +| P6 | Neupartner zählen nur mit **Starterpaket** (nicht nur Mitgliedschaft) | Erledigt | +| P7 | Rangliste: Qualifizierte oben; sinnvolle Sortierung Rang/Null-Rang; User-Liste begrenzt auf `max_winners`; optionale Filter „ohne Aktivität“ | Erledigt | + +--- + +## Technische Arbeitspakete (Referenz) + +| Paket | Beschreibung | Status | +|-------|--------------|--------| +| T1 | Migrationen `incentives`, `incentive_participants`, `incentive_points_log`, Tracking-Tabellen | Erledigt | +| T2 | Models, Factories, `IncentiveTracker`, Hooks in `Payment`, `InvoiceRepository`, `SalesPointsVolume` | Erledigt | +| T3 | Admin CRUD + DataTable + Views | Erledigt | +| T4 | User-Routen: Show, Participate, Details, Teaser | Erledigt | +| T5 | Artisan `incentive:calculate`, Cron | Erledigt | +| T6 | Übersetzungen `de` / `en` / `es` | Erledigt | +| T7 | Tests (Pest): Model, Tracker, Feature (Ranking, Starterkit, Abo, …) | Laufend erweitert | +| T8 | `IncentivePointsLogRepairService`, Debug-Commands (optional) | Vorhanden | + +--- + +## Offen / manuell + +| Aufgabe | Notiz | +|---------|--------| +| **Manueller End-to-End-Test** | Mit realitätsnahen Testdaten (Zahlung, Registrierung, Abo, Shop) im Staging prüfen | +| **Content-Pflege** | `site.md` – Bilder, finale Texte, Links zur Live-Route je nach Deployment | + +--- + +## Verwandte Dateien + +- Technischer Fahrplan: **`entwicklungsplan.md`** +- Modul-Kurzüberblick: **`README.md`** +- Marketing-/Nutzertexte: **`site.md`** diff --git a/docker-compose.yml b/docker-compose.yml index 3fc42fc..b0025c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: laravel.test: container_name: mivita-care-dev-container build: - context: './docker/8.4' + context: './vendor/laravel/sail/runtimes/8.4' dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP:-20}' @@ -11,7 +11,6 @@ services: extra_hosts: - 'host.docker.internal:host-gateway' ports: - # - '${APP_PORT:-80}:80' - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' environment: WWWUSER: '${WWWUSER:-501}' @@ -20,26 +19,22 @@ services: XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' IGNITION_LOCAL_SITES_PATH: '${PWD}' - # Umgebungsvariablen für Datenbank, Mail etc. + # --- Anbindung an das Mutterschiff --- DB_CONNECTION: mysql - DB_HOST: mysql + DB_HOST: global-mysql DB_PORT: 3306 DB_DATABASE: mivita - DB_USERNAME: sail + DB_USERNAME: root # Wir nutzen den Root-User des Mutterschiffs DB_PASSWORD: password - MAIL_HOST: mailpit + MAIL_HOST: global-mailpit MAIL_PORT: 1025 - REDIS_HOST: redis + REDIS_HOST: global-redis REDIS_PORT: 6379 volumes: - '.:/var/www/html' networks: - sail - - proxy - depends_on: - - mysql - - redis - - mailpit + - proxy # WICHTIG für Traefik und Global Services labels: - "traefik.enable=true" # Hauptdomain @@ -47,15 +42,16 @@ services: - "traefik.http.routers.mivita.entrypoints=websecure" - "traefik.http.routers.mivita.tls=true" - "traefik.http.routers.mivita.service=mivita-service" - # Wildcard für alle Subdomains - WICHTIG: Gleicher Service! + # Wildcard für alle Subdomains - "traefik.http.routers.mivita-sub.rule=HostRegexp(`^.+\\.mivita\\.test$`)" - "traefik.http.routers.mivita-sub.entrypoints=websecure" - "traefik.http.routers.mivita-sub.tls=true" - "traefik.http.routers.mivita-sub.service=mivita-service" - "traefik.http.routers.mivita-sub.priority=10" - # Service Definition - NUR EINMAL! + # Service Definition - "traefik.http.services.mivita-service.loadbalancer.server.port=80" - "traefik.docker.network=proxy" + horizon: image: sail-8.4/app container_name: mivita-horizon-1 @@ -65,71 +61,10 @@ services: - '.:/var/www/html' networks: - sail - depends_on: - - mysql - - redis - mysql: - image: 'mysql/mysql-server:8.0' - ports: - - '${FORWARD_DB_PORT:-33061}:3306' - environment: - MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' - MYSQL_ROOT_HOST: '%' - MYSQL_DATABASE: '${DB_DATABASE}' - MYSQL_USER: '${DB_USERNAME}' - MYSQL_PASSWORD: '${DB_PASSWORD}' - MYSQL_ALLOW_EMPTY_PASSWORD: 1 - MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS}' - volumes: - - 'sail-mysql:/var/lib/mysql' - - './docker/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' - networks: - - sail - healthcheck: - test: - - CMD - - mysqladmin - - ping - - '-p${DB_PASSWORD}' - retries: 3 - timeout: 5s - redis: - image: 'redis:alpine' - ports: - - '${FORWARD_REDIS_PORT:-6380}:6379' - volumes: - - 'sail-redis:/data' - networks: - - sail - healthcheck: - test: - - CMD - - redis-cli - - ping - retries: 3 - timeout: 5s - mailpit: - image: 'axllent/mailpit:latest' - ports: - - '${FORWARD_MAILPIT_PORT:-1025}:1025' - - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' - networks: - - sail - - proxy - labels: - - "traefik.enable=true" - - "traefik.http.routers.mivita-mail.rule=Host(`mivita-mail.test`)" - - "traefik.http.routers.mivita-mail.entrypoints=websecure" - - "traefik.http.routers.mivita-mail.tls=true" - - "traefik.http.services.mivita-mail.loadbalancer.server.port=8025" - - "traefik.docker.network=proxy" + - proxy # WICHTIG: Damit Horizon auf global-redis und global-mysql zugreifen kann! + networks: sail: driver: bridge proxy: - external: true -volumes: - sail-mysql: - driver: local - sail-redis: - driver: local + external: true \ No newline at end of file diff --git a/docker/8.0/Dockerfile b/docker/8.0/Dockerfile deleted file mode 100644 index b7b34e2..0000000 --- a/docker/8.0/Dockerfile +++ /dev/null @@ -1,70 +0,0 @@ -FROM ubuntu:24.04 - -LABEL maintainer="Taylor Otwell" - -ARG WWWGROUP -ARG NODE_VERSION=22 -ARG POSTGRES_VERSION=17 - -WORKDIR /var/www/html - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC -ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" -ENV SUPERVISOR_PHP_USER="sail" - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom - -RUN apt-get update && apt-get upgrade -y \ - && mkdir -p /etc/apt/keyrings \ - && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ - && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /usr/share/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/usr/share/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y php8.0-cli php8.0-dev \ - php8.0-pgsql php8.0-sqlite3 php8.0-gd php8.0-imagick \ - php8.0-curl php8.0-memcached php8.0-mongodb \ - php8.0-imap php8.0-mysql php8.0-mbstring \ - php8.0-xml php8.0-zip php8.0-bcmath php8.0-soap \ - php8.0-intl php8.0-readline php8.0-pcov \ - php8.0-msgpack php8.0-igbinary php8.0-ldap \ - php8.0-redis php8.0-swoole php8.0-xdebug \ - && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm \ - && npm install -g bun \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarnkey.gpg >/dev/null \ - && echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ - && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/pgdg.gpg >/dev/null \ - && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y yarn \ - && apt-get install -y mysql-client \ - && apt-get install -y postgresql-client-$POSTGRES_VERSION \ - && apt-get -y autoremove \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -RUN update-alternatives --set php /usr/bin/php8.0 - -RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.0 - -RUN userdel -r ubuntu -RUN groupadd --force -g $WWWGROUP sail -RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail - -COPY start-container /usr/local/bin/start-container -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY php.ini /etc/php/8.0/cli/conf.d/99-sail.ini -RUN chmod +x /usr/local/bin/start-container - -EXPOSE 80/tcp - -ENTRYPOINT ["start-container"] diff --git a/docker/8.0/php.ini b/docker/8.0/php.ini deleted file mode 100644 index 0d8ce9e..0000000 --- a/docker/8.0/php.ini +++ /dev/null @@ -1,5 +0,0 @@ -[PHP] -post_max_size = 100M -upload_max_filesize = 100M -variables_order = EGPCS -pcov.directory = . diff --git a/docker/8.0/start-container b/docker/8.0/start-container deleted file mode 100644 index 40c55df..0000000 --- a/docker/8.0/start-container +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then - echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." - exit 1 -fi - -if [ ! -z "$WWWUSER" ]; then - usermod -u $WWWUSER sail -fi - -if [ ! -d /.composer ]; then - mkdir /.composer -fi - -chmod -R ugo+rw /.composer - -if [ $# -gt 0 ]; then - if [ "$SUPERVISOR_PHP_USER" = "root" ]; then - exec "$@" - else - exec gosu $WWWUSER "$@" - fi -else - exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf -fi diff --git a/docker/8.0/supervisord.conf b/docker/8.0/supervisord.conf deleted file mode 100644 index 656da8a..0000000 --- a/docker/8.0/supervisord.conf +++ /dev/null @@ -1,14 +0,0 @@ -[supervisord] -nodaemon=true -user=root -logfile=/var/log/supervisor/supervisord.log -pidfile=/var/run/supervisord.pid - -[program:php] -command=%(ENV_SUPERVISOR_PHP_COMMAND)s -user=%(ENV_SUPERVISOR_PHP_USER)s -environment=LARAVEL_SAIL="1" -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 diff --git a/docker/8.1/Dockerfile b/docker/8.1/Dockerfile deleted file mode 100644 index cc5c611..0000000 --- a/docker/8.1/Dockerfile +++ /dev/null @@ -1,69 +0,0 @@ -FROM ubuntu:24.04 - -LABEL maintainer="Taylor Otwell" - -ARG WWWGROUP -ARG NODE_VERSION=22 -ARG POSTGRES_VERSION=17 - -WORKDIR /var/www/html - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC -ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" -ENV SUPERVISOR_PHP_USER="sail" - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom - -RUN apt-get update && apt-get upgrade -y \ - && mkdir -p /etc/apt/keyrings \ - && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ - && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /usr/share/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/usr/share/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y php8.1-cli php8.1-dev \ - php8.1-pgsql php8.1-sqlite3 php8.1-gd php8.1-imagick \ - php8.1-curl php8.1-mongodb \ - php8.1-imap php8.1-mysql php8.1-mbstring \ - php8.1-xml php8.1-zip php8.1-bcmath php8.1-soap \ - php8.1-intl php8.1-readline \ - php8.1-ldap \ - php8.1-msgpack php8.1-igbinary php8.1-redis php8.1-swoole \ - php8.1-memcached php8.1-pcov php8.1-xdebug \ - && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm \ - && npm install -g bun \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /usr/share/keyrings/yarn.gpg >/dev/null \ - && echo "deb [signed-by=/usr/share/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ - && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/pgdg.gpg >/dev/null \ - && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y yarn \ - && apt-get install -y mysql-client \ - && apt-get install -y postgresql-client-$POSTGRES_VERSION \ - && apt-get -y autoremove \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.1 - -RUN userdel -r ubuntu -RUN groupadd --force -g $WWWGROUP sail -RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail - -COPY start-container /usr/local/bin/start-container -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY php.ini /etc/php/8.1/cli/conf.d/99-sail.ini -RUN chmod +x /usr/local/bin/start-container - -EXPOSE 80/tcp - -ENTRYPOINT ["start-container"] diff --git a/docker/8.1/php.ini b/docker/8.1/php.ini deleted file mode 100644 index 0d8ce9e..0000000 --- a/docker/8.1/php.ini +++ /dev/null @@ -1,5 +0,0 @@ -[PHP] -post_max_size = 100M -upload_max_filesize = 100M -variables_order = EGPCS -pcov.directory = . diff --git a/docker/8.1/start-container b/docker/8.1/start-container deleted file mode 100644 index 40c55df..0000000 --- a/docker/8.1/start-container +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then - echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." - exit 1 -fi - -if [ ! -z "$WWWUSER" ]; then - usermod -u $WWWUSER sail -fi - -if [ ! -d /.composer ]; then - mkdir /.composer -fi - -chmod -R ugo+rw /.composer - -if [ $# -gt 0 ]; then - if [ "$SUPERVISOR_PHP_USER" = "root" ]; then - exec "$@" - else - exec gosu $WWWUSER "$@" - fi -else - exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf -fi diff --git a/docker/8.1/supervisord.conf b/docker/8.1/supervisord.conf deleted file mode 100644 index 656da8a..0000000 --- a/docker/8.1/supervisord.conf +++ /dev/null @@ -1,14 +0,0 @@ -[supervisord] -nodaemon=true -user=root -logfile=/var/log/supervisor/supervisord.log -pidfile=/var/run/supervisord.pid - -[program:php] -command=%(ENV_SUPERVISOR_PHP_COMMAND)s -user=%(ENV_SUPERVISOR_PHP_USER)s -environment=LARAVEL_SAIL="1" -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 diff --git a/docker/8.2/Dockerfile b/docker/8.2/Dockerfile deleted file mode 100644 index 536dffe..0000000 --- a/docker/8.2/Dockerfile +++ /dev/null @@ -1,70 +0,0 @@ -FROM ubuntu:24.04 - -LABEL maintainer="Taylor Otwell" - -ARG WWWGROUP -ARG NODE_VERSION=22 -ARG POSTGRES_VERSION=17 - -WORKDIR /var/www/html - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC -ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" -ENV SUPERVISOR_PHP_USER="sail" - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom - -RUN apt-get update && apt-get upgrade -y \ - && mkdir -p /etc/apt/keyrings \ - && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ - && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y php8.2-cli php8.2-dev \ - php8.2-pgsql php8.2-sqlite3 php8.2-gd php8.2-imagick \ - php8.2-curl php8.2-mongodb \ - php8.2-imap php8.2-mysql php8.2-mbstring \ - php8.2-xml php8.2-zip php8.2-bcmath php8.2-soap \ - php8.2-intl php8.2-readline \ - php8.2-ldap \ - php8.2-msgpack php8.2-igbinary php8.2-redis php8.2-swoole \ - php8.2-memcached php8.2-pcov php8.2-xdebug \ - && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm \ - && npm install -g pnpm \ - && npm install -g bun \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ - && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y yarn \ - && apt-get install -y mysql-client \ - && apt-get install -y postgresql-client-$POSTGRES_VERSION \ - && apt-get -y autoremove \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.2 - -RUN userdel -r ubuntu -RUN groupadd --force -g $WWWGROUP sail -RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail - -COPY start-container /usr/local/bin/start-container -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY php.ini /etc/php/8.2/cli/conf.d/99-sail.ini -RUN chmod +x /usr/local/bin/start-container - -EXPOSE 80/tcp - -ENTRYPOINT ["start-container"] diff --git a/docker/8.2/php.ini b/docker/8.2/php.ini deleted file mode 100644 index 0d8ce9e..0000000 --- a/docker/8.2/php.ini +++ /dev/null @@ -1,5 +0,0 @@ -[PHP] -post_max_size = 100M -upload_max_filesize = 100M -variables_order = EGPCS -pcov.directory = . diff --git a/docker/8.2/start-container b/docker/8.2/start-container deleted file mode 100644 index 40c55df..0000000 --- a/docker/8.2/start-container +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then - echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." - exit 1 -fi - -if [ ! -z "$WWWUSER" ]; then - usermod -u $WWWUSER sail -fi - -if [ ! -d /.composer ]; then - mkdir /.composer -fi - -chmod -R ugo+rw /.composer - -if [ $# -gt 0 ]; then - if [ "$SUPERVISOR_PHP_USER" = "root" ]; then - exec "$@" - else - exec gosu $WWWUSER "$@" - fi -else - exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf -fi diff --git a/docker/8.2/supervisord.conf b/docker/8.2/supervisord.conf deleted file mode 100644 index 656da8a..0000000 --- a/docker/8.2/supervisord.conf +++ /dev/null @@ -1,14 +0,0 @@ -[supervisord] -nodaemon=true -user=root -logfile=/var/log/supervisor/supervisord.log -pidfile=/var/run/supervisord.pid - -[program:php] -command=%(ENV_SUPERVISOR_PHP_COMMAND)s -user=%(ENV_SUPERVISOR_PHP_USER)s -environment=LARAVEL_SAIL="1" -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 diff --git a/docker/8.3/Dockerfile b/docker/8.3/Dockerfile deleted file mode 100644 index ef5904b..0000000 --- a/docker/8.3/Dockerfile +++ /dev/null @@ -1,71 +0,0 @@ -FROM ubuntu:24.04 - -LABEL maintainer="Taylor Otwell" - -ARG WWWGROUP -ARG NODE_VERSION=22 -ARG MYSQL_CLIENT="mysql-client" -ARG POSTGRES_VERSION=17 - -WORKDIR /var/www/html - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC -ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" -ENV SUPERVISOR_PHP_USER="sail" - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom - -RUN apt-get update && apt-get upgrade -y \ - && mkdir -p /etc/apt/keyrings \ - && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ - && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y php8.3-cli php8.3-dev \ - php8.3-pgsql php8.3-sqlite3 php8.3-gd \ - php8.3-curl php8.3-mongodb \ - php8.3-imap php8.3-mysql php8.3-mbstring \ - php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ - php8.3-intl php8.3-readline \ - php8.3-ldap \ - php8.3-msgpack php8.3-igbinary php8.3-redis \ - php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug php8.3-swoole \ - && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm \ - && npm install -g pnpm \ - && npm install -g bun \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ - && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y yarn \ - && apt-get install -y $MYSQL_CLIENT \ - && apt-get install -y postgresql-client-$POSTGRES_VERSION \ - && apt-get -y autoremove \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 - -RUN userdel -r ubuntu -RUN groupadd --force -g $WWWGROUP sail -RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail - -COPY start-container /usr/local/bin/start-container -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini -RUN chmod +x /usr/local/bin/start-container - -EXPOSE 80/tcp - -ENTRYPOINT ["start-container"] diff --git a/docker/8.3/php.ini b/docker/8.3/php.ini deleted file mode 100644 index 0d8ce9e..0000000 --- a/docker/8.3/php.ini +++ /dev/null @@ -1,5 +0,0 @@ -[PHP] -post_max_size = 100M -upload_max_filesize = 100M -variables_order = EGPCS -pcov.directory = . diff --git a/docker/8.3/start-container b/docker/8.3/start-container deleted file mode 100644 index 40c55df..0000000 --- a/docker/8.3/start-container +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then - echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." - exit 1 -fi - -if [ ! -z "$WWWUSER" ]; then - usermod -u $WWWUSER sail -fi - -if [ ! -d /.composer ]; then - mkdir /.composer -fi - -chmod -R ugo+rw /.composer - -if [ $# -gt 0 ]; then - if [ "$SUPERVISOR_PHP_USER" = "root" ]; then - exec "$@" - else - exec gosu $WWWUSER "$@" - fi -else - exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf -fi diff --git a/docker/8.3/supervisord.conf b/docker/8.3/supervisord.conf deleted file mode 100644 index 656da8a..0000000 --- a/docker/8.3/supervisord.conf +++ /dev/null @@ -1,14 +0,0 @@ -[supervisord] -nodaemon=true -user=root -logfile=/var/log/supervisor/supervisord.log -pidfile=/var/run/supervisord.pid - -[program:php] -command=%(ENV_SUPERVISOR_PHP_COMMAND)s -user=%(ENV_SUPERVISOR_PHP_USER)s -environment=LARAVEL_SAIL="1" -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 diff --git a/docker/8.4/Dockerfile b/docker/8.4/Dockerfile deleted file mode 100644 index 3797620..0000000 --- a/docker/8.4/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -FROM ubuntu:24.04 - -LABEL maintainer="Taylor Otwell" - -ARG WWWGROUP -ARG WWWUSER -ARG NODE_VERSION=22 -ARG MYSQL_CLIENT="mysql-client" -ARG POSTGRES_VERSION=17 - -WORKDIR /var/www/html - -ENV DEBIAN_FRONTEND=noninteractive -ENV TZ=UTC -ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" -ENV SUPERVISOR_PHP_USER="sail" - -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \ - echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom - -RUN apt-get update && apt-get upgrade -y \ - && mkdir -p /etc/apt/keyrings \ - && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \ - && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ - && apt-get update \ - && apt-get install -y php8.4-cli php8.4-dev \ - php8.4-pgsql php8.4-sqlite3 php8.4-gd \ - php8.4-curl php8.4-mongodb \ - php8.4-imap php8.4-mysql php8.4-mbstring \ - php8.4-xml php8.4-zip php8.4-bcmath php8.4-soap \ - php8.4-intl php8.4-readline \ - php8.4-ldap \ - php8.4-msgpack php8.4-igbinary php8.4-redis php8.4-swoole \ - php8.4-memcached php8.4-pcov php8.4-imagick php8.4-xdebug \ - && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm \ - && npm install -g pnpm \ - && npm install -g bun \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ - && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ - && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y yarn \ - && apt-get install -y $MYSQL_CLIENT \ - && apt-get install -y postgresql-client-$POSTGRES_VERSION \ - && apt-get -y autoremove \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.4 - -RUN userdel -r ubuntu -RUN groupadd --force -g $WWWGROUP sail -RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u $WWWUSER sail - -COPY start-container /usr/local/bin/start-container -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf -COPY php.ini /etc/php/8.4/cli/conf.d/99-sail.ini -RUN chmod +x /usr/local/bin/start-container - -EXPOSE 80/tcp - -ENTRYPOINT ["start-container"] diff --git a/docker/8.4/php.ini b/docker/8.4/php.ini deleted file mode 100644 index 0d8ce9e..0000000 --- a/docker/8.4/php.ini +++ /dev/null @@ -1,5 +0,0 @@ -[PHP] -post_max_size = 100M -upload_max_filesize = 100M -variables_order = EGPCS -pcov.directory = . diff --git a/docker/8.4/start-container b/docker/8.4/start-container deleted file mode 100644 index 40c55df..0000000 --- a/docker/8.4/start-container +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then - echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'." - exit 1 -fi - -if [ ! -z "$WWWUSER" ]; then - usermod -u $WWWUSER sail -fi - -if [ ! -d /.composer ]; then - mkdir /.composer -fi - -chmod -R ugo+rw /.composer - -if [ $# -gt 0 ]; then - if [ "$SUPERVISOR_PHP_USER" = "root" ]; then - exec "$@" - else - exec gosu $WWWUSER "$@" - fi -else - exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf -fi diff --git a/docker/8.4/supervisord.conf b/docker/8.4/supervisord.conf deleted file mode 100644 index 656da8a..0000000 --- a/docker/8.4/supervisord.conf +++ /dev/null @@ -1,14 +0,0 @@ -[supervisord] -nodaemon=true -user=root -logfile=/var/log/supervisor/supervisord.log -pidfile=/var/run/supervisord.pid - -[program:php] -command=%(ENV_SUPERVISOR_PHP_COMMAND)s -user=%(ENV_SUPERVISOR_PHP_USER)s -environment=LARAVEL_SAIL="1" -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 diff --git a/docker/mariadb/create-testing-database.sh b/docker/mariadb/create-testing-database.sh deleted file mode 100644 index d3b19d9..0000000 --- a/docker/mariadb/create-testing-database.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -/usr/bin/mariadb --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL - CREATE DATABASE IF NOT EXISTS testing; - GRANT ALL PRIVILEGES ON \`testing%\`.* TO '$MYSQL_USER'@'%'; -EOSQL diff --git a/docker/mysql/create-testing-database.sh b/docker/mysql/create-testing-database.sh deleted file mode 100644 index aeb1826..0000000 --- a/docker/mysql/create-testing-database.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -mysql --user=root --password="$MYSQL_ROOT_PASSWORD" <<-EOSQL - CREATE DATABASE IF NOT EXISTS testing; - GRANT ALL PRIVILEGES ON \`testing%\`.* TO '$MYSQL_USER'@'%'; -EOSQL diff --git a/docker/pgsql/create-testing-database.sql b/docker/pgsql/create-testing-database.sql deleted file mode 100644 index d84dc07..0000000 --- a/docker/pgsql/create-testing-database.sql +++ /dev/null @@ -1,2 +0,0 @@ -SELECT 'CREATE DATABASE testing' -WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'testing')\gexec diff --git a/public/css/application.css b/public/css/application.css index 2fda06c..20ee4e5 100644 --- a/public/css/application.css +++ b/public/css/application.css @@ -99,6 +99,10 @@ a[aria-expanded="true"] > .fa-caret-expand:before { display: none; } +.lead { + font-weight: 400; +} + .badge-outline-warning-dark { background-color: transparent; -webkit-box-shadow: 0 0 0 1px #ffd950 inset; diff --git a/public/img/incentive/nikki-beach.jpg b/public/img/incentive/nikki-beach.jpg new file mode 100644 index 0000000..79426e7 Binary files /dev/null and b/public/img/incentive/nikki-beach.jpg differ diff --git a/public/img/incentive/nikki-beach1.jpg b/public/img/incentive/nikki-beach1.jpg new file mode 100644 index 0000000..7ae24ca Binary files /dev/null and b/public/img/incentive/nikki-beach1.jpg differ diff --git a/public/img/incentive/nikki-beach2.jpg b/public/img/incentive/nikki-beach2.jpg new file mode 100644 index 0000000..4f2e9c2 Binary files /dev/null and b/public/img/incentive/nikki-beach2.jpg differ diff --git a/public/img/incentive/nikki-beach3.jpg b/public/img/incentive/nikki-beach3.jpg new file mode 100644 index 0000000..858b0ab Binary files /dev/null and b/public/img/incentive/nikki-beach3.jpg differ diff --git a/public/img/incentive/nikki-beach4.jpg b/public/img/incentive/nikki-beach4.jpg new file mode 100644 index 0000000..902b329 Binary files /dev/null and b/public/img/incentive/nikki-beach4.jpg differ diff --git a/public/img/incentive/nikki-beach5.jpg b/public/img/incentive/nikki-beach5.jpg new file mode 100644 index 0000000..27f0ac7 Binary files /dev/null and b/public/img/incentive/nikki-beach5.jpg differ diff --git a/public/img/incentive/nikki-beach6.jpg b/public/img/incentive/nikki-beach6.jpg new file mode 100644 index 0000000..da14012 Binary files /dev/null and b/public/img/incentive/nikki-beach6.jpg differ diff --git a/public/img/incentive/nikki-beach7.jpg b/public/img/incentive/nikki-beach7.jpg new file mode 100644 index 0000000..e1743c5 Binary files /dev/null and b/public/img/incentive/nikki-beach7.jpg differ diff --git a/public/js/iq-shopping-cart.js b/public/js/iq-shopping-cart.js index 5cc6244..72310d6 100755 --- a/public/js/iq-shopping-cart.js +++ b/public/js/iq-shopping-cart.js @@ -121,21 +121,80 @@ var IqShoppingCart = { _self.performRequest({product_id: product_id, qty: qty, action: 'updateCart'}) .done(_self.refreshItemsAndView); }, + /** + * Liefertag-Info (wenn keine Warnung) bzw. Warnung (Partner-Center Abo, < 20 Tage): nach jedem AJAX-Warenkorb-Update, + * da eingebettete Scripts in .html() nicht ausgeführt werden. + */ + updateAboIntervalWarning: function () { + var warningTemplate = window.aboIntervalWarningTemplate; + var infoTemplate = window.aboIntervalInfoTemplate; + if (!warningTemplate && !infoTemplate) { + return; + } + var select = document.getElementById('abo_interval_select'); + if (!select) { + return; + } + var warning = document.getElementById('abo_interval_warning'); + var info = document.getElementById('abo_interval_info'); + var option = select.options[select.selectedIndex]; + if (!option) { + return; + } + var days = parseInt(option.getAttribute('data-days'), 10); + var date = option.getAttribute('data-date') || ''; + if (isNaN(days)) { + return; + } + var showWarning = days < 20; + if (warning && warningTemplate) { + if (showWarning) { + warning.innerHTML = + ' ' + + warningTemplate.replace('__DAYS__', days).replace('__DATE__', date); + warning.classList.remove('d-none'); + } else { + warning.classList.add('d-none'); + } + } + if (info && infoTemplate) { + if (showWarning) { + info.innerHTML = ''; + info.classList.add('d-none'); + } else { + info.innerHTML = + ' ' + + infoTemplate.replace('__DAYS__', days).replace('__DATE__', date); + info.classList.remove('d-none'); + } + } + }, + refreshItemsAndView: function (data){ var _self = IqShoppingCart; + if (data && data.response === false && data.message) { + alert(data.message); + return; + } $(_self.card_holder).html(data.html_card); $(_self.comp_holder).html(data.html_comp); _self.showInit(); + _self.updateAboIntervalWarning(); }, refreshDatabaseAndView: function (data) { var _self = IqShoppingCart; + if (data && data.response === false && data.message) { + alert(data.message); + return; + } $(_self.card_holder).html(data.html_card); $(_self.comp_holder).html(data.html_comp); var input = $(_self.table).find('input[name="product_qty_'+data.data.product_id+'"]'); input.val(data.data.qty); _self.showInit(); + _self.updateAboIntervalWarning(); }, locationReload : function(){ //location.reload(); @@ -147,6 +206,7 @@ var IqShoppingCart = { $(_self.comp_holder).html(data.html_comp); _self.showInit(); + _self.updateAboIntervalWarning(); _self.oTable.draw(); }, checkNumber : function(number){ @@ -196,4 +256,11 @@ var IqShoppingCart = { console.log("Sorry, there was a problem!"); }); } -}; \ No newline at end of file +}; + +$(function () { + $(document).on('change', '#abo_interval_select', function () { + IqShoppingCart.updateAboIntervalWarning(); + }); + IqShoppingCart.updateAboIntervalWarning(); +}); \ No newline at end of file diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 7f3e75c..19628f1 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -16,6 +16,7 @@ return [ 'abo_order_info_check_2' => 'Die erste Lieferung und Abrechnung erfolgt am Tag der Abo-Einrichtung. Danach erfolgt der Versand automatisch am gewählten Liefertag des Folgemonats.', 'abo_order_info_check_3' => 'Als Zahlungsmethoden stehen PayPal und Kreditkarte zur Verfügung. Das Abo hat eine Mindestlaufzeit von :abo-min-duration Monaten. Danach kann es jederzeit pausiert, geändert oder gekündigt werden.', 'abo_order_info_checkbox' => 'Ja, ich habe die Abo-Bedingungen verstanden!', + 'abo_order_info_checkbox_required' => 'Bitte bestätige die Abo-Bedingungen, um fortzufahren.', 'abo_infos' => 'Abo Infos', 'abo_delivery_infos' => 'Abo Lieferinfos', 'abo_start_date' => 'Beginn des Abos', @@ -33,6 +34,14 @@ return [ 'abo_copy_abo_interval' => 'Die Anpassung des Abonnement-Liefertags wirkt sich auf den kommenden Ausführungstermin aus, wenn das Abonnement aktiv ist.', 'error_abo_interval' => 'Das Abo Interval nicht korrekt', 'error_abo_interval_in_the_past' => 'Das Abo wurde diesen Monat noch nicht ausgeführt. Eine Änderung auf einen vergangenen Tag würde den aktuellen Monat überspringen.', + 'warning_next_date_soon' => 'Hinweis: Die nächste Abo-Ausführung ist bereits in :days Tagen (:date).', + 'warning_next_date_soon_select' => 'Hinweis: Die nächste Abo-Ausführung ist bereits in :placeholder_days Tagen (:placeholder_date).', + 'warning_next_date_info' => 'Die nächste Abo-Ausführung ist in :days Tagen am :date.', + 'info_next_execution_select' => 'Nächste Ausführung: in :placeholder_days Tagen am :placeholder_date.', + 'error_change_locked' => 'Änderungen sind nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Änderungen müssen mindestens 10 Tage vorher erfolgen.', + 'error_abo_interval_too_soon' => 'Der gewählte Liefertag liegt nur :days Tage entfernt. Bitte wähle einen Liefertag, der mindestens 10 Tage in der Zukunft liegt.', + 'error_cancel_locked' => 'Eine Kündigung ist nicht mehr möglich. Die nächste Ausführung ist in :days Tagen. Kündigungen müssen mindestens 3 Tage vorher erfolgen.', + 'error_pause_locked' => 'Das Abo kann nicht mehr pausiert werden. Die nächste Ausführung ist in :days Tagen. Pausieren muss mindestens 3 Tage vorher erfolgen.', 'error_next_date' => 'Das Datum für die nächste Ausführung nicht korrekt', 'checkout_mail_abo_hl' => 'Dein Abo / regelmäßige Lieferung.', 'checkout_mail_abo_start' => 'Dein Abo wurde erfolgreich mit folgenden Einstellungen angelegt:', @@ -102,6 +111,12 @@ return [ 'cancel_abo' => 'Abo kündigen', 'confirm_cancel' => 'Möchten Sie das Abo wirklich kündigen?', 'team_subscriptions' => 'Team Abos', + 'team_customer_abos' => 'Team Kunden-Abos', + 'chart_monthly_abos' => 'Abos pro Monat', + 'chart_active_abos' => 'Aktive Abos', + 'chart_abos_label' => 'Abos', + 'abo_count' => 'Anzahl Abos', + 'customer_privacy_info' => 'Aus Datenschutzgründen werden keine persönlichen Kundendaten angezeigt.', 'every_month_on' => 'monatlich am :day.', 'back' => 'zurück', ]; diff --git a/resources/lang/de/home.php b/resources/lang/de/home.php index 465a298..2324519 100644 --- a/resources/lang/de/home.php +++ b/resources/lang/de/home.php @@ -1,61 +1,61 @@ '', - 'MIVITA_Consultancy_agreement' => 'MIVITA_Beratervertrag', - 'active_role' => 'Aktive Rolle', - 'activities' => 'Aktivitäten', - 'adjust_data' => 'Daten anpassen', - 'adviser_membership_active' => 'Berater-Mitgliedschaft aktiv', - 'adviser_onlineshop_active' => 'Berater-Online-Shop aktiv', - 'adviser_onlineshop_inactive' => 'Berater-Shop inaktiv', - 'advisor_account_inactive' => 'Berater-Account inaktiv', - 'at' => 'am', - 'change_your_email_address' => 'Ändere Deine E-Mail Adresse.', - 'change_your_personal_data' => 'Ändere Deine persönlichen Daten.', - 'change_your_personal_password' => 'Ändere Dein persönliches Passwort.', - 'create_your_personal_password' => - array( - '' => 'Erstelle Dein persönlichen Passwort.', - ), - 'current_points_for' => 'Aktuelle Punkte für', - 'data' => 'Daten', - 'data_complete_unlocked' => 'Daten vollständig, freigeschaltet', - 'declaration_of_consent' => 'Einverständniserklärung', - 'email_verified' => 'E-Mail verifizier', - 'expired_on' => 'abgelaufen am', - 'log_out_and_see_you_soon' => 'Abmelden und bis bald.', - 'login' => 'Anmeldung', - 'manage_membership' => 'Mitgliedschaft verwalten', - 'manage_membership_now_here' => 'Mitgliedschaft jetzt hier verwalten', - 'membership' => 'Mitgliedschaft', - 'news_updates' => 'Neuigkeiten & Updates', - 'news_archive' => 'News-Archiv', - 'news_archive_title' => 'Alle Neuigkeiten & Updates', - 'news_archive_current' => 'Aktuelle News', - 'news_archive_older' => 'Ältere Meldungen', - 'news_archive_empty' => 'Keine älteren Meldungen vorhanden.', - 'news_archive_link' => 'Alle News ansehen', - 'news_back_to_dashboard' => 'Zurück zum Dashboard', - 'open_since' => 'Eröffnet seit', - 'open_your_shop' => 'Eröffne Deinen eigenen mivita-Shop', - 'read_less' => 'Weniger anzeigen', - 'read_more' => 'Mehr lesen', - 'privacy_policy_approved' => 'Datenschutzerklärung zugestimmt', - 'security' => 'Sicherheit', - 'settings_your_shop' => 'Deine Shop-Einstellungen', - 'shop_not_booked' => 'Shop nicht gebucht', - 'today_is' => 'Heute ist', - 'until' => 'bis zum', - 'welcome_back' => 'Willkommen zurück', - 'your_shop' => 'Dein Shop', - 'monthly_statistics' => 'Monatsstatistik', - 'customer_turnover_points' => 'Kunden-Umsatz Punkte', - 'team_turnover_points' => 'Team-Umsatz Punkte', - 'direct_new_partners' => 'Direkte Neupartner', - 'team_new_partners' => 'Neupartner im Team', - 'customer_subscriptions' => 'Kundenabos', - 'team_subscriptions' => 'Teamabos', - 'own' => 'Eigene', - 'live_calculation_hint' => 'Live-Berechnung (noch nicht abgeschlossen)', -); +return [ + '' => '', + 'MIVITA_Consultancy_agreement' => 'MIVITA_Beratervertrag', + 'active_role' => 'Aktive Rolle', + 'activities' => 'Aktivitäten', + 'adjust_data' => 'Daten anpassen', + 'adviser_membership_active' => 'Berater-Mitgliedschaft aktiv', + 'adviser_onlineshop_active' => 'Berater-Online-Shop aktiv', + 'adviser_onlineshop_inactive' => 'Berater-Shop inaktiv', + 'advisor_account_inactive' => 'Berater-Account inaktiv', + 'at' => 'am', + 'change_your_email_address' => 'Ändere Deine E-Mail Adresse.', + 'change_your_personal_data' => 'Ändere Deine persönlichen Daten.', + 'change_your_personal_password' => 'Ändere Dein persönliches Passwort.', + 'create_your_personal_password' => [ + '' => 'Erstelle Dein persönlichen Passwort.', + ], + 'current_points_for' => 'Aktuelle Punkte für', + 'data' => 'Daten', + 'data_complete_unlocked' => 'Daten vollständig, freigeschaltet', + 'declaration_of_consent' => 'Einverständniserklärung', + 'email_verified' => 'E-Mail verifizier', + 'expired_on' => 'abgelaufen am', + 'log_out_and_see_you_soon' => 'Abmelden und bis bald.', + 'login' => 'Anmeldung', + 'manage_membership' => 'Mitgliedschaft verwalten', + 'manage_membership_now_here' => 'Mitgliedschaft jetzt hier verwalten', + 'membership' => 'Mitgliedschaft', + 'news_updates' => 'Neuigkeiten & Updates', + 'news_archive' => 'News-Archiv', + 'news_archive_title' => 'Alle Neuigkeiten & Updates', + 'news_archive_current' => 'Aktuelle News', + 'news_archive_older' => 'Ältere Meldungen', + 'news_archive_empty' => 'Keine älteren Meldungen vorhanden.', + 'news_archive_link' => 'Alle News ansehen', + 'news_back_to_dashboard' => 'Zurück zum Dashboard', + 'open_since' => 'Eröffnet seit', + 'open_your_shop' => 'Eröffne Deinen eigenen mivita-Shop', + 'read_less' => 'Weniger anzeigen', + 'read_more' => 'Mehr lesen', + 'privacy_policy_approved' => 'Datenschutzerklärung zugestimmt', + 'security' => 'Sicherheit', + 'settings_your_shop' => 'Deine Shop-Einstellungen', + 'shop_not_booked' => 'Shop nicht gebucht', + 'today_is' => 'Heute ist', + 'until' => 'bis zum', + 'welcome_back' => 'Willkommen zurück', + 'your_shop' => 'Dein Shop', + 'monthly_statistics' => 'Monatsstatistik', + 'customer_turnover_points' => 'Kunden-Umsatz Punkte', + 'team_turnover_points' => 'Team-Umsatz Punkte', + 'direct_new_partners' => 'Direkte Neupartner', + 'team_new_partners' => 'Neupartner im Team', + 'customer_subscriptions' => 'Kundenabos', + 'team_subscriptions' => 'Teamabos', + 'own' => 'Eigene', + 'live_calculation_hint' => 'Live-Berechnung (noch nicht abgeschlossen)', + 'live_calculation_hint_text' => 'Wird erst zum Monatsende berechnet.', +]; diff --git a/resources/lang/de/incentive.php b/resources/lang/de/incentive.php new file mode 100644 index 0000000..ffa7676 --- /dev/null +++ b/resources/lang/de/incentive.php @@ -0,0 +1,172 @@ + 'Incentives', + 'incentive' => 'Incentive', + 'name' => 'Name', + 'status' => 'Status', + 'period' => 'Zeitraum', + 'actions' => 'Aktionen', + 'participants' => 'Teilnehmer', + 'save' => 'Speichern', + 'cancel' => 'Abbrechen', + 'yes' => 'Ja', + 'no' => 'Nein', + 'you' => 'Du', + + // Status + 'status_draft' => 'Entwurf', + 'status_active' => 'Aktiv', + 'status_closed' => 'Beendet', + + // CRUD + 'create' => 'Neues Incentive anlegen', + 'edit' => 'Bearbeiten', + 'created' => 'Incentive wurde erfolgreich angelegt.', + 'updated' => 'Incentive wurde erfolgreich aktualisiert.', + + // Konfiguration + 'configuration' => 'Konfiguration', + 'qualification_start' => 'Qualifikationsbeginn', + 'qualification_end' => 'Qualifikationsende', + 'calculation_end' => 'Berechnungsende', + 'points_partner_onetime' => 'Einmalpunkte pro Partner', + 'points_abo_onetime' => 'Einmalpunkte pro Abo', + 'min_direct_partners' => 'Mind. direkte Partner', + 'min_customer_abos' => 'Mind. Kundenabos', + 'max_winners' => 'Max. Gewinner', + 'image' => 'Bild', + 'image_help' => 'Dateiname des Bildes im Ordner public/img/incentive/ (z.B. montenegro-2026.jpg)', + 'description' => 'Beschreibung / Werbetext', + 'description_help' => 'Motivierender Einleitungstext der auf der Teaser-Seite angezeigt wird.', + 'terms' => 'Teilnahmebedingungen', + 'terms_help' => 'Vollständiger Text der Teilnahmebedingungen. Wird als aufklappbarer Bereich auf der Seite angezeigt.', + 'name_help' => 'Interner Name des Incentives (wird auch als Seitenüberschrift angezeigt).', + 'subtitle' => 'Untertitel', + 'subtitle_placeholder' => 'z.B. Deine exklusive Auszeit an der Adria!', + 'subtitle_help' => 'Kurzer Werbespruch, der im Hero-Bereich unter dem Titel angezeigt wird.', + 'content_lang_de' => 'Deutsch', + 'default_language' => 'Standard', + 'lang_fallback_hint' => 'Leer lassen = Deutsch wird als Fallback verwendet.', + + // Ranking + 'ranking' => 'Rangliste', + 'rank' => 'Rang', + 'consultant' => 'Berater', + 'total_points' => 'Gesamtpunkte', + 'partners' => 'Partner', + 'abos' => 'Abos', + 'qualified' => 'Qualifiziert', + 'open' => 'Offen', + 'winner' => 'Gewinner', + 'no_participants' => 'Noch keine Teilnehmer.', + 'no_participants_with_points' => 'Noch keine Teilnehmer mit Punkten.', + 'anonymous_consultant' => 'Anonymer Berater', + 'ranking_anonymous_hint' => 'Namen erscheinen erst, wenn die Teilnahme am Incentive bestätigt wurde.', + 'ranking_extended_hint' => 'Die Liste zeigt die Plätze 1–30. Die besten :n qualifizierten Berater (hervorgehoben) gewinnen; die Plätze danach zeigen, wer noch nachlegen kann.', + 'calculation_details' => 'Berechnungsdetails', + 'close' => 'Schliessen', + + // Neuberechnung + 'recalculate' => 'Neuberechnung', + 'recalculate_confirm' => 'Soll die Neuberechnung gestartet werden?', + 'force_recalculate' => 'Komplett neu berechnen', + 'force_recalculate_confirm' => 'ACHTUNG: Alle bestehenden Logs werden geloescht und komplett neu berechnet. Fortfahren?', + 'recalculated' => 'Neuberechnung abgeschlossen. :participants Teilnehmer verarbeitet, :errors Fehler.', + + // Admin-Rangliste + 'admin_terms_accepted' => 'Teilnahme (Bedingungen)', + 'admin_terms_pending' => 'Ausstehend', + 'admin_terms_accepted_at_tooltip' => 'Zeitpunkt der Bestätigung', + + // Teilnahme (User) + 'participate_title' => 'Jetzt teilnehmen!', + 'accept_terms' => 'Ich akzeptiere die Teilnahmebedingungen', + 'show_terms' => 'Bedingungen anzeigen', + 'participate_now' => 'Jetzt teilnehmen', + 'not_active' => 'Dieses Incentive ist derzeit nicht aktiv.', + 'terms_required' => 'Bitte akzeptiere die Teilnahmebedingungen.', + 'already_participating' => 'Du nimmst bereits teil.', + 'participation_confirmed' => 'Deine Teilnahme wurde bestaetigt!', + + // Teaser-Seite + 'teaser_hero_subtitle' => 'Deine exklusive Auszeit an der Adria wartet auf dich!', + 'teaser_intro_bold' => 'Pack deine Koffer, denn mivita belohnt deine Bestleistungen!', + 'teaser_intro_text' => 'Erlebe unvergessliche Tage an der malerischen Küste, tausche dich mit den Top-Leadern aus und feiere deinen Erfolg mit uns!', + 'teaser_intro_cta' => 'Gehörst du zu den besten :n Partnern? Dann bist du dabei!', + 'teaser_until' => 'bis', + 'teaser_partner_onetime_text' => 'einmalig pro direkt gesponsertem Neupartner im Qualifikationszeitraum.', + 'teaser_abo_onetime_text' => 'einmalig pro neu abgeschlossenem Kundenabo im Qualifikationszeitraum.', + 'teaser_cta_ready' => 'Bist du bereit für die Challenge?', + 'teaser_cta_text' => 'Melde dich jetzt an, um im offiziellen Ranking gelistet zu werden. Nur die besten :n qualifizierten Berater gewinnen!', + 'teaser_cta_button' => 'Jetzt zum Ranking & Teilnehmen', + 'teaser_cta_to_ranking' => 'Zur Live-Rangliste', + 'teaser_cta_already_in' => 'Du bist bereits angemeldet. Verfolge deinen aktuellen Rang in der Live-Rangliste.', + 'teaser_pending_title' => 'Deine Punkte laufen bereits', + 'teaser_pending_text' => 'Bestätige die Teilnahme, damit dein Name in der Rangliste erscheint und du die Detailansicht nutzen kannst.', + 'teaser_cta_confirm' => 'Teilnahme bestätigen', + 'teaser_cta_coming_soon' => 'Bald geht es los!', + + // Show-Seite Sektionen + 'section_period' => 'Der Qualifikationszeitraum', + 'qualification_period' => 'Qualifikationszeitraum', + 'calculation_period' => 'Endspurt (Berechnungsende)', + 'calculation_period_hint' => 'Akkumulierte Punkte werden bis einschließlich :date gerechnet.', + 'section_min_qual' => 'Dein Ticket: Die Mindestqualifikation', + 'min_qual_intro' => 'Um im offiziellen Ranking gelistet zu werden und für den Gewinn in Frage zu kommen, müssen im Qualifikationszeitraum folgende Basis-Ziele erreicht werden:', + 'min_partners_label' => 'direkte neue Teampartner (jeweils nur mit einem Starterpaket)', + 'min_abos_label' => 'neu abgeschlossene Kundenabos', + 'min_qual_ranking_hint' => 'Im Live-Ranking wird dein Name erst dann fettgedruckt hervorgehoben, wenn du diese Mindestqualifikation erfolgreich erreicht hast.', + 'section_points' => 'So sammelst du deine Incentive-Punkte', + 'points_partners_title' => 'Punkte für neue Teampartner', + 'points_abos_title' => 'Punkte für Kundenabos', + 'points_short' => 'Pkt.', + 'points_onetime_label' => 'einmalig pro Neupartner/Abo', + 'points_starter_package_label' => 'jeweils mit einem dirket bestellten Starterpaket, neue Partner mit nur einer Mitgliedschaft zählen leider nicht mit.', + 'points_partner_boost' => 'Zusatz-Boost: Du erhältst alle Kunden- und Eigenumsatzpunkte deines Neupartners ab seinem Starttag, innerhalb des Qualifikationszeitraums.', + 'points_abo_direct' => 'Dein eigenes Abo zählt auch mit - auch bestehende Abos.', + 'points_abo_boost' => 'Zusatz-Boost: Du erhältst die monatlichen Abopunkte ab dem Abschlussmonat, innerhalb des Qualifikationszeitraums.', + 'section_ranking' => 'Das Live-Ranking', + 'ranking_winners_hint' => 'Nur die besten :n qualifizierten Berater gewinnen.', + 'dashboard_btn_teaser' => 'Zum Incentive', + 'dashboard_btn_ranking' => 'Zur Live-Rangliste', + 'read_more' => 'Mehr lesen', + 'read_less' => 'Weniger lesen', + 'you_participate' => 'Du nimmst teil!', + 'your_rank' => 'Dein aktueller Rang', + 'participate_intro' => 'Bist du bereit für die Challenge? Melde dich einmalig an, um im offiziellen Ranking gelistet zu werden.', + 'pending_confirmation_banner' => 'Deine Punkte werden bereits im Qualifikationszeitraum mitgerechnet. Bitte bestätige die Teilnahme, damit dein Name in der Rangliste sichtbar wird und du alle Funktionen nutzen kannst.', + 'details_requires_confirmation' => 'Die Detailansicht ist erst nach Bestätigung der Teilnahme verfügbar.', + 'participate_abo_hint' => 'Es liegt mindestens ein für die Wertung relevantes Abo vor (aktives Berater-Abo oder Kundenabo im Qualifikationszeitraum). Mit dem Teilnehmen werden die Punkte dafür direkt nach den aktuellen Regeln übernommen.', + + // Berechnungsdetails (User) + 'my_details' => 'Meine Berechnung', + 'my_calculation' => 'Meine Berechnungsuebersicht', + 'back_to_ranking' => 'Zurueck zur Rangliste', + 'section_partners' => 'A. Neupartner-Punkte', + 'section_abos' => 'B. Kundenabo-Punkte', + 'new_partner' => 'Neupartner', + 'entry_date' => 'Einstieg', + 'customer_abo' => 'Kundenabo', + 'abo_date' => 'Abschluss', + 'onetime' => 'Einmalig', + 'sum' => 'Gesamt', + 'subtotal' => 'Zwischensumme', + 'no_partners_yet' => 'Noch keine Neupartner erfasst.', + 'no_abos_yet' => 'Noch keine Kundenabos erfasst.', + 'not_yet_qualified' => 'Noch nicht qualifiziert', + + // Transaktionsdetails + 'transaction_date' => 'Datum', + 'transaction_description' => 'Beschreibung', + 'transaction_period' => 'Zeitraum', + 'transaction_type' => 'Art', + 'transaction_points' => 'Punkte', + 'onetime_registration' => 'Einmalig: Registrierung', + 'onetime_abo_activation' => 'Einmalig: Abo-Aktivierung', + 'accumulated' => 'Umsatz', + + // Galerie + 'gallery_title' => 'Impressionen', +]; diff --git a/resources/lang/de/msg.php b/resources/lang/de/msg.php index 32959e2..94381f3 100644 --- a/resources/lang/de/msg.php +++ b/resources/lang/de/msg.php @@ -36,10 +36,10 @@ return [ 'shopping_instance_not_found' => 'Fehler: Es wurde keine ShoppingInstance gefunden', 'shopping_user_not_found' => 'Fehler: Es wurde kein ShoppingUser gefunden', 'account_released' => 'Account freigeschaltet', + 'cart_product_not_allowed_for_order_type' => 'Der Warenkorb enthält Artikel, die für diese Bestellart nicht vorgesehen sind. Bitte leere den Warenkorb und wähle nur Produkte, die zu dieser Bestellung passen.', ]; /* -{{ __('msg.') }} +{{ __('msg.') }} __('msg.') msg.name */ - diff --git a/resources/lang/de/navigation.php b/resources/lang/de/navigation.php index aa2631c..967915a 100644 --- a/resources/lang/de/navigation.php +++ b/resources/lang/de/navigation.php @@ -75,8 +75,15 @@ return [ 'level_reports' => 'Level Reports', 'dashboard_news' => 'Dashboard News', 'teamabos' => 'Team Abos', + 'team_customer_abos' => 'Team Kunden-Abos', 'customer_orders' => 'Kundenbestellungen', 'external_orders' => 'Externe Bestellungen', 'tools' => 'Tools', 'news_archive' => 'News Archiv', + 'incentive' => 'Incentive', + 'incentives' => 'Incentives', + 'create' => 'Neu anlegen', + 'my_abo' => 'Mein Abo', + 'my_subscriptions' => 'Meine Abos', + 'team_customers' => 'Team Kunden', ]; diff --git a/resources/lang/de/order.php b/resources/lang/de/order.php index 0d4c0c5..67e6a2b 100644 --- a/resources/lang/de/order.php +++ b/resources/lang/de/order.php @@ -118,6 +118,7 @@ return [ 'reorder' => 'Nachbestellen', 'reorder_info' => 'Möchtest Du diesen Artikel noch einmal bestellen?
    Mit einem Klick auf den Button werden die Artikel erneut in den Warenkorb gelegt und du wirst auf die Warenkorb-Seite weitergeleitet.', 'reorder_info_2' => 'Dein Lieferland ist: :country
    Möchtest du deine Bestellung in ein anders Land liefern lassen, ändere bitte Deine Rechnungs- oder Lieferadresse unter Meine Daten', + 'reorder_abo_not_allowed' => 'Abo-Bestellungen können nicht über „Nachbestellen“ wiederholt werden. Bitte nutze deine Abo-Verwaltung oder den Shop für Einzelbestellungen.', 'free_shipping' => 'Versandkostenfrei', 'free_shipping_reached' => 'Ab :amount € versandkostenfrei', 'free_shipping_info' => 'Noch :missing € bis zum versandkostenfreien Versand (ab :amount €)', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 3adf649..228b196 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -16,6 +16,7 @@ return [ 'abo_order_info_check_2' => 'The first delivery and billing takes place on the day the subscription is set up. After that, shipping is automatically carried out on the selected delivery day of the following month.', 'abo_order_info_check_3' => 'PayPal and credit card are available as payment methods. The subscription has a minimum duration of :abo-min-duration months. After that, it can be paused, changed or canceled at any time.', 'abo_order_info_checkbox' => 'Yes, I have understood the subscription terms!', + 'abo_order_info_checkbox_required' => 'Please confirm the subscription terms to continue.', 'abo_infos' => 'Subscription info', 'abo_delivery_infos' => 'Subscription delivery information', 'abo_start_date' => 'Start date of the subscription', @@ -99,9 +100,23 @@ return [ 'change_my_data_empty' => 'You have not yet stored a billing and delivery address, without this you cannot create a subscription, please create it.', 'abo_error_basis_product' => 'Error: Please select at least one base product.', 'error_abo_interval_in_the_past' => 'The subscription has not been executed this month yet. Changing to a past day would skip the current month.', + 'warning_next_date_soon' => 'Note: The next subscription execution is in :days days (:date).', + 'warning_next_date_soon_select' => 'Note: The next subscription execution is in :placeholder_days days (:placeholder_date).', + 'warning_next_date_info' => 'The next subscription execution is in :days days on :date.', + 'info_next_execution_select' => 'Next execution: in :placeholder_days days on :placeholder_date.', + 'error_change_locked' => 'Changes are no longer possible. The next execution is in :days days. Changes must be made at least 10 days in advance.', + 'error_abo_interval_too_soon' => 'The selected delivery day is only :days days away. Please choose a delivery day at least 10 days in the future.', + 'error_cancel_locked' => 'Cancellation is no longer possible. The next execution is in :days days. Cancellations must be made at least 3 days in advance.', + 'error_pause_locked' => 'The subscription can no longer be paused. The next execution is in :days days. Pausing must be done at least 3 days in advance.', 'cancel_abo' => 'Cancel subscription', 'confirm_cancel' => 'Do you really want to cancel the subscription?', 'back' => 'back', 'team_subscriptions' => 'Team subscriptions', + 'team_customer_abos' => 'Team Customer Subscriptions', + 'chart_monthly_abos' => 'Subscriptions per month', + 'chart_active_abos' => 'Active subscriptions', + 'chart_abos_label' => 'subscriptions', + 'abo_count' => 'Number of subscriptions', + 'customer_privacy_info' => 'For privacy reasons, no personal customer data is displayed.', 'every_month_on' => 'monthly on :day.', ]; diff --git a/resources/lang/en/home.php b/resources/lang/en/home.php index ffebb72..b0a4443 100644 --- a/resources/lang/en/home.php +++ b/resources/lang/en/home.php @@ -1,60 +1,60 @@ 'MIVITA_Consultant contract', - 'active_role' => 'active role', - 'activities' => 'activities', - 'adjust_data' => 'adjust data', - 'adviser_membership_active' => 'consultant membership active', - 'adviser_onlineshop_active' => 'consultant online shop active', - 'adviser_onlineshop_inactive' => 'consultant shop inactive', - 'advisor_account_inactive' => 'consultant account inactive', - 'at' => 'at the', - 'change_your_email_address' => 'Change your email address.', - 'change_your_personal_data' => 'Change your personal information.', - 'change_your_personal_password' => 'Change your personal password.', - 'create_your_personal_password' => - array( - '' => 'Create your personal password.', - ), - 'current_points_for' => 'current points for', - 'data' => 'data', - 'data_complete_unlocked' => 'data complete, unlocked', - 'declaration_of_consent' => 'consent form', - 'email_verified' => 'verify email', - 'expired_on' => 'expired on', - 'log_out_and_see_you_soon' => 'Sign out and see you soon.', - 'login' => 'registration', - 'manage_membership' => 'manage membership', - 'manage_membership_now_here' => 'manage your membership here now', - 'membership' => 'membership', - 'news_updates' => 'News & Updates', - 'news_archive' => 'News Archive', - 'news_archive_title' => 'All News & Updates', - 'news_archive_current' => 'Current News', - 'news_archive_older' => 'Older Posts', - 'news_archive_empty' => 'No older posts available.', - 'news_archive_link' => 'View all news', - 'news_back_to_dashboard' => 'Back to Dashboard', - 'open_since' => 'opened since', - 'open_your_shop' => 'open your own mivita shop', - 'read_less' => 'Show less', - 'read_more' => 'Read more', - 'privacy_policy_approved' => 'privacy policy agreed', - 'security' => 'security', - 'settings_your_shop' => 'your shop settings', - 'shop_not_booked' => 'shop not booked', - 'today_is' => 'today is', - 'until' => 'until', - 'welcome_back' => 'welcome back', - 'your_shop' => 'your shop', - 'monthly_statistics' => 'Monthly Statistics', - 'customer_turnover_points' => 'Customer Turnover Points', - 'team_turnover_points' => 'Team Turnover Points', - 'direct_new_partners' => 'Direct New Partners', - 'team_new_partners' => 'New Partners in Team', - 'customer_subscriptions' => 'Customer Subscriptions', - 'team_subscriptions' => 'Team Subscriptions', - 'own' => 'Own', - 'live_calculation_hint' => 'Live calculation (not yet finalized)', -); +return [ + 'MIVITA_Consultancy_agreement' => 'MIVITA_Consultant contract', + 'active_role' => 'active role', + 'activities' => 'activities', + 'adjust_data' => 'adjust data', + 'adviser_membership_active' => 'consultant membership active', + 'adviser_onlineshop_active' => 'consultant online shop active', + 'adviser_onlineshop_inactive' => 'consultant shop inactive', + 'advisor_account_inactive' => 'consultant account inactive', + 'at' => 'at the', + 'change_your_email_address' => 'Change your email address.', + 'change_your_personal_data' => 'Change your personal information.', + 'change_your_personal_password' => 'Change your personal password.', + 'create_your_personal_password' => [ + '' => 'Create your personal password.', + ], + 'current_points_for' => 'current points for', + 'data' => 'data', + 'data_complete_unlocked' => 'data complete, unlocked', + 'declaration_of_consent' => 'consent form', + 'email_verified' => 'verify email', + 'expired_on' => 'expired on', + 'log_out_and_see_you_soon' => 'Sign out and see you soon.', + 'login' => 'registration', + 'manage_membership' => 'manage membership', + 'manage_membership_now_here' => 'manage your membership here now', + 'membership' => 'membership', + 'news_updates' => 'News & Updates', + 'news_archive' => 'News Archive', + 'news_archive_title' => 'All News & Updates', + 'news_archive_current' => 'Current News', + 'news_archive_older' => 'Older Posts', + 'news_archive_empty' => 'No older posts available.', + 'news_archive_link' => 'View all news', + 'news_back_to_dashboard' => 'Back to Dashboard', + 'open_since' => 'opened since', + 'open_your_shop' => 'open your own mivita shop', + 'read_less' => 'Show less', + 'read_more' => 'Read more', + 'privacy_policy_approved' => 'privacy policy agreed', + 'security' => 'security', + 'settings_your_shop' => 'your shop settings', + 'shop_not_booked' => 'shop not booked', + 'today_is' => 'today is', + 'until' => 'until', + 'welcome_back' => 'welcome back', + 'your_shop' => 'your shop', + 'monthly_statistics' => 'Monthly Statistics', + 'customer_turnover_points' => 'Customer Turnover Points', + 'team_turnover_points' => 'Team Turnover Points', + 'direct_new_partners' => 'Direct New Partners', + 'team_new_partners' => 'New Partners in Team', + 'customer_subscriptions' => 'Customer Subscriptions', + 'team_subscriptions' => 'Team Subscriptions', + 'own' => 'Own', + 'live_calculation_hint' => 'Live calculation (not yet finalized)', + 'live_calculation_hint_text' => 'Will be calculated at the end of the month.', +]; diff --git a/resources/lang/en/incentive.php b/resources/lang/en/incentive.php new file mode 100644 index 0000000..92bf945 --- /dev/null +++ b/resources/lang/en/incentive.php @@ -0,0 +1,172 @@ + 'Incentives', + 'incentive' => 'Incentive', + 'name' => 'Name', + 'status' => 'Status', + 'period' => 'Period', + 'actions' => 'Actions', + 'participants' => 'Participants', + 'save' => 'Save', + 'cancel' => 'Cancel', + 'yes' => 'Yes', + 'no' => 'No', + 'you' => 'You', + + // Status + 'status_draft' => 'Draft', + 'status_active' => 'Active', + 'status_closed' => 'Closed', + + // CRUD + 'create' => 'Create new incentive', + 'edit' => 'Edit', + 'created' => 'Incentive has been created successfully.', + 'updated' => 'Incentive has been updated successfully.', + + // Configuration + 'configuration' => 'Configuration', + 'qualification_start' => 'Qualification start', + 'qualification_end' => 'Qualification end', + 'calculation_end' => 'Calculation end', + 'points_partner_onetime' => 'One-time points per partner', + 'points_abo_onetime' => 'One-time points per subscription', + 'min_direct_partners' => 'Min. direct partners', + 'min_customer_abos' => 'Min. customer subscriptions', + 'max_winners' => 'Max. winners', + 'image' => 'Image', + 'image_help' => 'Filename of the image in public/img/incentive/ folder (e.g. montenegro-2026.jpg)', + 'description' => 'Description / Promo text', + 'description_help' => 'Motivating intro text shown on the teaser page.', + 'terms' => 'Terms and conditions', + 'terms_help' => 'Full text of the terms and conditions. Displayed as a collapsible section on the page.', + 'name_help' => 'Internal name of the incentive (also shown as the page headline).', + 'subtitle' => 'Subtitle', + 'subtitle_placeholder' => 'e.g. Your exclusive getaway on the Adriatic coast!', + 'subtitle_help' => 'Short tagline displayed in the hero area below the title.', + 'content_lang_de' => 'German', + 'default_language' => 'Default', + 'lang_fallback_hint' => 'Leave empty = German will be used as fallback.', + + // Ranking + 'ranking' => 'Ranking', + 'rank' => 'Rank', + 'consultant' => 'Consultant', + 'total_points' => 'Total points', + 'partners' => 'Partners', + 'abos' => 'Subscriptions', + 'qualified' => 'Qualified', + 'open' => 'Open', + 'winner' => 'Winner', + 'no_participants' => 'No participants yet.', + 'no_participants_with_points' => 'No participants with points yet.', + 'anonymous_consultant' => 'Anonymous consultant', + 'ranking_anonymous_hint' => 'Names appear only after participation in the incentive has been confirmed.', + 'ranking_extended_hint' => 'The list shows ranks 1–30. The best :n qualified consultants (highlighted) win; the ranks below show who can still push ahead.', + 'calculation_details' => 'Calculation Details', + 'close' => 'Close', + + // Recalculation + 'recalculate' => 'Recalculate', + 'recalculate_confirm' => 'Do you want to start the recalculation?', + 'force_recalculate' => 'Full recalculation', + 'force_recalculate_confirm' => 'WARNING: All existing logs will be deleted and recalculated from scratch. Continue?', + 'recalculated' => 'Recalculation completed. :participants participants processed, :errors errors.', + + // Admin ranking + 'admin_terms_accepted' => 'Participation (terms)', + 'admin_terms_pending' => 'Pending', + 'admin_terms_accepted_at_tooltip' => 'Confirmation time', + + // Participation (User) + 'participate_title' => 'Join now!', + 'accept_terms' => 'I accept the terms and conditions', + 'show_terms' => 'Show terms', + 'participate_now' => 'Join now', + 'not_active' => 'This incentive is currently not active.', + 'terms_required' => 'Please accept the terms and conditions.', + 'already_participating' => 'You are already participating.', + 'participation_confirmed' => 'Your participation has been confirmed!', + + // Teaser page + 'teaser_hero_subtitle' => 'Your exclusive getaway on the Adriatic coast awaits!', + 'teaser_intro_bold' => 'Pack your bags, because mivita rewards your top performance!', + 'teaser_intro_text' => 'Experience unforgettable days on the picturesque coastline, connect with top leaders and celebrate your success with us!', + 'teaser_intro_cta' => 'Are you among the best :n partners? Then you\'re in!', + 'teaser_until' => 'until', + 'teaser_partner_onetime_text' => 'one-time for each directly sponsored new partner during the qualification period.', + 'teaser_abo_onetime_text' => 'one-time for each newly concluded customer subscription during the qualification period.', + 'teaser_cta_ready' => 'Ready for the challenge?', + 'teaser_cta_text' => 'Sign up now to be listed in the official ranking. Only the best :n qualified consultants win!', + 'teaser_cta_button' => 'Go to ranking & join', + 'teaser_cta_to_ranking' => 'To the live ranking', + 'teaser_cta_already_in' => 'You are already registered. Track your current rank in the live ranking.', + 'teaser_pending_title' => 'Your points are already counting', + 'teaser_pending_text' => 'Confirm participation so your name appears in the ranking and you can open the detail view.', + 'teaser_cta_confirm' => 'Confirm participation', + 'teaser_cta_coming_soon' => 'Coming soon!', + + // Show page sections + 'section_period' => 'The Qualification Period', + 'qualification_period' => 'Qualification period', + 'calculation_period' => 'Final sprint (calculation end)', + 'calculation_period_hint' => 'Accumulated points are counted up to and including :date.', + 'section_min_qual' => 'Your Ticket: Minimum Qualification', + 'min_qual_intro' => 'To be listed in the official ranking and be eligible to win, the following base goals must be achieved during the qualification period:', + 'min_partners_label' => 'direct new team partners (each only with one starter package)', + 'min_abos_label' => 'newly concluded customer subscriptions', + 'min_qual_ranking_hint' => 'In the live ranking, your name will only be highlighted in bold once you have successfully reached this minimum qualification.', + 'section_points' => 'How to Collect Your Incentive Points', + 'points_partners_title' => 'Points for new team partners', + 'points_abos_title' => 'Points for customer subscriptions', + 'points_short' => 'pts.', + 'points_onetime_label' => 'one-time per new partner/subscription', + 'points_starter_package_label' => 'each with a directly ordered starter package, new partners with only one membership do not count.', + 'points_partner_boost' => 'Bonus boost: You also receive all customer and own sales points of your new partner from their start date, within the qualification period.', + 'points_abo_direct' => 'Your own subscription also counts - existing subscriptions are also included.', + 'points_abo_boost' => 'Bonus boost: You also receive the monthly subscription points from the subscription start month, within the qualification period.', + 'section_ranking' => 'The Live Ranking', + 'ranking_winners_hint' => 'Only the best :n qualified consultants win.', + 'dashboard_btn_teaser' => 'To the incentive', + 'dashboard_btn_ranking' => 'To the live ranking', + 'read_more' => 'Read more', + 'read_less' => 'Read less', + 'you_participate' => 'You are participating!', + 'your_rank' => 'Your current rank', + 'participate_intro' => 'Ready for the challenge? Register once to be listed in the official ranking.', + 'pending_confirmation_banner' => 'Your points are already counted for the qualification period. Please confirm participation so your name appears in the ranking and you can use all features.', + 'details_requires_confirmation' => 'The detail view is available only after you confirm participation.', + 'participate_abo_hint' => 'You already have at least one subscription that counts (active consultant subscription or a customer subscription started in the qualification period). When you join, points for it are applied immediately according to the current rules.', + + // Calculation details (User) + 'my_details' => 'My calculation', + 'my_calculation' => 'My calculation overview', + 'back_to_ranking' => 'Back to ranking', + 'section_partners' => 'A. New partner points', + 'section_abos' => 'B. Customer subscription points', + 'new_partner' => 'New partner', + 'entry_date' => 'Entry date', + 'customer_abo' => 'Customer subscription', + 'abo_date' => 'Start date', + 'onetime' => 'One-time', + 'sum' => 'Total', + 'subtotal' => 'Subtotal', + 'no_partners_yet' => 'No new partners recorded yet.', + 'no_abos_yet' => 'No customer subscriptions recorded yet.', + 'not_yet_qualified' => 'Not yet qualified', + + // Transaction details + 'transaction_date' => 'Date', + 'transaction_description' => 'Description', + 'transaction_period' => 'Period', + 'transaction_type' => 'Type', + 'transaction_points' => 'Points', + 'onetime_registration' => 'One-time: Registration', + 'onetime_abo_activation' => 'One-time: Subscription activation', + 'accumulated' => 'Sales', + + // Gallery + 'gallery_title' => 'Impressions', +]; diff --git a/resources/lang/en/msg.php b/resources/lang/en/msg.php index 4a7a711..f737fce 100644 --- a/resources/lang/en/msg.php +++ b/resources/lang/en/msg.php @@ -1,42 +1,42 @@ 'The VAT ID could not be validated, please check your entry', - 'VATID_successfully_entered' => 'VAT ID entered successfully', - 'abo_deaktivert' => 'subscription option disabled', - 'account_released' => 'Account activated', - 'booked_package_has_been_changed' => 'booked package has been changed.', - 'cancel_membership_is_requested' => 'Termination of membership has been requested', - 'compensation_products_cannot_be_0' => 'Error: The compensation products cannot be 0.', - 'contact_delete' => 'contact deleted', - 'country_account_has_been_changed__cost_has_been_reset' => 'The billing country has been changed and the goods checklist has been reset', - 'error_checkbox_not_confirm' => 'Error: Checkbox not confirmed', - 'error_occurred_with_order' => 'An error occurred while ordering', - 'file_deleted' => 'file deleted', - 'file_empty' => '"file empty"', - 'file_not_found' => 'file not found', - 'file_uploaded' => 'file uploaded', - 'homeparty_delete' => 'time out party deleted', - 'homeparty_guest_delete' => 'time out party guest deleted', - 'link_for_homeparty_not_found' => 'Link for the time-out party was not found or is no longer active.', - 'no_change_made' => 'no change made', - 'no_id_card_deposited_please_upload_first' => 'No ID provided, please upload it first', - 'no_trade_licence_deposited_please_upload_first' => 'No business license stored, please upload it first', - 'please_enter_reason_why_you_not_need_trade_licence' => 'Please provide a reason why you do not need a business license', - 'please_select_compensation_product' => 'Please select a compensation product', - 'please_select_count_compensation_products' => 'Please select :count compensation products', - 'reverse_charge_procedure_and_VATID_deleted' => 'reverse charge procedure and VAT ID deleted', - 'shipping_cost_cannot_be_0' => 'Error: Shipping cost cannot be 0', - 'shipping_costs_were_not_calculated_correctly' => 'Error: Shipping costs were not calculated correctly', - 'shipping_country_was_not_correctly' => 'Error: The shipping country was not processed correctly in the shopping cart', - 'shipping_country_was_not_found' => 'Error: Shipping country not found', - 'shopping_cart_was_not_user_shop' => 'Error: The consultant has no store, the order cannot be continued', - 'shopping_cart_was_shipping_free' => 'Error: The shopping cart was specified as free shipping', - 'shopping_instance_not_found' => 'Error: No ShoppingInstance was found', - 'shopping_user_not_found' => 'Error: No ShoppingUser was found', - 'user_not_found' => 'The consultant was not found.
    The account has been deactivated or deleted.', - 'your_shopping_cart_is_empty_please_add_products_first' => - array ( - '' => 'Your shopping cart is empty, please add products first', - ), -); +return [ + 'VATID_could_not_be_validated' => 'The VAT ID could not be validated, please check your entry', + 'VATID_successfully_entered' => 'VAT ID entered successfully', + 'abo_deaktivert' => 'subscription option disabled', + 'account_released' => 'Account activated', + 'booked_package_has_been_changed' => 'booked package has been changed.', + 'cancel_membership_is_requested' => 'Termination of membership has been requested', + 'compensation_products_cannot_be_0' => 'Error: The compensation products cannot be 0.', + 'contact_delete' => 'contact deleted', + 'country_account_has_been_changed__cost_has_been_reset' => 'The billing country has been changed and the goods checklist has been reset', + 'error_checkbox_not_confirm' => 'Error: Checkbox not confirmed', + 'error_occurred_with_order' => 'An error occurred while ordering', + 'file_deleted' => 'file deleted', + 'file_empty' => '"file empty"', + 'file_not_found' => 'file not found', + 'file_uploaded' => 'file uploaded', + 'homeparty_delete' => 'time out party deleted', + 'homeparty_guest_delete' => 'time out party guest deleted', + 'link_for_homeparty_not_found' => 'Link for the time-out party was not found or is no longer active.', + 'no_change_made' => 'no change made', + 'no_id_card_deposited_please_upload_first' => 'No ID provided, please upload it first', + 'no_trade_licence_deposited_please_upload_first' => 'No business license stored, please upload it first', + 'please_enter_reason_why_you_not_need_trade_licence' => 'Please provide a reason why you do not need a business license', + 'please_select_compensation_product' => 'Please select a compensation product', + 'please_select_count_compensation_products' => 'Please select :count compensation products', + 'reverse_charge_procedure_and_VATID_deleted' => 'reverse charge procedure and VAT ID deleted', + 'shipping_cost_cannot_be_0' => 'Error: Shipping cost cannot be 0', + 'shipping_costs_were_not_calculated_correctly' => 'Error: Shipping costs were not calculated correctly', + 'shipping_country_was_not_correctly' => 'Error: The shipping country was not processed correctly in the shopping cart', + 'shipping_country_was_not_found' => 'Error: Shipping country not found', + 'shopping_cart_was_not_user_shop' => 'Error: The consultant has no store, the order cannot be continued', + 'shopping_cart_was_shipping_free' => 'Error: The shopping cart was specified as free shipping', + 'shopping_instance_not_found' => 'Error: No ShoppingInstance was found', + 'shopping_user_not_found' => 'Error: No ShoppingUser was found', + 'user_not_found' => 'The consultant was not found.
    The account has been deactivated or deleted.', + 'your_shopping_cart_is_empty_please_add_products_first' => [ + '' => 'Your shopping cart is empty, please add products first', + ], + 'cart_product_not_allowed_for_order_type' => 'Your cart contains items that are not allowed for this order type. Please clear the cart and only add products that match this order.', +]; diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index 2a18d54..a022f4d 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -75,8 +75,15 @@ return [ 'shop' => 'Shop', 'to_shop' => 'To Shop', 'teamabos' => 'Team Abos', + 'team_customer_abos' => 'Team Customer Subscriptions', 'customer_orders' => 'Customer Orders', 'external_orders' => 'External Orders', 'tools' => 'Tools', 'news_archive' => 'News Archive', + 'incentive' => 'Incentive', + 'incentives' => 'Incentives', + 'create' => 'Create new', + 'my_abo' => 'My Abo', + 'my_subscriptions' => 'My Subscriptions', + 'team_customers' => 'Team Customers', ]; diff --git a/resources/lang/en/order.php b/resources/lang/en/order.php index 733f9dc..04bd052 100644 --- a/resources/lang/en/order.php +++ b/resources/lang/en/order.php @@ -118,4 +118,8 @@ return [ 'free_shipping' => 'Free Shipping', 'free_shipping_reached' => 'Free shipping from :amount €', 'free_shipping_info' => 'Only :missing € more for free shipping (from :amount €)', + 'reorder' => 'Reorder', + 'reorder_info' => 'Would you like to order these items again?
    Click the button to add them to your cart and go to the cart page.', + 'reorder_info_2' => 'Your delivery country is: :country
    If you want a different country, update your billing or delivery address under My details', + 'reorder_abo_not_allowed' => 'Subscription orders cannot be repeated via “Reorder”. Please use your subscription area or the shop for one-off orders.', ]; diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index 1e39d95..f2f9759 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -16,6 +16,7 @@ return [ 'abo_order_info_check_2' => 'La primera entrega y facturación se realiza el día en que se establece la suscripción. Después, el envío se realiza automáticamente el día de entrega seleccionado del mes siguiente.', 'abo_order_info_check_3' => 'PayPal y tarjeta de crédito están disponibles como métodos de pago. La suscripción tiene una duración mínima de :abo-min-duration meses. Después, puede pausarse, cambiarse o cancelarse en cualquier momento.', 'abo_order_info_checkbox' => '¡Sí, he entendido los términos de la suscripción!', + 'abo_order_info_checkbox_required' => 'Por favor, confirma los términos de la suscripción para continuar.', 'abo_infos' => 'Información de suscripción', 'abo_delivery_infos' => 'Información de entrega de la suscripción', 'abo_start_date' => 'Fecha de inicio de la suscripción', @@ -99,9 +100,23 @@ return [ 'change_my_data_empty' => 'Aún no ha almacenado una dirección de facturación y entrega, sin esta no puede crear una suscripción, por favor créela.', 'abo_error_basis_product' => 'Error: Por favor seleccione al menos un producto base.', 'error_abo_interval_in_the_past' => 'La suscripción no se ha ejecutado este mes aún. Cambiar a un día pasado saltaría el mes actual.', + 'warning_next_date_soon' => 'Nota: La próxima ejecución de la suscripción es en :days días (:date).', + 'warning_next_date_soon_select' => 'Nota: La próxima ejecución de la suscripción es en :placeholder_days días (:placeholder_date).', + 'warning_next_date_info' => 'La próxima ejecución de la suscripción es en :days días el :date.', + 'info_next_execution_select' => 'Próxima ejecución: en :placeholder_days días el :placeholder_date.', + 'error_change_locked' => 'Los cambios ya no son posibles. La próxima ejecución es en :days días. Los cambios deben realizarse al menos 10 días antes.', + 'error_abo_interval_too_soon' => 'El día de entrega seleccionado está a solo :days días. Elija un día de entrega con al menos 10 días de anticipación.', + 'error_cancel_locked' => 'La cancelación ya no es posible. La próxima ejecución es en :days días. Las cancelaciones deben realizarse al menos 3 días antes.', + 'error_pause_locked' => 'La suscripción ya no puede pausarse. La próxima ejecución es en :days días. La pausa debe realizarse al menos 3 días antes.', 'cancel_abo' => 'Cancelar suscripción', 'confirm_cancel' => '¿Realmente desea cancelar la suscripción?', 'back' => 'atrás', 'team_subscriptions' => 'Suscripciones de equipo', + 'team_customer_abos' => 'Suscripciones de clientes del equipo', + 'chart_monthly_abos' => 'Suscripciones por mes', + 'chart_active_abos' => 'Suscripciones activas', + 'chart_abos_label' => 'suscripciones', + 'abo_count' => 'Número de suscripciones', + 'customer_privacy_info' => 'Por razones de privacidad, no se muestran datos personales del cliente.', 'every_month_on' => 'mensualmente el :day.', ]; diff --git a/resources/lang/es/home.php b/resources/lang/es/home.php index dc6803b..4cb72f4 100644 --- a/resources/lang/es/home.php +++ b/resources/lang/es/home.php @@ -1,51 +1,60 @@ 'MIVITA_Contrato de consultor', - 'active_role' => 'rol activo', - 'activities' => 'actividades', - 'adjust_data' => 'ajustar datos', - 'adviser_membership_active' => 'membresía consultora activa', - 'adviser_onlineshop_active' => 'tienda online del consultor activa', - 'adviser_onlineshop_inactive' => 'tienda de consultores inactiva', - 'advisor_account_inactive' => 'cuenta de consultor inactiva', - 'at' => 'en el', - 'change_your_email_address' => 'cambia tu direccion de correo electronico.', - 'change_your_personal_data' => 'cambie su información personal.', - 'change_your_personal_password' => 'cambie su contraseña personal.', - 'create_your_personal_password' => - array( - '' => 'crea tu contraseña personal.', - ), - 'current_points_for' => 'puntos actuales para', - 'data' => 'datos', - 'data_complete_unlocked' => 'datos completos, desbloqueados', - 'declaration_of_consent' => 'formulario de consentimiento', - 'email_verified' => 'verificar correo electrónico', - 'expired_on' => 'expirado el', - 'log_out_and_see_you_soon' => 'Cierra sesión y nos vemos pronto.', - 'login' => 'registro', - 'manage_membership' => 'gestionar la afiliación', - 'manage_membership_now_here' => 'gestiona tu membresía aquí ahora', - 'membership' => 'afiliación', - 'news_updates' => 'Noticias y Actualizaciones', - 'news_archive' => 'Archivo de Noticias', - 'news_archive_title' => 'Todas las Noticias y Actualizaciones', - 'news_archive_current' => 'Noticia actual', - 'news_archive_older' => 'Publicaciones anteriores', - 'news_archive_empty' => 'No hay publicaciones anteriores disponibles.', - 'news_archive_link' => 'Ver todas las noticias', - 'news_back_to_dashboard' => 'Volver al panel', - 'open_since' => 'abierto desde', - 'open_your_shop' => 'abre tu propia tienda mivita', - 'read_less' => 'Mostrar menos', - 'read_more' => 'Leer más', - 'privacy_policy_approved' => 'política de privacidad acordada', - 'security' => 'seguridad', - 'settings_your_shop' => 'configuración de tu tienda', - 'shop_not_booked' => 'tienda no reservada', - 'today_is' => 'hoy es', - 'until' => 'hasta', - 'welcome_back' => 'bienvenido de nuevo', - 'your_shop' => 'tu tienda', -); +return [ + 'MIVITA_Consultancy_agreement' => 'MIVITA_Contrato de consultor', + 'active_role' => 'rol activo', + 'activities' => 'actividades', + 'adjust_data' => 'ajustar datos', + 'adviser_membership_active' => 'membresía consultora activa', + 'adviser_onlineshop_active' => 'tienda online del consultor activa', + 'adviser_onlineshop_inactive' => 'tienda de consultores inactiva', + 'advisor_account_inactive' => 'cuenta de consultor inactiva', + 'at' => 'en el', + 'change_your_email_address' => 'cambia tu direccion de correo electronico.', + 'change_your_personal_data' => 'cambie su información personal.', + 'change_your_personal_password' => 'cambie su contraseña personal.', + 'create_your_personal_password' => [ + '' => 'crea tu contraseña personal.', + ], + 'current_points_for' => 'puntos actuales para', + 'data' => 'datos', + 'data_complete_unlocked' => 'datos completos, desbloqueados', + 'declaration_of_consent' => 'formulario de consentimiento', + 'email_verified' => 'verificar correo electrónico', + 'expired_on' => 'expirado el', + 'log_out_and_see_you_soon' => 'Cierra sesión y nos vemos pronto.', + 'login' => 'registro', + 'manage_membership' => 'gestionar la afiliación', + 'manage_membership_now_here' => 'gestiona tu membresía aquí ahora', + 'membership' => 'afiliación', + 'news_updates' => 'Noticias y Actualizaciones', + 'news_archive' => 'Archivo de Noticias', + 'news_archive_title' => 'Todas las Noticias y Actualizaciones', + 'news_archive_current' => 'Noticia actual', + 'news_archive_older' => 'Publicaciones anteriores', + 'news_archive_empty' => 'No hay publicaciones anteriores disponibles.', + 'news_archive_link' => 'Ver todas las noticias', + 'news_back_to_dashboard' => 'Volver al panel', + 'open_since' => 'abierto desde', + 'open_your_shop' => 'abre tu propia tienda mivita', + 'read_less' => 'Mostrar menos', + 'read_more' => 'Leer más', + 'privacy_policy_approved' => 'política de privacidad acordada', + 'security' => 'seguridad', + 'settings_your_shop' => 'configuración de tu tienda', + 'shop_not_booked' => 'tienda no reservada', + 'today_is' => 'hoy es', + 'until' => 'hasta', + 'welcome_back' => 'bienvenido de nuevo', + 'your_shop' => 'tu tienda', + 'monthly_statistics' => 'Estadísticas mensuales', + 'customer_turnover_points' => 'Puntos de venta de clientes', + 'team_turnover_points' => 'Puntos de venta del equipo', + 'direct_new_partners' => 'Nuevos socios directos', + 'team_new_partners' => 'Nuevos socios en el equipo', + 'customer_subscriptions' => 'Suscripciones de clientes', + 'team_subscriptions' => 'Suscripciones del equipo', + 'own' => 'Propio', + 'live_calculation_hint' => 'Cálculo en vivo (no finalizado)', + 'live_calculation_hint_text' => 'Se calculará al final del mes.', +]; diff --git a/resources/lang/es/incentive.php b/resources/lang/es/incentive.php new file mode 100644 index 0000000..5f16f51 --- /dev/null +++ b/resources/lang/es/incentive.php @@ -0,0 +1,172 @@ + 'Incentivos', + 'incentive' => 'Incentivo', + 'name' => 'Nombre', + 'status' => 'Estado', + 'period' => 'Periodo', + 'actions' => 'Acciones', + 'participants' => 'Participantes', + 'save' => 'Guardar', + 'cancel' => 'Cancelar', + 'yes' => 'Si', + 'no' => 'No', + 'you' => 'Tu', + + // Status + 'status_draft' => 'Borrador', + 'status_active' => 'Activo', + 'status_closed' => 'Cerrado', + + // CRUD + 'create' => 'Crear nuevo incentivo', + 'edit' => 'Editar', + 'created' => 'El incentivo se ha creado correctamente.', + 'updated' => 'El incentivo se ha actualizado correctamente.', + + // Configuration + 'configuration' => 'Configuracion', + 'qualification_start' => 'Inicio de calificacion', + 'qualification_end' => 'Fin de calificacion', + 'calculation_end' => 'Fin de calculo', + 'points_partner_onetime' => 'Puntos unicos por socio', + 'points_abo_onetime' => 'Puntos unicos por suscripcion', + 'min_direct_partners' => 'Min. socios directos', + 'min_customer_abos' => 'Min. suscripciones de clientes', + 'max_winners' => 'Max. ganadores', + 'image' => 'Imagen', + 'image_help' => 'Nombre del archivo de imagen en la carpeta public/img/incentive/ (p.ej. montenegro-2026.jpg)', + 'description' => 'Descripcion / Texto promocional', + 'description_help' => 'Texto introductorio motivador que se muestra en la pagina teaser.', + 'terms' => 'Terminos y condiciones', + 'terms_help' => 'Texto completo de los terminos y condiciones. Se muestra como seccion desplegable en la pagina.', + 'name_help' => 'Nombre interno del incentivo (tambien se muestra como titulo de la pagina).', + 'subtitle' => 'Subtitulo', + 'subtitle_placeholder' => 'p.ej. Tu escapada exclusiva en la costa adriatica!', + 'subtitle_help' => 'Eslogan corto que se muestra en el area hero debajo del titulo.', + 'content_lang_de' => 'Aleman', + 'default_language' => 'Predeterminado', + 'lang_fallback_hint' => 'Dejar vacio = se usara el aleman como alternativa.', + + // Ranking + 'ranking' => 'Clasificacion', + 'rank' => 'Puesto', + 'consultant' => 'Consultor', + 'total_points' => 'Puntos totales', + 'partners' => 'Socios', + 'abos' => 'Suscripciones', + 'qualified' => 'Calificado', + 'open' => 'Abierto', + 'winner' => 'Ganador', + 'no_participants' => 'Aun no hay participantes.', + 'no_participants_with_points' => 'Aun no hay participantes con puntos.', + 'anonymous_consultant' => 'Consultor anonimo', + 'ranking_anonymous_hint' => 'Los nombres solo se muestran despues de confirmar la participacion en el incentivo.', + 'ranking_extended_hint' => 'La lista muestra los puestos 1–30. Los mejores :n consultores calificados (marcados) ganan; los puestos siguientes muestran quien aun puede reforzar.', + 'calculation_details' => 'Detalles del calculo', + 'close' => 'Cerrar', + + // Recalculation + 'recalculate' => 'Recalcular', + 'recalculate_confirm' => 'Desea iniciar el recalculo?', + 'force_recalculate' => 'Recalculo completo', + 'force_recalculate_confirm' => 'ATENCION: Se eliminaran todos los registros existentes y se recalculara desde cero. Continuar?', + 'recalculated' => 'Recalculo completado. :participants participantes procesados, :errors errores.', + + // Admin ranking + 'admin_terms_accepted' => 'Participacion (terminos)', + 'admin_terms_pending' => 'Pendiente', + 'admin_terms_accepted_at_tooltip' => 'Fecha de confirmacion', + + // Participation (User) + 'participate_title' => 'Participa ahora!', + 'accept_terms' => 'Acepto los terminos y condiciones', + 'show_terms' => 'Mostrar terminos', + 'participate_now' => 'Participar ahora', + 'not_active' => 'Este incentivo no esta activo actualmente.', + 'terms_required' => 'Por favor acepta los terminos y condiciones.', + 'already_participating' => 'Ya estas participando.', + 'participation_confirmed' => 'Tu participacion ha sido confirmada!', + + // Teaser page + 'teaser_hero_subtitle' => 'Tu escapada exclusiva en la costa adriatica te espera!', + 'teaser_intro_bold' => 'Haz las maletas, porque mivita premia tus mejores resultados!', + 'teaser_intro_text' => 'Vive dias inolvidables en la pintoresca costa, conecta con los mejores lideres y celebra tu exito con nosotros!', + 'teaser_intro_cta' => 'Eres de los mejores :n socios? Entonces estas dentro!', + 'teaser_until' => 'hasta', + 'teaser_partner_onetime_text' => 'unico por cada nuevo socio patrocinado directamente durante el periodo de calificacion.', + 'teaser_abo_onetime_text' => 'unico por cada nueva suscripcion de cliente concluida durante el periodo de calificacion.', + 'teaser_cta_ready' => 'Listo para el desafio?', + 'teaser_cta_text' => 'Registrate ahora para aparecer en el ranking oficial. Solo los mejores :n consultores calificados ganan!', + 'teaser_cta_button' => 'Ir al ranking y participar', + 'teaser_cta_to_ranking' => 'Al ranking en vivo', + 'teaser_cta_already_in' => 'Ya estas registrado. Sigue tu puesto actual en el ranking en vivo.', + 'teaser_pending_title' => 'Tus puntos ya cuentan', + 'teaser_pending_text' => 'Confirma la participacion para que tu nombre aparezca en el ranking y puedas abrir la vista detallada.', + 'teaser_cta_confirm' => 'Confirmar participacion', + 'teaser_cta_coming_soon' => 'Proximamente!', + + // Show page sections + 'section_period' => 'El Periodo de Calificacion', + 'qualification_period' => 'Periodo de calificacion', + 'calculation_period' => 'Recta final (fin de calculo)', + 'calculation_period_hint' => 'Los puntos acumulados se cuentan hasta el :date inclusive.', + 'section_min_qual' => 'Tu Billete: Calificacion Minima', + 'min_qual_intro' => 'Para aparecer en el ranking oficial y poder ganar, deben alcanzarse los siguientes objetivos base durante el periodo de calificacion:', + 'min_partners_label' => 'nuevos socios directos del equipo (cada uno solo con un paquete de inicio)', + 'min_abos_label' => 'nuevas suscripciones de clientes concluidas', + 'min_qual_ranking_hint' => 'En el ranking en vivo, tu nombre solo se resaltara en negrita cuando hayas alcanzado exitosamente esta calificacion minima.', + 'section_points' => 'Como Acumular tus Puntos de Incentivo', + 'points_partners_title' => 'Puntos por nuevos socios del equipo', + 'points_abos_title' => 'Puntos por suscripciones de clientes', + 'points_short' => 'pts.', + 'points_onetime_label' => 'unico por nuevo socio/suscripcion', + 'points_starter_package_label' => 'cada uno con un paquete de inicio directo ordenado, los nuevos socios con solo una membresia no cuentan.', + 'points_partner_boost' => 'Bonus extra: Tambien recibes todos los puntos de ventas de clientes y propios de tu nuevo socio desde su fecha de inicio, dentro del periodo de calificacion.', + 'points_abo_direct' => 'Tu propia suscripcion tambien cuenta - incluso suscripciones existentes.', + 'points_abo_boost' => 'Bonus extra: Tambien recibes los puntos mensuales de suscripcion desde el mes de inicio, dentro del periodo de calificacion.', + 'section_ranking' => 'El Ranking en Vivo', + 'ranking_winners_hint' => 'Solo los mejores :n consultores calificados ganan.', + 'dashboard_btn_teaser' => 'Al incentivo', + 'dashboard_btn_ranking' => 'Al ranking en vivo', + 'read_more' => 'Leer más', + 'read_less' => 'Leer menos', + 'you_participate' => 'Estas participando!', + 'your_rank' => 'Tu puesto actual', + 'participate_intro' => 'Listo para el desafio? Registrate una vez para aparecer en el ranking oficial.', + 'pending_confirmation_banner' => 'Tus puntos ya cuentan en el periodo de calificacion. Confirma la participacion para que tu nombre sea visible en el ranking y puedas usar todas las funciones.', + 'details_requires_confirmation' => 'La vista detallada solo esta disponible despues de confirmar la participacion.', + 'participate_abo_hint' => 'Ya tienes al menos una suscripcion relevante (suscripcion de consultor activa o suscripcion de cliente en el periodo de calificacion). Al participar, los puntos se aplican de inmediato segun las reglas vigentes.', + + // Calculation details (User) + 'my_details' => 'Mi calculo', + 'my_calculation' => 'Mi resumen de calculo', + 'back_to_ranking' => 'Volver a la clasificacion', + 'section_partners' => 'A. Puntos de nuevos socios', + 'section_abos' => 'B. Puntos de suscripciones de clientes', + 'new_partner' => 'Nuevo socio', + 'entry_date' => 'Fecha de entrada', + 'customer_abo' => 'Suscripcion de cliente', + 'abo_date' => 'Fecha de inicio', + 'onetime' => 'Unico', + 'sum' => 'Total', + 'subtotal' => 'Subtotal', + 'no_partners_yet' => 'Aun no se han registrado nuevos socios.', + 'no_abos_yet' => 'Aun no se han registrado suscripciones de clientes.', + 'not_yet_qualified' => 'Aun no calificado', + + // Detalles de transacciones + 'transaction_date' => 'Fecha', + 'transaction_description' => 'Descripcion', + 'transaction_period' => 'Periodo', + 'transaction_type' => 'Tipo', + 'transaction_points' => 'Puntos', + 'onetime_registration' => 'Unico: Registro', + 'onetime_abo_activation' => 'Unico: Activacion de suscripcion', + 'accumulated' => 'Ventas', + + // Galería + 'gallery_title' => 'Impresiones', +]; diff --git a/resources/lang/es/navigation.php b/resources/lang/es/navigation.php index 5e6fc33..65b1e7e 100644 --- a/resources/lang/es/navigation.php +++ b/resources/lang/es/navigation.php @@ -75,8 +75,15 @@ return [ 'shop' => 'Tienda', 'to_shop' => 'A la Tienda', 'teamabos' => 'Suscripciones del Equipo', + 'team_customer_abos' => 'Suscripciones de Clientes del Equipo', 'customer_orders' => 'Pedidos de clientes', 'external_orders' => 'Pedidos externos', 'tools' => 'Herramientas', 'news_archive' => 'Archivo de Noticias', + 'incentive' => 'Incentivo', + 'incentives' => 'Incentivos', + 'create' => 'Crear nuevo', + 'my_abo' => 'Mi Suscripción', + 'my_subscriptions' => 'Mis Suscripciones', + 'team_customers' => 'Clientes del Equipo', ]; diff --git a/resources/views/admin/abo/detail.blade.php b/resources/views/admin/abo/detail.blade.php index dc32549..35776f5 100644 --- a/resources/views/admin/abo/detail.blade.php +++ b/resources/views/admin/abo/detail.blade.php @@ -1,22 +1,30 @@ @extends('layouts.layout-2') @section('content') -

    - {{ __('back') }} - {{ __('navigation.abo') }} {{ '#'.$user_abo->payone_userid }} + {{ __('back') }} + {{ __('navigation.abo') }} {{ '#' . $user_abo->payone_userid }}

    -@if(Session::has('alert-error')) -
    -
    -
      -
    • {{ Session::get('alert-error') }}
    • -
    -
    -
    -@endif + @if (Session::has('alert-error')) +
    +
    +
      +
    • {{ Session::get('alert-error') }}
    • +
    +
    +
    + @endif + @if (Session::has('alert-warning')) +
    +
    +
      +
    • {{ Session::get('alert-warning') }}
    • +
    +
    +
    + @endif
    @include('admin.abo._detail')
    @@ -27,20 +35,24 @@ @include('admin.customer._customer_detail', ['shopping_user' => $customer_detail]) - {!! Form::open(['action' => route('user_abos_update', [$view, $user_abo->id]), 'class' => 'form-horizontal', 'id'=>'cart-order-form']) !!} - -
    - @include('admin.abo._order_abo') + {!! Form::open([ + 'action' => route('user_abos_update', [$view, $user_abo->id]), + 'class' => 'form-horizontal', + 'id' => 'cart-order-form', + ]) !!} + +
    + @include('admin.abo._order_abo') +
    + + @if ($comp_products && Yard::instance('shopping')->getNumComp() > 0) +
    + @include('user.order.comp_product')
    + @endif - @if($comp_products && Yard::instance('shopping')->getNumComp() > 0) -
    - @include('user.order.comp_product') -
    - @endif + {{ Form::close() }} -{{ Form::close() }} -
    @include('admin.abo._initial_composition')
    @@ -54,48 +66,49 @@
    - {{ __('back') }} + {{ __('back') }} -