10.April 2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:15:27 +02:00
parent a00c42e770
commit f58c709945
208 changed files with 19280 additions and 2914 deletions

View file

@ -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"
}
}
}

6
.env
View file

@ -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

View file

@ -0,0 +1,169 @@
<?php
namespace App\Console\Commands;
use App\Models\AboChartSnapshot;
use App\Models\UserAbo;
use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class AboStoreChartSnapshots extends Command
{
protected $signature = 'abo:store-chart-snapshots
{--user= : Nur einen bestimmten User berechnen (user_id)}
{--force : Bereits vorhandene Snapshots überschreiben}';
protected $description = 'Speichert monatliche Abo-Zählungen aller vergangenen Monate in der Datenbank (einmalig je Monat)';
private const SCOPES = ['ot', 'team_abos', 'team_cust_abos'];
private const START_YEAR = 2026;
public function handle(): int
{
$now = Carbon::now();
$force = (bool) $this->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<array{int, int}>
*/
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;
}
}

View file

@ -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<int> 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;

View file

@ -0,0 +1,205 @@
<?php
namespace App\Console\Commands;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* Batch-Neuberechnung fuer Incentives.
*
* Abo-Wertung (nicht hier codiert, sondern in {@see IncentiveParticipant::rebuildFromSourceTables}
* und {@see IncentivePointsLogRepairService::syncMissingTrackingAbos}):
*
* - Eigenabo (me): zaehlt auch wenn es vor dem Qualifikationszeitraum abgeschlossen wurde;
* Einmalpunkte wirken dann ab Qualifikationsbeginn (aktiviert_at/Log-Monat auf Start des Zeitraums).
* - Kundenabo (ot): nur wenn im Qualifikationszeitraum neu abgeschlossen (created_at im Zeitraum).
*
* Zu Beginn werden fuer alle Berater (User mit m_level) fehlende Teilnehmerzeilen ohne
* accepted_terms angelegt ({@see IncentiveParticipant::ensureConsultantsForIncentive}), damit
* Punkte ohne Checkbox mitlaufen; die Rangliste blendet Namen erst nach Zustimmung ein.
*/
class IncentiveCalculate extends Command
{
protected $signature = 'incentive:calculate
{incentive_id? : ID des Incentives (leer = alle aktiven)}
{--force : Tracking-Tabellen + Log loeschen und komplett neu aufbauen}
{--skip-repair : Kein Nachziehen von Trackings/FKs/SV-Logs (nur Summen aus bestehendem Log)}
{--verbose-details : Zeigt Details pro Teilnehmer}';
protected $description = 'Incentive-Punkte: fehlende Partner-/Abo-Trackings, FK-Reparatur, fehlende SV-Logs, Summen/Ranking; --force = kompletter Neuaufbau aus Quelldaten';
public function handle(IncentivePointsLogRepairService $repairService): int
{
if ($id = $this->argument('incentive_id')) {
$incentive = Incentive::find($id);
if (! $incentive) {
$this->error("Incentive #{$id} nicht gefunden.");
return self::FAILURE;
}
return $this->processIncentive($incentive, $repairService);
}
$incentives = Incentive::active()->get();
if ($incentives->isEmpty()) {
$this->info('Keine aktiven Incentives gefunden.');
return self::SUCCESS;
}
$exitCode = self::SUCCESS;
foreach ($incentives as $incentive) {
if ($this->processIncentive($incentive, $repairService) !== self::SUCCESS) {
$exitCode = self::FAILURE;
}
}
return $exitCode;
}
private function processIncentive(Incentive $incentive, IncentivePointsLogRepairService $repairService): int
{
$force = $this->option('force');
$skipRepair = $this->option('skip-repair');
$verbose = $this->option('verbose-details');
$this->info("=== {$incentive->name} (ID: {$incentive->id}) ===");
$this->info(" Zeitraum: {$incentive->qualification_start->format('d.m.Y')} - {$incentive->qualification_end->format('d.m.Y')}");
if ($force) {
$this->info(' Modus: FORCE (Tracking + Log aus Quelldaten neu aufbauen)');
} elseif ($skipRepair) {
$this->info(' Modus: Nur Neuberechnung (Summen/Ranking aus bestehendem Log)');
} else {
$this->info(' Modus: Tracking nachziehen + FK-Reparatur + SV-Logs + Neuberechnung');
}
$stubAdded = IncentiveParticipant::ensureConsultantsForIncentive($incentive);
if ($stubAdded > 0) {
$this->info(" Berater-Teilnehmer neu angelegt (ohne Zustimmung): {$stubAdded}");
}
$participants = $incentive->participants()->with('user', 'user.account')->get();
$this->info(" Teilnehmer: {$participants->count()}");
$this->newLine();
$stats = [
'processed' => 0,
'errors' => 0,
'with_points' => 0,
'with_partners' => 0,
'with_abos' => 0,
'tracking_partner_added' => 0,
'tracking_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;
}
}

View file

@ -0,0 +1,196 @@
<?php
namespace App\Console\Commands;
use App\Models\Incentive;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\Models\ShoppingOrder;
use App\User;
use Illuminate\Console\Command;
class IncentiveDebugTrackPartner extends Command
{
protected $signature = 'incentive:debug-track-partner {order_id : Shopping Order ID}';
protected $description = 'Debuggt trackNewPartner Schritt fuer Schritt fuer eine bestimmte Bestellung';
public function handle(): int
{
$order_id = $this->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;
}
}

View file

@ -0,0 +1,241 @@
<?php
namespace App\Console\Commands;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveNewPartner;
use App\Models\IncentivePointsLog;
use App\Models\ShoppingOrder;
use App\Models\UserSalesVolume;
use Illuminate\Console\Command;
class IncentiveDebugTrackSalesVolume extends Command
{
protected $signature = 'incentive:debug-track-sv {sv_id : UserSalesVolume ID}';
protected $description = 'Debuggt trackSalesVolume Schritt fuer Schritt fuer einen bestimmten SalesVolume-Eintrag';
public function handle(): int
{
$sv_id = $this->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;
}
}

View file

@ -0,0 +1,419 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PayoneFailedPaypalReport extends Command
{
protected $signature = 'payone:failed-paypal-report
{--from=2026-04-02 : Start-Datum (YYYY-MM-DD)}
{--to= : End-Datum (YYYY-MM-DD), Standard: heute}
{--output=storage/reports/paypal-failed-report.csv : Ausgabedatei}';
protected $description = 'Erstellt einen Schadenbericht über fehlgeschlagene PayPal-Zahlungen (Error 923)';
public function handle(): int
{
$from = $this->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);
}
}

View file

@ -0,0 +1,220 @@
<?php
namespace App\Console\Commands;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Services\AboHelper;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairMissingAboFromOrders extends Command
{
protected $signature = 'abo:repair-missing
{--fix : Reparatur ausfuehren (ohne: nur Abgleich/Vorschau)}
{--force : Mit --fix: ohne Rueckfrage (Skripte/CI)}
{--since= : Nur Bestellungen mit created_at >= (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<ShoppingOrder>
*/
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<int>
*/
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);
}
}

View file

@ -0,0 +1,129 @@
<?php
namespace App\Console\Commands;
use App\Models\ShoppingOrder;
use App\Repositories\InvoiceRepository;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Console\Command;
class RepairMissingInvoices extends Command
{
protected $signature = 'repair:missing-invoices
{--fix : Tatsaechlich reparieren (ohne Flag nur Vorschau)}
{--no-mail : Keine Rechnungs-Mails versenden}
{--since=2026-02-20 : Ab welchem Datum suchen}';
protected $description = 'Repariert fehlende Rechnungen und SalesVolumes fuer bezahlte Bestellungen (Bug: addSalesPointsVolumeUser)';
public function handle(): int
{
$since = $this->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;
}
}

View file

@ -0,0 +1,297 @@
<?php
namespace App\Console\Commands;
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 Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RetryFailedPaypalAbos extends Command
{
protected $signature = 'abo:retry-failed-paypal
{--dry-run : Nur anzeigen, keine Bestellungen ausführen}
{--abo-id= : Nur ein bestimmtes Abo erneut ausführen}';
protected $description = 'Führt Abo-Bestellungen erneut aus, die aufgrund der PayPal-Panne (Error 923) fehlgeschlagen sind';
private float $timeStart;
public function handle(): int
{
$this->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<int, UserAbo>
*/
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';
}
}

View file

@ -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';
}
}

View file

@ -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

View file

@ -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');

View file

@ -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;
}
}

View file

@ -0,0 +1,411 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\Models\IncentivePointsLog;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\Services\Incentive\IncentiveCalculationService;
use App\User;
use Request;
class IncentiveController extends Controller
{
public function __construct()
{
$this->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<string, string>, trans_description: array<string, string>, trans_terms: array<string, string>}
*/
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 '<a href="'.route('admin_incentive_show', [$incentive->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-eye"></span></a>
<a href="'.route('admin_incentive_edit', [$incentive->id]).'" class="btn icon-btn btn-sm btn-warning"><span class="fa fa-edit"></span></a>';
})
->addColumn('status_label', function (Incentive $incentive) {
return '<span class="badge badge-'.$incentive->getStatusColor().'">'.$incentive->getStatusType().'</span>';
})
->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);
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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()
{

View file

@ -1,18 +1,15 @@
<?php
namespace App\Http\Controllers;
use Carbon;
use Request;
use App\Services\Payment;
use App\Models\UserInvoice;
use App\Services\HTMLHelper;
use App\Services\Payment;
use Illuminate\Http\JsonResponse;
use Request;
class PaymentInvoiceController extends Controller
{
public function __construct()
{
$this->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 '<a href="' . route('admin_sales_users_detail', [$UserInvoice->shopping_order->id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
return '<a href="'.route('admin_sales_users_detail', [$UserInvoice->shopping_order->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
}
return '<a href="' . route('admin_sales_customers_detail', [$UserInvoice->shopping_order->id]) . '" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
return '<a href="'.route('admin_sales_customers_detail', [$UserInvoice->shopping_order->id]).'" class="btn icon-btn btn-sm btn-primary"><span class="fa fa-edit"></span></a>';
})
->addColumn('total_shipping', function (UserInvoice $UserInvoice) {
return '<span class="no-line-break">' . $UserInvoice->shopping_order->getFormattedTotalShipping() . " €</span>";
return '<span class="no-line-break">'.$UserInvoice->shopping_order->getFormattedTotalShipping().' €</span>';
})
->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 '<a href="#" data-toggle="modal" data-target="#modals-load-content" data-modal="modal-lg"
data-id="' . $UserInvoice->id . '" data-route="' . route('modal_load') . '" data-action="user-credit-status" data-view="">
<span class="badge badge-pill badge-' . $UserInvoice->getStatusColor() . '">' . $UserInvoice->getStatusType() . '</span>
data-id="'.$UserInvoice->id.'" data-route="'.route('modal_load').'" data-action="user-credit-status" data-view="">
<span class="badge badge-pill badge-'.$UserInvoice->getStatusColor().'">'.$UserInvoice->getStatusType().'</span>
</a>';
})
->addColumn('invoice', function (UserInvoice $UserInvoice) {
$ret = "";
$ret .= '<a href="' . route('storage_file', [$UserInvoice->shopping_order->id, 'invoice', 'download']) . '" class="btn btn-primary btn-xs"><i class="fa fa-download"></i></a> ';
$ret .= '<a href="' . route('storage_file', [$UserInvoice->shopping_order->id, 'invoice', 'stream']) . '" target="_blank" class="btn btn-warning btn-xs"><i class="fa fa-eye"></i></a>';
$ret = '';
$ret .= '<a href="'.route('storage_file', [$UserInvoice->shopping_order->id, 'invoice', 'download']).'" class="btn btn-primary btn-xs"><i class="fa fa-download"></i></a> ';
$ret .= '<a href="'.route('storage_file', [$UserInvoice->shopping_order->id, 'invoice', 'stream']).'" target="_blank" class="btn btn-warning btn-xs"><i class="fa fa-eye"></i></a>';
return $ret;
})
->orderColumn('id', 'id $1')

View file

@ -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);

View file

@ -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;
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\SAdmin;
use App\Http\Controllers\Controller;
use App\Services\SyS\AboOrdersOverview;
class SAdminController extends Controller
{
protected $userRepo;
public function __construct()
{
$this->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');
}
}

View file

@ -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');
}
}
}

View file

@ -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(),
]);
}

View file

@ -0,0 +1,200 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Admin\IncentiveController as AdminIncentiveController;
use App\Http\Controllers\Controller;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Models\UserAbo;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Request;
class IncentiveController extends Controller
{
/**
* Anzahl Plaetze in der User-Live-Rangliste (Gewinner-Highlight bleibt ueber max_winners, typ. Top 20).
*/
public const USER_RANKING_DISPLAY_LIMIT = 30;
public function __construct()
{
$this->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<string>
*/
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;
}
}

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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
*/

View file

@ -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;
}
}
}

View file

@ -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);
}
}

View file

@ -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 {

View file

@ -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);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AboChartSnapshot extends Model
{
protected $table = 'abo_chart_snapshots';
protected $fillable = [
'user_id',
'scope',
'year',
'month',
'count',
'calculated_at',
];
protected function casts(): array
{
return [
'user_id' => 'int',
'year' => 'int',
'month' => 'int',
'count' => 'int',
'calculated_at' => 'datetime',
];
}
}

240
app/Models/Incentive.php Normal file
View file

@ -0,0 +1,240 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Class Incentive
*
* @property int $id
* @property string $name
* @property array|null $trans_name
* @property string|null $subtitle
* @property array|null $trans_subtitle
* @property string $slug
* @property string|null $description
* @property array|null $trans_description
* @property string|null $image
* @property string|null $terms
* @property array|null $trans_terms
* @property Carbon $qualification_start
* @property Carbon $qualification_end
* @property Carbon $calculation_end
* @property int $points_partner_onetime
* @property int $points_abo_onetime
* @property int $min_direct_partners
* @property int $min_customer_abos
* @property int $max_winners
* @property int $status
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
* @property-read Collection<int, IncentiveParticipant> $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<int, array{month: int, year: int}>
*/
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;
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Class IncentiveNewAbo
*
* @property int $id
* @property int $participant_id
* @property int $user_abo_id
* @property Carbon $activated_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read IncentiveParticipant $participant
* @property-read UserAbo $userAbo
* @property-read \Illuminate\Database\Eloquent\Collection<int, IncentivePointsLog> $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');
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Class IncentiveNewPartner
*
* @property int $id
* @property int $participant_id
* @property int $user_id
* @property Carbon $registered_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read IncentiveParticipant $participant
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection<int, IncentivePointsLog> $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');
}
}

View file

@ -0,0 +1,536 @@
<?php
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Class IncentiveParticipant
*
* @property int $id
* @property int $incentive_id
* @property int $user_id
* @property Carbon|null $accepted_terms_at
* @property int $total_points
* @property int $qualified_partners
* @property int $qualified_abos
* @property bool $is_qualified
* @property int|null $rank
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Incentive $incentive
* @property-read User $user
* @property-read Collection<int, IncentivePointsLog> $pointsLog
* @property-read Collection<int, IncentiveNewPartner> $newPartners
* @property-read Collection<int, IncentiveNewAbo> $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';
}
}

View file

@ -0,0 +1,148 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Class IncentivePointsLog
*
* @property int $id
* @property int $participant_id
* @property string $type
* @property string $source_type
* @property int $source_id
* @property string $source_label
* @property int $month
* @property int $year
* @property int $points_onetime
* @property int $points_accumulated
* @property bool $is_storno
* @property int|null $storno_of_id
* @property int|null $user_sales_volume_id
* @property int|null $incentive_new_partner_id
* @property int|null $incentive_new_abo_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read IncentiveParticipant $participant
* @property-read UserSalesVolume|null $salesVolume
* @property-read IncentiveNewPartner|null $incentiveNewPartner
* @property-read IncentiveNewAbo|null $incentiveNewAbo
* @property-read IncentivePointsLog|null $stornoOf
*
* @method static \Illuminate\Database\Eloquent\Builder|IncentivePointsLog newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|IncentivePointsLog newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|IncentivePointsLog query()
*
* @mixin \Eloquent
*/
class IncentivePointsLog extends Model
{
use HasFactory;
protected $table = 'incentive_points_log';
protected $casts = [
'participant_id' => '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;
}
}

View file

@ -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');

View file

@ -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' ? '<span class="badge badge-outline-warning-dark">'.__('tables.adviser').'</span>' : '<span class="badge badge-outline-info">'.__('tables.customer').'</span>';

View file

@ -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);
}
}

View file

@ -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);
}
/**

View file

@ -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<int, int|null> 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;
}
}

View file

@ -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();

View file

@ -1,55 +1,55 @@
<?php
namespace App\Services\BusinessPlan;
use App\User;
use stdClass;
use Carbon\Carbon;
use App\Models\UserLevel;
use App\Models\UserBusiness;
use App\Services\TranslationHelper;
use App\Models\UserBusinessStructure;
use App\Models\UserLevel;
use App\User;
use Carbon\Carbon;
use stdClass;
class BusinessUserItem
{
public $businessUserItems = [];
private $date;
private $b_user;
private $user_level_active_pos;
private $b_user;
private $user_level_active_pos;
public function __construct($date)
{
$this->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};
}
}
}
}

View file

@ -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;
}
}

View file

@ -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,

View file

@ -1,108 +1,111 @@
<?php
<?php
namespace App\Services;
use App\Models\Tag;
use App\Models\DcTag;
use App\Models\FileTag;
use App\Models\DcFileTag;
use App\Models\DcTag;
class DcHelper {
class DcHelper
{
public static $points;
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'],
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 .= '<li class="dd-item" data-id="'.$tag->id.'">
<span class="pull-right">
<a href="#" class="btn btn-sm mt-1 nestable_update_btn" data-action="update-tag-active" data-target="self" data-id="'.$tag->id.'" data-url="'.route('admin_downloadcenter_item_store', ['update_ajax']).'">
' . ($tag->active ? '<i class="fa fa-eye text-success"></i>' : '<i class="fa fa-eye-slash text-danger"></i>') . '
$out = '';
foreach ($tags as $tag) {
$out .= '<li class="dd-item" data-id="'.$tag->id.'">
<div style="display: inline-block;">
<div class="ml-4">
<a href="#" class="btn btn-sm mt-1 mt-0 nestable_update_btn" data-action="update-tag-active" data-target="self" data-id="'.$tag->id.'" data-url="'.route('admin_downloadcenter_item_store', ['update_ajax']).'">
'.($tag->active ? '<i class="fa fa-eye text-success"></i>' : '<i class="fa fa-eye-slash text-danger"></i>').'
</a>
<a href="'.route('admin_downloadcenter_item_delete', ['obj' => 'tag', 'id'=> $tag->id]).'" class="btn btn-sm mt-1 nestable_list_delete"><i class="fa fa-trash text-danger"></i></a>
</span>
<a href="'.route('admin_downloadcenter_item_delete', ['obj' => 'tag', 'id' => $tag->id]).'" class="btn btn-sm mt-1 mt-0 nestable_list_delete"><i class="fa fa-trash text-danger"></i></a>
</div>
</div>
<div class="dd-handle">
'.$tag->name.'
</div>
</li>';
}
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 .= '<li class="dd-item" data-id="'.$tag->id.'">
$out = '';
foreach ($tags as $tag) {
$out .= '<li class="dd-item" data-id="'.$tag->id.'">
<div class="dd-handle dd-nodrag">
<label class="custom-control custom-checkbox m-0" for="nestable_check_'.$tag->id.'">
<input type="checkbox" class="custom-control-input" name="nestable_check[]" id="nestable_check_'.$tag->id.'" value="'.$tag->id.'" '.(array_search($tag->id, $search) !== FALSE ? 'checked="checked"' : '').'>
<input type="checkbox" class="custom-control-input" name="nestable_check[]" id="nestable_check_'.$tag->id.'" value="'.$tag->id.'" '.(array_search($tag->id, $search) !== false ? 'checked="checked"' : '').'>
<span class="custom-control-label"> '.$tag->name.' </span>
</label>
</div>
</li>';
}
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 .= '<label class="form-label" for="category_'.$category_id.'">'.$value['name'].'</label>';
$out .= '<select class="selectpicker category-filter" name="categories['.$category_id.'][]" id="category_'.$category_id.'" data-style="btn-light" data-live-search="true" multiple>';
foreach($value['items'] as $tag){
foreach ($value['items'] as $tag) {
$out .= '<option value="'.$tag->id.'">'.$tag->name.' ('.$tag->count.')</option>';
}
$out .= '</select>';
}
return $out;
}
private function getAttributesOptions($ids = array(), $all = true){
$ret = "";
return $ret;
}
private function getAttributesOptions($ids = [], $all = true)
{
$ret = '';
}
return $ret;
}
}

View file

@ -117,7 +117,9 @@ class HTMLHelper
foreach ($values as $value) {
$attr = ($value == $default) ? 'selected="selected"' : '';
$str = self::getAboStrLang($value);
$ret .= '<option value="'.$value.'" '.$attr.'>'.$str.'</option>\n';
$nextDate = AboHelper::getFirstAboDate(now(), $value);
$daysUntil = AboHelper::calendarDaysUntil(now(), $nextDate);
$ret .= '<option value="'.$value.'" data-days="'.$daysUntil.'" data-date="'.$nextDate->format('d.m.Y').'" '.$attr.'>'.$str.'</option>\n';
}
return $ret;

View file

@ -0,0 +1,54 @@
<?php
namespace App\Services\Incentive;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use Illuminate\Support\Facades\Log;
class IncentiveCalculationService
{
/**
* Full recalculation of an incentive (batch / cron / manual).
* Normal: Neuberechnung aus Tracking-Tabellen + Log.
* Force: Kompletter Neuaufbau aus Quelldaten (Users, UserAbos, UserSalesVolumes).
*/
public function recalculate(Incentive $incentive, bool $force = false): array
{
$stats = ['participants' => 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();
}
}
}

View file

@ -0,0 +1,353 @@
<?php
namespace App\Services\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\Models\IncentivePointsLog;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\User;
class IncentivePointsLogRepairService
{
/**
* Fehlende Neupartner-Tracking-Zeilen anlegen (Starterpaket / gleiche Regeln wie Neuaufbau A).
* Nutzt IncentiveTracker::trackNewPartner, wenn eine qualifizierte Bestellung existiert.
*
* @return int Anzahl nachgezogener Partner-Trackings fuer diesen Teilnehmer
*/
public function syncMissingTrackingPartners(IncentiveParticipant $participant): int
{
$incentive = $participant->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();
}
}

View file

@ -0,0 +1,373 @@
<?php
namespace App\Services\Incentive;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\Models\IncentivePointsLog;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class IncentiveTracker
{
/**
* Track a new partner registration (Starterpaket bezahlt).
* Fuegt Partner in Tracking-Tabelle ein + Log-Eintrag + Neuberechnung.
*/
public static function trackNewPartner(ShoppingOrder $shopping_order): void
{
try {
if (! $shopping_order->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,
]);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Services;
class LocaleGuard
{
/**
* @return array<int, string>
*/
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;
}
}

View file

@ -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(),
]);
}
}
}

View file

@ -1,4 +1,5 @@
<?php
/**
* This class is a wrapper to be able to send arrays of Payone request
* to the Payone platform.
@ -16,8 +17,8 @@
* You should have received a copy of the GNU General Public License
* along with Payone Connector. If not, see <http://www.gnu.org/licenses/>.
*
* @package Simple PHP Integration
* @link https://www.bspayone.com/
*
* @copyright (C) BS PAYONE GmbH 2016, 2018
* @author Florian Bender <florian.bender@bspayone.com>
* @author Timo Kuchel <timo.kuchel@bspayone.com>
@ -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 "<br>";
*/
/* echo "done.\n";
echo "Request took " . $duration . " seconds.\n";
echo "<br>";
*/
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;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Services;
use App\Models\Product;
class ProductOrderContext
{
/**
* @return list<string>
*/
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<string> $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');
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace App\Services\SyS;
use App\Models\ShoppingOrder;
use App\Models\UserAboOrder;
class AboOrdersOverview
{
/**
* Payone-/Shop-Zahlungsstatus: tatsächlich eingezogen.
*
* @var list<string>
*/
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' => '<a href='.route('admin_sales_customers_detail', [$aboOrder->shopping_order_id]).'>'.$aboOrder->shopping_order_id.'</a>',
'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;
}
}

View file

@ -0,0 +1,536 @@
<?php
namespace App\Services\SyS;
use App\Console\Commands\UserMakeAboOrder;
use App\Models\PaymentTransaction;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserShop;
use App\Services\AboHelper;
use Carbon\Carbon;
use Illuminate\Console\Command as ConsoleCommand;
use Illuminate\Console\OutputStyle;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Str;
use ReflectionMethod;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Throwable;
class PayoneCallbackTestbench
{
public static function show()
{
self::ensureAllowed();
return view('sys.tools.payone-callback-testbench', [
'fixture' => 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<string, string>
*/
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<string, string>
*/
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 23) 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<string, mixed>
*/
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);
}
}

View file

@ -1,70 +1,97 @@
<?php
namespace App\Services;
use App\Models\ShippingCountry;
use App\User;
use Illuminate\Support\Str;
use RuntimeException;
use Yard;
class UserService
{
public static $user_country;
public static $shipping_country;
public static $shipping_free = false;
public static $user_tax_free = false;
public static $user_reverse_charge = false;
public static $instance = 'shopping';
public static function getTransChange(){
public static function getTransChange()
{
$langs = config('localization.supportedLocales');
$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 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;
}
}
}

View file

@ -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;
}

View file

@ -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<static>|User wherePreDeletedAt($value)
*
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\UserBusiness> $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);

View file

@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use App\Models\Incentive;
use Illuminate\Database\Eloquent\Factories\Factory;
class IncentiveFactory extends Factory
{
protected $model = Incentive::class;
public function definition(): array
{
return [
'name' => '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]);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Database\Factories;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveParticipant;
use Illuminate\Database\Eloquent\Factories\Factory;
class IncentiveNewAboFactory extends Factory
{
protected $model = IncentiveNewAbo::class;
public function definition(): array
{
return [
'participant_id' => IncentiveParticipant::factory(),
'user_abo_id' => $this->faker->randomNumber(3),
'activated_at' => now(),
];
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class IncentiveNewPartnerFactory extends Factory
{
protected $model = IncentiveNewPartner::class;
public function definition(): array
{
return [
'participant_id' => IncentiveParticipant::factory(),
'user_id' => fn () => User::inRandomOrder()->first()?->id ?? 1,
'registered_at' => now(),
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class IncentiveParticipantFactory extends Factory
{
protected $model = IncentiveParticipant::class;
public function definition(): array
{
return [
'incentive_id' => 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,
]);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Database\Factories;
use App\Models\IncentiveParticipant;
use App\Models\IncentivePointsLog;
use Illuminate\Database\Eloquent\Factories\Factory;
class IncentivePointsLogFactory extends Factory
{
protected $model = IncentivePointsLog::class;
public function definition(): array
{
return [
'participant_id' => 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,
]);
}
}

View file

@ -1,8 +1,8 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateCountriesTable extends Migration
{
@ -35,13 +35,10 @@ class CreateCountriesTable extends Migration
$table->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();
});
}

View file

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

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('incentives', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('incentive_participants', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('incentive_points_log', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('incentive_new_partners', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('incentive_new_abos', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('user_sales_volumes', function (Blueprint $table) {
if (! Schema::hasColumn('user_sales_volumes', 'info')) {
$table->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']);
});
}
};

View file

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('incentives', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('incentives', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('incentive_points_log', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('incentive_participants', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('abo_chart_snapshots', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,53 @@
<?php
namespace Database\Seeders;
use App\Models\IncentiveParticipant;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class IncentiveParticipantSeeder extends Seeder
{
/**
* Spielt Teilnehmer fuer Incentive ID 1 ein.
* Nimmt aktive Berater (mit m_sponsor) sortiert nach Anzahl direkter Partner.
* Bereits bestehende Teilnehmer werden uebersprungen.
*/
public function run(): void
{
$incentive_id = 1;
$limit = 50;
// Bereits teilnehmende User-IDs
$existing_user_ids = IncentiveParticipant::where('incentive_id', $incentive_id)
->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).");
}
}

127
dev/2026-03-12/tasks.md Normal file
View file

@ -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) |

Binary file not shown.

View file

@ -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.*

View file

@ -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

View file

@ -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.

View file

@ -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`**

View file

@ -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

View file

@ -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"]

View file

@ -1,5 +0,0 @@
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
variables_order = EGPCS
pcov.directory = .

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -1,5 +0,0 @@
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
variables_order = EGPCS
pcov.directory = .

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -1,5 +0,0 @@
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
variables_order = EGPCS
pcov.directory = .

View file

@ -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

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more