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

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